@jonathan0823/pi-sonarqube 0.1.3 → 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/dist/api.d.ts +38 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +367 -0
- package/dist/api.js.map +1 -0
- package/dist/commands.d.ts +54 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +192 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +278 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -54
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +149 -975
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +64 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +31 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +193 -0
- package/dist/ui.js.map +1 -0
- package/package.json +1 -1
- package/src/api.ts +476 -0
- package/src/commands.ts +248 -0
- package/src/config.ts +340 -0
- package/src/index.ts +274 -1262
- package/src/types.ts +79 -0
- package/src/ui.ts +271 -0
package/src/index.ts
CHANGED
|
@@ -1,801 +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 type { AutocompleteItem, AutocompleteSuggestions } from "@earendil-works/pi-tui";
|
|
11
|
-
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
12
|
-
import { basename, dirname, relative, resolve } from "node:path";
|
|
13
6
|
import { Type } from "typebox";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// ── Internal types / state keys ───────────────────────────────────────────────
|
|
66
|
-
|
|
67
|
-
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";
|
|
68
56
|
|
|
69
57
|
const SonarToolParams = Type.Object({
|
|
70
58
|
action: StringEnum(["analyze", "issues", "open"] as const),
|
|
71
59
|
path: Type.Optional(Type.String({ description: "Target alias or project directory to analyze or inspect" })),
|
|
72
60
|
issueIndex: Type.Optional(Type.Number({ description: "1-based issue index to open" })),
|
|
73
|
-
severities: Type.Optional(
|
|
74
|
-
|
|
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
|
+
),
|
|
75
67
|
types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
|
|
76
68
|
rules: Type.Optional(Type.Array(Type.String({ description: "Rule keys to fetch" }))),
|
|
77
69
|
});
|
|
78
70
|
|
|
79
|
-
|
|
80
|
-
{ value: "analyze", label: "analyze", description: "run analysis and fetch issues" },
|
|
81
|
-
{ value: "issues", label: "issues", description: "browse the latest issues" },
|
|
82
|
-
{ value: "open", label: "open", description: "preview a specific issue" },
|
|
83
|
-
{ value: "init", label: "init", description: "configure a project target" },
|
|
84
|
-
] as const;
|
|
85
|
-
|
|
86
|
-
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
87
|
-
|
|
88
|
-
function nowIso(): string {
|
|
89
|
-
return new Date().toISOString();
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function slugify(input: string): string {
|
|
93
|
-
return (
|
|
94
|
-
input
|
|
95
|
-
.toLowerCase()
|
|
96
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
97
|
-
.replace(/^-+|-+$/g, "")
|
|
98
|
-
.slice(0, 80) || "sonarqube"
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function normalizePath(input: string, cwd: string): string {
|
|
103
|
-
const trimmed = input.trim().replace(/^@/, "");
|
|
104
|
-
return resolve(cwd, trimmed);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function parseProperties(text: string): Record<string, string> {
|
|
108
|
-
const props: Record<string, string> = {};
|
|
109
|
-
for (const rawLine of text.split(/\r?\n/)) {
|
|
110
|
-
const line = rawLine.trim();
|
|
111
|
-
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
|
|
112
|
-
const sep = line.indexOf("=");
|
|
113
|
-
if (sep < 0) continue;
|
|
114
|
-
const key = line.slice(0, sep).trim();
|
|
115
|
-
const value = line.slice(sep + 1).trim();
|
|
116
|
-
if (key) props[key] = value;
|
|
117
|
-
}
|
|
118
|
-
return props;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function normalizeServerUrl(url: string | undefined): string {
|
|
122
|
-
const fallback = "http://localhost:9000";
|
|
123
|
-
if (!url?.trim()) return fallback;
|
|
124
|
-
return url.trim().replace(/\/+$/, "");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const SONAR_SEVERITIES = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"] as const;
|
|
128
|
-
const SONAR_STATUSES = ["OPEN", "CONFIRMED", "REOPENED", "RESOLVED", "CLOSED"] as const;
|
|
129
|
-
const SONAR_TYPES = ["BUG", "VULNERABILITY", "CODE_SMELL"] as const;
|
|
130
|
-
|
|
131
|
-
function createAutocompleteItem(value: string, description?: string): AutocompleteItem {
|
|
132
|
-
return description ? { value, label: value, description } : { value, label: value };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function filterAutocompleteItems(
|
|
136
|
-
items: readonly { value: string; label: string; description?: string }[],
|
|
137
|
-
prefix: string,
|
|
138
|
-
): AutocompleteItem[] {
|
|
139
|
-
const query = prefix.trim().toLowerCase();
|
|
140
|
-
const filtered = query
|
|
141
|
-
? items.filter((item) => item.value.toLowerCase().startsWith(query) || item.label.toLowerCase().startsWith(query))
|
|
142
|
-
: [...items];
|
|
143
|
-
return filtered.map((item) => ({ value: item.value, label: item.label, description: item.description }));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function mergeAutocompleteSuggestions(
|
|
147
|
-
prefix: string,
|
|
148
|
-
current: AutocompleteSuggestions | null,
|
|
149
|
-
extraItems: AutocompleteItem[],
|
|
150
|
-
): AutocompleteSuggestions | null {
|
|
151
|
-
const merged = new Map<string, AutocompleteItem>();
|
|
152
|
-
for (const item of current?.items ?? []) merged.set(item.value, item);
|
|
153
|
-
for (const item of extraItems) merged.set(item.value, item);
|
|
154
|
-
const items = [...merged.values()];
|
|
155
|
-
return items.length > 0 ? { prefix, items } : current;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function splitSonarArgumentContext(argumentText: string): { command: string; current: string; tokens: string[] } {
|
|
159
|
-
const trimmedLeft = argumentText.replace(/^\s+/, "");
|
|
160
|
-
if (!trimmedLeft) return { command: "", current: "", tokens: [] };
|
|
161
|
-
|
|
162
|
-
const tokens = trimmedLeft.split(/\s+/);
|
|
163
|
-
const current = /\s$/.test(argumentText) ? "" : tokens.pop() ?? "";
|
|
164
|
-
return { command: tokens[0] ?? current, current, tokens: tokens.length > 0 ? tokens : current ? [current] : [] };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function createFilterCompletionList(): AutocompleteItem[] {
|
|
168
|
-
return [
|
|
169
|
-
...SONAR_SEVERITIES.flatMap((value) => [
|
|
170
|
-
createAutocompleteItem(`severity:${value}`, "severity"),
|
|
171
|
-
createAutocompleteItem(value, "severity"),
|
|
172
|
-
]),
|
|
173
|
-
...SONAR_STATUSES.flatMap((value) => [
|
|
174
|
-
createAutocompleteItem(`status:${value}`, "status"),
|
|
175
|
-
createAutocompleteItem(value, "status"),
|
|
176
|
-
]),
|
|
177
|
-
...SONAR_TYPES.flatMap((value) => [
|
|
178
|
-
createAutocompleteItem(`type:${value}`, "type"),
|
|
179
|
-
createAutocompleteItem(value, "type"),
|
|
180
|
-
]),
|
|
181
|
-
];
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function sonarArgumentCompletions(argumentText: string, issues?: SonarIssue[]): AutocompleteItem[] | null {
|
|
185
|
-
const { command, current, tokens } = splitSonarArgumentContext(argumentText);
|
|
186
|
-
const lowerCommand = command.toLowerCase();
|
|
187
|
-
const commandMatches = SONAR_COMMANDS.filter((item) => item.value.startsWith(lowerCommand));
|
|
188
|
-
|
|
189
|
-
if (!command || commandMatches.length > 1 || !SONAR_COMMANDS.some((item) => item.value === lowerCommand)) {
|
|
190
|
-
return commandMatches.length > 0 ? commandMatches.map((item) => createAutocompleteItem(item.value, item.description)) : null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
if (lowerCommand === "open") {
|
|
194
|
-
const issueItems = (issues ?? []).slice(0, 10).map((issue, index) =>
|
|
195
|
-
createAutocompleteItem(
|
|
196
|
-
String(index + 1),
|
|
197
|
-
`${issue.severity} ${issue.filePath}${issue.line ? `:${issue.line}` : ""}`,
|
|
198
|
-
),
|
|
199
|
-
);
|
|
200
|
-
const suggestions = [...issueItems, ...createFilterCompletionList()];
|
|
201
|
-
const filtered = filterAutocompleteItems(suggestions, current);
|
|
202
|
-
return filtered.length > 0 ? filtered : null;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
if (lowerCommand === "analyze" || lowerCommand === "issues") {
|
|
206
|
-
const suggestions = createFilterCompletionList();
|
|
207
|
-
const filtered = filterAutocompleteItems(suggestions, current);
|
|
208
|
-
return filtered.length > 0 ? filtered : null;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (lowerCommand === "init") {
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return tokens.length === 0 ? filterAutocompleteItems(SONAR_COMMANDS, current) : null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
219
|
-
|
|
220
|
-
function resolveProjectKey(baseDir: string, props: Record<string, string>): string {
|
|
221
|
-
const fromProps = props["sonar.projectKey"]?.trim();
|
|
222
|
-
if (fromProps) return fromProps;
|
|
223
|
-
return slugify(basename(baseDir));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
async function readOptionalText(path: string): Promise<string | undefined> {
|
|
227
|
-
try {
|
|
228
|
-
return await readFile(path, "utf8");
|
|
229
|
-
} catch {
|
|
230
|
-
return undefined;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
async function readOptionalJson<T>(path: string): Promise<T | undefined> {
|
|
235
|
-
try {
|
|
236
|
-
const text = await readFile(path, "utf8");
|
|
237
|
-
return JSON.parse(text) as T;
|
|
238
|
-
} catch {
|
|
239
|
-
return undefined;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function projectConfigPath(baseDir: string): string {
|
|
244
|
-
return resolve(baseDir, CONFIG_DIR_NAME, "sonarqube.json");
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function sonarqubeConfigDir(baseDir: string): string {
|
|
248
|
-
return resolve(baseDir, CONFIG_DIR_NAME);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Load project-local config from `.pi/sonarqube.json`.
|
|
253
|
-
* Returns undefined when the file doesn't exist or is invalid.
|
|
254
|
-
*/
|
|
255
|
-
async function loadProjectConfig(baseDir: string): Promise<SonarInitConfig | undefined> {
|
|
256
|
-
return readOptionalJson<SonarInitConfig>(projectConfigPath(baseDir));
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Save project-local config to `.pi/sonarqube.json`.
|
|
261
|
-
*/
|
|
262
|
-
async function saveProjectConfig(baseDir: string, config: SonarInitConfig): Promise<void> {
|
|
263
|
-
const dir = sonarqubeConfigDir(baseDir);
|
|
264
|
-
await mkdir(dir, { recursive: true });
|
|
265
|
-
await writeFile(projectConfigPath(baseDir), JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
interface SonarWorkspaceRegistry {
|
|
269
|
-
version: 1;
|
|
270
|
-
workspaces: Record<string, string>;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
interface ResolvedTarget {
|
|
274
|
-
baseDir: string;
|
|
275
|
-
repoRoot: string;
|
|
276
|
-
alias?: string;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const WORKSPACE_REGISTRY_FILE = "sonarqube.workspaces.json";
|
|
280
|
-
|
|
281
|
-
function workspaceRegistryPath(repoRoot: string): string {
|
|
282
|
-
return resolve(repoRoot, CONFIG_DIR_NAME, WORKSPACE_REGISTRY_FILE);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
async function hasGitRootMarker(dir: string): Promise<boolean> {
|
|
286
|
-
try {
|
|
287
|
-
const entry = await stat(resolve(dir, ".git"));
|
|
288
|
-
return entry.isDirectory() || entry.isFile();
|
|
289
|
-
} catch {
|
|
290
|
-
return false;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
async function findRepoRoot(startDir: string): Promise<string> {
|
|
295
|
-
let dir = resolve(startDir);
|
|
296
|
-
for (;;) {
|
|
297
|
-
if (await hasGitRootMarker(dir)) return dir;
|
|
298
|
-
const parent = dirname(dir);
|
|
299
|
-
if (parent === dir) return resolve(startDir);
|
|
300
|
-
dir = parent;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
async function loadWorkspaceRegistry(startDir: string): Promise<{ repoRoot: string; registry: SonarWorkspaceRegistry }> {
|
|
305
|
-
const repoRoot = await findRepoRoot(startDir);
|
|
306
|
-
const raw = await readOptionalJson<Partial<SonarWorkspaceRegistry>>(workspaceRegistryPath(repoRoot));
|
|
307
|
-
const workspaces = raw?.workspaces && typeof raw.workspaces === "object" ? { ...raw.workspaces } : {};
|
|
308
|
-
return { repoRoot, registry: { version: 1, workspaces: workspaces as Record<string, string> } };
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
async function saveWorkspaceRegistry(startDir: string, alias: string, targetDir: string): Promise<void> {
|
|
312
|
-
const { repoRoot, registry } = await loadWorkspaceRegistry(startDir);
|
|
313
|
-
registry.workspaces[alias] = relative(repoRoot, targetDir) || ".";
|
|
314
|
-
await mkdir(sonarqubeConfigDir(repoRoot), { recursive: true });
|
|
315
|
-
await writeFile(workspaceRegistryPath(repoRoot), JSON.stringify(registry, null, 2) + "\n", "utf8");
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function looksLikePath(token: string): boolean {
|
|
319
|
-
return (
|
|
320
|
-
token === "." ||
|
|
321
|
-
token === ".." ||
|
|
322
|
-
token.startsWith("./") ||
|
|
323
|
-
token.startsWith("../") ||
|
|
324
|
-
token.startsWith("/") ||
|
|
325
|
-
token.startsWith("~") ||
|
|
326
|
-
token.includes("/") ||
|
|
327
|
-
token.includes("\\")
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function knownTargets(registry: SonarWorkspaceRegistry): string[] {
|
|
332
|
-
return Object.keys(registry.workspaces).sort();
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
async function resolveTarget(ctx: ExtensionContext, targetInput?: string): Promise<ResolvedTarget> {
|
|
336
|
-
const { repoRoot, registry } = await loadWorkspaceRegistry(ctx.cwd);
|
|
337
|
-
if (!targetInput) {
|
|
338
|
-
return { baseDir: ctx.cwd, repoRoot };
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const aliasTarget = registry.workspaces[targetInput];
|
|
342
|
-
if (aliasTarget) {
|
|
343
|
-
const baseDir = resolve(repoRoot, aliasTarget);
|
|
344
|
-
const baseDirStat = await stat(baseDir).catch(() => undefined);
|
|
345
|
-
if (!baseDirStat?.isDirectory()) {
|
|
346
|
-
throw new Error(`SonarQube target "${targetInput}" points to a missing directory: ${baseDir}`);
|
|
347
|
-
}
|
|
348
|
-
return { baseDir, repoRoot, alias: targetInput };
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (looksLikePath(targetInput)) {
|
|
352
|
-
const baseDir = normalizePath(targetInput, ctx.cwd);
|
|
353
|
-
const baseDirStat = await stat(baseDir).catch(() => undefined);
|
|
354
|
-
if (!baseDirStat?.isDirectory()) {
|
|
355
|
-
throw new Error(`SonarQube target path not found: ${baseDir}`);
|
|
356
|
-
}
|
|
357
|
-
return { baseDir, repoRoot };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const known = knownTargets(registry);
|
|
361
|
-
if (known.length > 0) {
|
|
362
|
-
throw new Error(
|
|
363
|
-
`Unknown SonarQube target "${targetInput}". Known targets: ${known.join(", ")}. Use /sonarqube init <alias> <path> to add one.`,
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
throw new Error(`Unknown SonarQube target "${targetInput}". Use /sonarqube init <alias> <path> to add one.`);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
async function resolveInitTarget(
|
|
371
|
-
ctx: ExtensionContext,
|
|
372
|
-
alias?: string,
|
|
373
|
-
targetInput?: string,
|
|
374
|
-
): Promise<ResolvedTarget> {
|
|
375
|
-
if (targetInput) {
|
|
376
|
-
const resolved = await resolveTarget(ctx, targetInput);
|
|
377
|
-
return { ...resolved, alias };
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const { repoRoot } = await loadWorkspaceRegistry(ctx.cwd);
|
|
381
|
-
return { baseDir: ctx.cwd, repoRoot, alias };
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
export { projectConfigPath, loadProjectConfig, saveProjectConfig, sonarqubeConfigDir };
|
|
385
|
-
|
|
386
|
-
// ── Config resolution (env > .pi/sonarqube.json > sonar-project.properties) ────
|
|
387
|
-
|
|
388
|
-
async function resolveConfig(ctx: ExtensionContext, inputPath?: string): Promise<SonarProjectConfig> {
|
|
389
|
-
const { baseDir } = await resolveTarget(ctx, inputPath);
|
|
390
|
-
const propertiesPath = resolve(baseDir, "sonar-project.properties");
|
|
391
|
-
const propertiesText = await readOptionalText(propertiesPath);
|
|
392
|
-
const props = propertiesText ? parseProperties(propertiesText) : {};
|
|
393
|
-
const projectCfg = await loadProjectConfig(baseDir);
|
|
394
|
-
|
|
395
|
-
const serverUrl = normalizeServerUrl(
|
|
396
|
-
process.env.SONARQUBE_URL ??
|
|
397
|
-
process.env.SONAR_HOST_URL ??
|
|
398
|
-
projectCfg?.serverUrl?.trim() ??
|
|
399
|
-
props["sonar.host.url"],
|
|
400
|
-
);
|
|
401
|
-
const projectKey =
|
|
402
|
-
process.env.SONAR_PROJECT_KEY?.trim() ||
|
|
403
|
-
projectCfg?.projectKey?.trim() ||
|
|
404
|
-
props["sonar.projectKey"]?.trim() ||
|
|
405
|
-
resolveProjectKey(baseDir, props);
|
|
406
|
-
const token =
|
|
407
|
-
process.env.SONARQUBE_TOKEN?.trim() ||
|
|
408
|
-
process.env.SONAR_TOKEN?.trim() ||
|
|
409
|
-
projectCfg?.token?.trim() ||
|
|
410
|
-
props["sonar.token"]?.trim();
|
|
411
|
-
|
|
412
|
-
return {
|
|
413
|
-
baseDir,
|
|
414
|
-
serverUrl,
|
|
415
|
-
projectKey,
|
|
416
|
-
token: token || undefined,
|
|
417
|
-
hasProperties: Boolean(propertiesText),
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
// ── Auth / API helpers ────────────────────────────────────────────────────────
|
|
422
|
-
|
|
423
|
-
function authHeader(token?: string): string | undefined {
|
|
424
|
-
if (!token) return undefined;
|
|
425
|
-
return `Basic ${Buffer.from(`${token}:`).toString("base64")}`;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
async function fetchJson<T>(url: string, token?: string, signal?: AbortSignal): Promise<T> {
|
|
429
|
-
const headers: Record<string, string> = { Accept: "application/json" };
|
|
430
|
-
const auth = authHeader(token);
|
|
431
|
-
if (auth) headers.Authorization = auth;
|
|
432
|
-
|
|
433
|
-
const response = await fetch(url, { headers, signal });
|
|
434
|
-
if (!response.ok) {
|
|
435
|
-
const body = await response.text().catch(() => "");
|
|
436
|
-
const suffix = body.trim() ? `: ${body.trim().slice(0, 240)}` : "";
|
|
437
|
-
throw new Error(`SonarQube API request failed (${response.status} ${response.statusText}) for ${url}${suffix}`);
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
return (await response.json()) as T;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function toIssuePath(component: string, projectKey: string): string {
|
|
444
|
-
const prefix = `${projectKey}:`;
|
|
445
|
-
if (component.startsWith(prefix)) return component.slice(prefix.length);
|
|
446
|
-
const colon = component.indexOf(":");
|
|
447
|
-
return colon >= 0 ? component.slice(colon + 1) : component;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function normalizeIssueList(values?: string[], uppercase = false): string[] | undefined {
|
|
451
|
-
const cleaned = values
|
|
452
|
-
?.map((value) => value.trim())
|
|
453
|
-
.filter(Boolean)
|
|
454
|
-
.map((value) => (uppercase ? value.toUpperCase() : value));
|
|
455
|
-
if (!cleaned?.length) return undefined;
|
|
456
|
-
return [...new Set(cleaned)];
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function normalizeIssueFilters(filters?: SonarIssueFetchOptions): SonarIssueFetchOptions | undefined {
|
|
460
|
-
const normalized = {
|
|
461
|
-
severities: normalizeIssueList(filters?.severities, true),
|
|
462
|
-
statuses: normalizeIssueList(filters?.statuses, true),
|
|
463
|
-
types: normalizeIssueList(filters?.types, true),
|
|
464
|
-
rules: normalizeIssueList(filters?.rules),
|
|
465
|
-
};
|
|
466
|
-
return normalized.severities || normalized.statuses || normalized.types || normalized.rules ? normalized : undefined;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function mergeIssueFilters(...filters: Array<Partial<SonarIssueFetchOptions> | undefined>): SonarIssueFetchOptions | undefined {
|
|
470
|
-
const merged: SonarIssueFetchOptions = {};
|
|
471
|
-
for (const filter of filters) {
|
|
472
|
-
if (!filter) continue;
|
|
473
|
-
if (filter.severities?.length) merged.severities = [...(merged.severities ?? []), ...filter.severities];
|
|
474
|
-
if (filter.statuses?.length) merged.statuses = [...(merged.statuses ?? []), ...filter.statuses];
|
|
475
|
-
if (filter.types?.length) merged.types = [...(merged.types ?? []), ...filter.types];
|
|
476
|
-
if (filter.rules?.length) merged.rules = [...(merged.rules ?? []), ...filter.rules];
|
|
477
|
-
}
|
|
478
|
-
return normalizeIssueFilters(merged);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function splitFilterValues(value: string): string[] {
|
|
482
|
-
return value.split(",").map((part) => part.trim()).filter(Boolean);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function parseIssueFilterToken(token: string): Partial<SonarIssueFetchOptions> | undefined {
|
|
486
|
-
const trimmed = token.trim();
|
|
487
|
-
if (!trimmed) return undefined;
|
|
488
|
-
|
|
489
|
-
const explicit = trimmed.match(/^([^:=]+)[:=](.+)$/);
|
|
490
|
-
if (explicit) {
|
|
491
|
-
const key = explicit[1].trim().toLowerCase();
|
|
492
|
-
const values = splitFilterValues(explicit[2]);
|
|
493
|
-
if (values.length === 0) return undefined;
|
|
494
|
-
|
|
495
|
-
if (key === "severity" || key === "severities") return { severities: values.map((value) => value.toUpperCase()) };
|
|
496
|
-
if (key === "status" || key === "statuses") return { statuses: values.map((value) => value.toUpperCase()) };
|
|
497
|
-
if (key === "type" || key === "types") return { types: values.map((value) => value.toUpperCase()) };
|
|
498
|
-
if (key === "rule" || key === "rules") return { rules: values };
|
|
499
|
-
return undefined;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
const upper = trimmed.toUpperCase();
|
|
503
|
-
if (SONAR_SEVERITIES.includes(upper as (typeof SONAR_SEVERITIES)[number])) return { severities: [upper] };
|
|
504
|
-
if (SONAR_STATUSES.includes(upper as (typeof SONAR_STATUSES)[number])) return { statuses: [upper] };
|
|
505
|
-
if (SONAR_TYPES.includes(upper as (typeof SONAR_TYPES)[number])) return { types: [upper] };
|
|
506
|
-
return undefined;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
interface ParsedSonarIssueArgs {
|
|
510
|
-
targetInput?: string;
|
|
511
|
-
issueIndex?: number;
|
|
512
|
-
filters?: SonarIssueFetchOptions;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
function parseSonarIssueArgs(tokens: string[], allowIssueIndex = false): ParsedSonarIssueArgs {
|
|
516
|
-
const filters: Array<Partial<SonarIssueFetchOptions> | undefined> = [];
|
|
517
|
-
let targetInput: string | undefined;
|
|
518
|
-
let issueIndex: number | undefined;
|
|
519
|
-
|
|
520
|
-
for (const token of tokens) {
|
|
521
|
-
if (!token) continue;
|
|
522
|
-
if (allowIssueIndex && issueIndex === undefined && /^\d+$/.test(token)) {
|
|
523
|
-
issueIndex = Number(token);
|
|
524
|
-
continue;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
const parsedFilter = parseIssueFilterToken(token);
|
|
528
|
-
if (parsedFilter) {
|
|
529
|
-
filters.push(parsedFilter);
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
if (!targetInput) {
|
|
534
|
-
targetInput = token;
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
return {
|
|
539
|
-
targetInput,
|
|
540
|
-
issueIndex,
|
|
541
|
-
filters: mergeIssueFilters(...filters),
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function issueFilterLabel(filters?: SonarIssueFetchOptions): string {
|
|
546
|
-
if (!filters) return "";
|
|
547
|
-
const parts = [
|
|
548
|
-
filters.severities?.length ? `severities=${filters.severities.join(",")}` : "",
|
|
549
|
-
filters.statuses?.length ? `statuses=${filters.statuses.join(",")}` : "",
|
|
550
|
-
filters.types?.length ? `types=${filters.types.join(",")}` : "",
|
|
551
|
-
filters.rules?.length ? `rules=${filters.rules.join(",")}` : "",
|
|
552
|
-
].filter(Boolean);
|
|
553
|
-
return parts.join(" • ");
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
function severitySortRank(severity: string): number {
|
|
557
|
-
switch (severity.toUpperCase()) {
|
|
558
|
-
case "BLOCKER":
|
|
559
|
-
return 0;
|
|
560
|
-
case "CRITICAL":
|
|
561
|
-
return 1;
|
|
562
|
-
case "MAJOR":
|
|
563
|
-
return 2;
|
|
564
|
-
case "MINOR":
|
|
565
|
-
return 3;
|
|
566
|
-
case "INFO":
|
|
567
|
-
return 4;
|
|
568
|
-
default:
|
|
569
|
-
return 5;
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function compareIssuesForContext(left: SonarIssue, right: SonarIssue): number {
|
|
574
|
-
const severityDiff = severitySortRank(left.severity) - severitySortRank(right.severity);
|
|
575
|
-
if (severityDiff !== 0) return severityDiff;
|
|
576
|
-
const fileDiff = left.filePath.localeCompare(right.filePath);
|
|
577
|
-
if (fileDiff !== 0) return fileDiff;
|
|
578
|
-
const leftLine = left.line ?? Number.POSITIVE_INFINITY;
|
|
579
|
-
const rightLine = right.line ?? Number.POSITIVE_INFINITY;
|
|
580
|
-
if (leftLine !== rightLine) return leftLine - rightLine;
|
|
581
|
-
return left.message.localeCompare(right.message);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// ── Issue helpers ─────────────────────────────────────────────────────────────
|
|
585
|
-
|
|
586
|
-
function buildIssuePreview(baseDir: string, issue: SonarIssue, radius = 3): Promise<string> {
|
|
587
|
-
return (async () => {
|
|
588
|
-
const filePath = resolve(baseDir, issue.filePath);
|
|
589
|
-
const content = await readFile(filePath, "utf8");
|
|
590
|
-
const lines = content.split(/\r?\n/);
|
|
591
|
-
const target = issue.line ?? 1;
|
|
592
|
-
const start = Math.max(1, target - radius);
|
|
593
|
-
const end = Math.min(lines.length, target + radius);
|
|
594
|
-
const width = String(end).length;
|
|
595
|
-
|
|
596
|
-
const rendered: string[] = [];
|
|
597
|
-
for (let line = start; line <= end; line++) {
|
|
598
|
-
const marker = line === target ? ">" : " ";
|
|
599
|
-
const num = String(line).padStart(width, " ");
|
|
600
|
-
rendered.push(`${marker} ${num} | ${lines[line - 1] ?? ""}`);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
return rendered.join("\n");
|
|
604
|
-
})();
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
function formatIssue(issue: SonarIssue, index?: number): string {
|
|
608
|
-
const loc = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
|
|
609
|
-
const prefix = index !== undefined ? `${String(index).padStart(2, "0")}. ` : "";
|
|
610
|
-
const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
|
|
611
|
-
return `${prefix}${issue.severity} ${loc} — ${rule} — ${issue.message}`;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function formatSummary(state: SonarAnalysisState): string {
|
|
615
|
-
const issueCount = state.totalIssues;
|
|
616
|
-
const noIssues = issueCount === 0;
|
|
617
|
-
const filterLabel = state.filters ? ` (${issueFilterLabel(state.filters)})` : "";
|
|
618
|
-
return noIssues
|
|
619
|
-
? `SonarQube: no issues found for ${state.projectKey}${filterLabel}`
|
|
620
|
-
: `SonarQube: ${issueCount} issue${issueCount === 1 ? "" : "s"} found for ${state.projectKey}${filterLabel}`;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function formatReport(state: SonarAnalysisState): string {
|
|
624
|
-
const lines = [
|
|
625
|
-
`Project: ${state.projectKey}`,
|
|
626
|
-
`Server: ${state.serverUrl}`,
|
|
627
|
-
`Base dir: ${state.baseDir}`,
|
|
628
|
-
`Issues: ${state.totalIssues}`,
|
|
629
|
-
];
|
|
630
|
-
|
|
631
|
-
if (state.filters) {
|
|
632
|
-
lines.push(`Filters: ${issueFilterLabel(state.filters)}`);
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
if (state.issues.length === 0) {
|
|
636
|
-
lines.push("No open issues found.");
|
|
637
|
-
return lines.join("\n");
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
lines.push("");
|
|
641
|
-
for (const [index, issue] of state.issues.slice(0, 10).entries()) {
|
|
642
|
-
lines.push(formatIssue(issue, index + 1));
|
|
643
|
-
}
|
|
644
|
-
if (state.issues.length > 10) {
|
|
645
|
-
lines.push(`... ${state.issues.length - 10} more`);
|
|
646
|
-
}
|
|
647
|
-
return lines.join("\n");
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
function severityColor(theme: Theme, severity: string): string {
|
|
653
|
-
switch (severity.toLowerCase()) {
|
|
654
|
-
case "blocker":
|
|
655
|
-
case "critical":
|
|
656
|
-
return theme.fg("error", severity);
|
|
657
|
-
case "major":
|
|
658
|
-
return theme.fg("warning", severity);
|
|
659
|
-
case "minor":
|
|
660
|
-
return theme.fg("accent", severity);
|
|
661
|
-
default:
|
|
662
|
-
return theme.fg("muted", severity);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
const ANALYSIS_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
667
|
-
const ANALYSIS_WIDGET_KEY = "sonarqube-analysis";
|
|
668
|
-
|
|
669
|
-
interface AnalysisUiHandle {
|
|
670
|
-
setPhase(phase: string): void;
|
|
671
|
-
stop(): void;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function startAnalysisUi(ctx: ExtensionContext, projectKey: string): AnalysisUiHandle {
|
|
675
|
-
let frameIndex = 0;
|
|
676
|
-
let phase = "Starting...";
|
|
677
|
-
const render = () => {
|
|
678
|
-
const frame = ANALYSIS_SPINNER[frameIndex];
|
|
679
|
-
ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, [`${frame} SonarQube ${projectKey}`, phase]);
|
|
680
|
-
};
|
|
681
|
-
|
|
682
|
-
ctx.ui.setWorkingMessage(`Analyzing ${projectKey}...`);
|
|
683
|
-
ctx.ui.setWorkingIndicator({ frames: ANALYSIS_SPINNER as unknown as string[], intervalMs: 80 });
|
|
684
|
-
render();
|
|
685
|
-
|
|
686
|
-
const timer = setInterval(() => {
|
|
687
|
-
frameIndex = (frameIndex + 1) % ANALYSIS_SPINNER.length;
|
|
688
|
-
render();
|
|
689
|
-
}, 80);
|
|
690
|
-
|
|
691
|
-
return {
|
|
692
|
-
setPhase(nextPhase: string) {
|
|
693
|
-
phase = nextPhase;
|
|
694
|
-
render();
|
|
695
|
-
},
|
|
696
|
-
stop() {
|
|
697
|
-
clearInterval(timer);
|
|
698
|
-
ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, undefined);
|
|
699
|
-
ctx.ui.setWorkingMessage();
|
|
700
|
-
ctx.ui.setWorkingIndicator();
|
|
701
|
-
},
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// ── Issue Browser UI component ────────────────────────────────────────────────
|
|
706
|
-
|
|
707
|
-
class IssueBrowser {
|
|
708
|
-
private selected = 0;
|
|
709
|
-
private cachedWidth: number | undefined;
|
|
710
|
-
private cachedLines: string[] | undefined;
|
|
711
|
-
|
|
712
|
-
constructor(
|
|
713
|
-
private readonly state: SonarAnalysisState,
|
|
714
|
-
private readonly theme: Theme,
|
|
715
|
-
private readonly done: (result: number | null) => void,
|
|
716
|
-
) {}
|
|
717
|
-
|
|
718
|
-
invalidate(): void {
|
|
719
|
-
this.cachedWidth = undefined;
|
|
720
|
-
this.cachedLines = undefined;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
handleInput(data: string): void {
|
|
724
|
-
if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
|
|
725
|
-
this.done(null);
|
|
726
|
-
return;
|
|
727
|
-
}
|
|
728
|
-
if (matchesKey(data, Key.up)) {
|
|
729
|
-
this.selected = Math.max(0, this.selected - 1);
|
|
730
|
-
this.invalidate();
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
if (matchesKey(data, Key.down)) {
|
|
734
|
-
this.selected = Math.min(this.state.issues.length - 1, this.selected + 1);
|
|
735
|
-
this.invalidate();
|
|
736
|
-
return;
|
|
737
|
-
}
|
|
738
|
-
if (matchesKey(data, Key.enter)) {
|
|
739
|
-
this.done(this.selected);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
render(width: number): string[] {
|
|
744
|
-
if (this.cachedLines && this.cachedWidth === width) {
|
|
745
|
-
return this.cachedLines;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
const lines: string[] = [];
|
|
749
|
-
const title = this.theme.fg("accent", this.theme.bold(" SonarQube Issues "));
|
|
750
|
-
const subtitleText = this.state.filters
|
|
751
|
-
? `${this.state.projectKey} • ${this.state.totalIssues} issue(s) • ${issueFilterLabel(this.state.filters)}`
|
|
752
|
-
: `${this.state.projectKey} • ${this.state.totalIssues} issue(s)`;
|
|
753
|
-
const subtitle = this.theme.fg("dim", subtitleText);
|
|
754
|
-
lines.push(truncateToWidth(title, width));
|
|
755
|
-
lines.push(truncateToWidth(subtitle, width));
|
|
756
|
-
lines.push("");
|
|
757
|
-
|
|
758
|
-
const pageSize = Math.min(20, this.state.issues.length);
|
|
759
|
-
const halfWindow = Math.floor(pageSize / 2);
|
|
760
|
-
const maxStart = Math.max(0, this.state.issues.length - pageSize);
|
|
761
|
-
const start = Math.max(0, Math.min(this.selected - halfWindow, maxStart));
|
|
762
|
-
const end = Math.min(this.state.issues.length, start + pageSize);
|
|
763
|
-
const visibleIssues = this.state.issues.slice(start, end);
|
|
764
|
-
|
|
765
|
-
if (visibleIssues.length === 0) {
|
|
766
|
-
lines.push(truncateToWidth(this.theme.fg("success", "No open issues found."), width));
|
|
767
|
-
} else {
|
|
768
|
-
if (start > 0) {
|
|
769
|
-
lines.push(truncateToWidth(this.theme.fg("dim", `... ${start} more above`), width));
|
|
770
|
-
}
|
|
771
|
-
visibleIssues.forEach((issue, index) => {
|
|
772
|
-
const issueIndex = start + index;
|
|
773
|
-
const selected = issueIndex === this.selected;
|
|
774
|
-
const marker = selected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
775
|
-
const location = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
|
|
776
|
-
const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
|
|
777
|
-
const severity = severityColor(this.theme, issue.severity);
|
|
778
|
-
const summary = `${marker} ${String(issueIndex + 1).padStart(2, "0")}. ${severity} ${this.theme.fg(
|
|
779
|
-
"accent",
|
|
780
|
-
location,
|
|
781
|
-
)} ${this.theme.fg("muted", rule)} ${this.theme.fg("text", `— ${issue.message}`)}`;
|
|
782
|
-
lines.push(truncateToWidth(summary, width));
|
|
783
|
-
});
|
|
784
|
-
if (end < this.state.issues.length) {
|
|
785
|
-
lines.push(truncateToWidth(this.theme.fg("dim", `... ${this.state.issues.length - end} more below`), width));
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
lines.push("");
|
|
790
|
-
lines.push(truncateToWidth(this.theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"), width));
|
|
791
|
-
|
|
792
|
-
this.cachedWidth = width;
|
|
793
|
-
this.cachedLines = lines;
|
|
794
|
-
return lines;
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// ── State restoration ─────────────────────────────────────────────────────────
|
|
71
|
+
// ── State restoration ───────────────────────────────────────────────────────
|
|
799
72
|
|
|
800
73
|
async function restoreState(
|
|
801
74
|
pi: ExtensionAPI,
|
|
@@ -810,275 +83,11 @@ async function restoreState(
|
|
|
810
83
|
statesByBaseDir.set(latest.baseDir, latest);
|
|
811
84
|
}
|
|
812
85
|
}
|
|
813
|
-
|
|
814
86
|
ctx.ui.setStatus("sonarqube", undefined);
|
|
815
87
|
return latest;
|
|
816
88
|
}
|
|
817
89
|
|
|
818
|
-
// ──
|
|
819
|
-
|
|
820
|
-
function extractScannerHint(output: string, config: SonarProjectConfig): string {
|
|
821
|
-
const lines = output.toLowerCase();
|
|
822
|
-
|
|
823
|
-
if (/status code 401|status 401|unauthorized/i.test(lines)) {
|
|
824
|
-
const defaultHint = config.serverUrl === "http://localhost:9000"
|
|
825
|
-
? " (default: http://localhost:9000)"
|
|
826
|
-
: "";
|
|
827
|
-
return [
|
|
828
|
-
`Authentication failed for ${config.serverUrl}${defaultHint}.`,
|
|
829
|
-
"Run `/sonarqube init` to set up your server URL and token, then retry.",
|
|
830
|
-
"Or set SONARQUBE_TOKEN in your environment.",
|
|
831
|
-
].join(" ");
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
if (/status code 403|status 403|forbidden/i.test(lines)) {
|
|
835
|
-
return [
|
|
836
|
-
`Access denied by ${config.serverUrl}.`,
|
|
837
|
-
'Your token may not have the required permissions ("Execute Analysis").',
|
|
838
|
-
"Run `/sonarqube init` to update the token or check server permissions.",
|
|
839
|
-
].join(" ");
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
if (/connect econnrefused|connect refused|connect timeout|enotfound|econnreset/i.test(lines)) {
|
|
843
|
-
return [
|
|
844
|
-
`Cannot reach SonarQube server at ${config.serverUrl}.`,
|
|
845
|
-
"Is the server running? Run `/sonarqube init` to check the server URL.",
|
|
846
|
-
"Default: http://localhost:9000",
|
|
847
|
-
].join(" ");
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
if (/not found|unknown url/i.test(lines) && (config.serverUrl === "http://localhost:9000" || /localhost/i.test(config.serverUrl))) {
|
|
851
|
-
return [
|
|
852
|
-
`SonarQube API at ${config.serverUrl} returned "not found".`,
|
|
853
|
-
"The default server URL is http://localhost:9000. Run `/sonarqube init` to configure.",
|
|
854
|
-
].join(" ");
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
return "";
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// ── SonarScanner interaction ─────────────────────────────────────────────────
|
|
861
|
-
|
|
862
|
-
async function runScanner(pi: ExtensionAPI, config: SonarProjectConfig, signal?: AbortSignal): Promise<string> {
|
|
863
|
-
const args = [
|
|
864
|
-
`-Dsonar.projectKey=${config.projectKey}`,
|
|
865
|
-
`-Dsonar.projectBaseDir=${config.baseDir}`,
|
|
866
|
-
`-Dsonar.host.url=${config.serverUrl}`,
|
|
867
|
-
...(config.hasProperties ? [] : [`-Dsonar.sources=.`]),
|
|
868
|
-
`-Dsonar.scm.disabled=true`,
|
|
869
|
-
];
|
|
870
|
-
if (config.token) {
|
|
871
|
-
args.push(`-Dsonar.token=${config.token}`);
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
const result = await pi.exec("sonar-scanner", args, {
|
|
875
|
-
cwd: config.baseDir,
|
|
876
|
-
signal,
|
|
877
|
-
timeout: 30 * 60 * 1000,
|
|
878
|
-
});
|
|
879
|
-
if (result.code !== 0) {
|
|
880
|
-
const output = [result.stdout, result.stderr]
|
|
881
|
-
.filter(Boolean)
|
|
882
|
-
.join("\n")
|
|
883
|
-
.trim();
|
|
884
|
-
const hint = extractScannerHint(output, config);
|
|
885
|
-
const detail = output ? `\n\n${output.slice(-4000)}` : "";
|
|
886
|
-
const message = hint
|
|
887
|
-
? `SonarQube scan failed for ${config.projectKey}. ${hint}`
|
|
888
|
-
: `SonarQube scan failed for ${config.projectKey}${detail}`;
|
|
889
|
-
throw new Error(message);
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
return result.stdout || result.stderr;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
async function readReportTask(baseDir: string): Promise<Record<string, string>> {
|
|
896
|
-
const path = resolve(baseDir, ".scannerwork", "report-task.txt");
|
|
897
|
-
const text = await readFile(path, "utf8");
|
|
898
|
-
return parseProperties(text);
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
async function waitForAnalysis(
|
|
902
|
-
serverUrl: string,
|
|
903
|
-
token: string | undefined,
|
|
904
|
-
ceTaskUrl: string,
|
|
905
|
-
signal?: AbortSignal,
|
|
906
|
-
): Promise<string | undefined> {
|
|
907
|
-
for (let attempt = 0; attempt < 120; attempt++) {
|
|
908
|
-
const result = await fetchJson<{
|
|
909
|
-
task: { status: string; analysisId?: string; errorMessage?: string };
|
|
910
|
-
}>(ceTaskUrl, token, signal);
|
|
911
|
-
const status = result.task.status.toUpperCase();
|
|
912
|
-
if (status === "SUCCESS") return result.task.analysisId;
|
|
913
|
-
if (status === "FAILED") {
|
|
914
|
-
throw new Error(`SonarQube analysis failed: ${result.task.errorMessage || "unknown error"}`);
|
|
915
|
-
}
|
|
916
|
-
if (status === "CANCELED") {
|
|
917
|
-
throw new Error("SonarQube analysis was canceled by the server");
|
|
918
|
-
}
|
|
919
|
-
await new Promise((resolveWait) => setTimeout(resolveWait, 2000));
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
throw new Error(`Timed out waiting for SonarQube analysis at ${serverUrl}`);
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
async function fetchRuleName(
|
|
926
|
-
serverUrl: string,
|
|
927
|
-
token: string | undefined,
|
|
928
|
-
ruleKey: string,
|
|
929
|
-
signal?: AbortSignal,
|
|
930
|
-
): Promise<string | undefined> {
|
|
931
|
-
try {
|
|
932
|
-
const result = await fetchJson<{ rule: { name?: string } }>(
|
|
933
|
-
`${serverUrl}/api/rules/show?key=${encodeURIComponent(ruleKey)}`,
|
|
934
|
-
token,
|
|
935
|
-
signal,
|
|
936
|
-
);
|
|
937
|
-
return result.rule.name;
|
|
938
|
-
} catch {
|
|
939
|
-
return undefined;
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
async function fetchIssues(
|
|
944
|
-
serverUrl: string,
|
|
945
|
-
token: string | undefined,
|
|
946
|
-
projectKey: string,
|
|
947
|
-
signal?: AbortSignal,
|
|
948
|
-
filters?: SonarIssueFetchOptions,
|
|
949
|
-
): Promise<SonarIssue[]> {
|
|
950
|
-
const normalizedFilters = normalizeIssueFilters(filters);
|
|
951
|
-
const issues: SonarIssue[] = [];
|
|
952
|
-
const ruleNames = new Map<string, string | undefined>();
|
|
953
|
-
let page = 1;
|
|
954
|
-
let total = 0;
|
|
955
|
-
|
|
956
|
-
for (;;) {
|
|
957
|
-
const url = new URL("/api/issues/search", serverUrl);
|
|
958
|
-
url.searchParams.set("projects", projectKey);
|
|
959
|
-
url.searchParams.set("resolved", "false");
|
|
960
|
-
url.searchParams.set("ps", "100");
|
|
961
|
-
url.searchParams.set("p", String(page));
|
|
962
|
-
if (normalizedFilters?.severities?.length) {
|
|
963
|
-
url.searchParams.set("severities", normalizedFilters.severities.join(","));
|
|
964
|
-
}
|
|
965
|
-
if (normalizedFilters?.statuses?.length) {
|
|
966
|
-
url.searchParams.set("statuses", normalizedFilters.statuses.join(","));
|
|
967
|
-
}
|
|
968
|
-
if (normalizedFilters?.types?.length) {
|
|
969
|
-
url.searchParams.set("types", normalizedFilters.types.join(","));
|
|
970
|
-
}
|
|
971
|
-
if (normalizedFilters?.rules?.length) {
|
|
972
|
-
url.searchParams.set("rules", normalizedFilters.rules.join(","));
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
const result = await fetchJson<{
|
|
976
|
-
total: number;
|
|
977
|
-
issues: Array<{
|
|
978
|
-
key: string;
|
|
979
|
-
rule: string;
|
|
980
|
-
severity: string;
|
|
981
|
-
message: string;
|
|
982
|
-
component: string;
|
|
983
|
-
line?: number;
|
|
984
|
-
status?: string;
|
|
985
|
-
}>;
|
|
986
|
-
}>(url.toString(), token, signal);
|
|
987
|
-
|
|
988
|
-
total = result.total;
|
|
989
|
-
for (const raw of result.issues) {
|
|
990
|
-
if (!ruleNames.has(raw.rule)) {
|
|
991
|
-
ruleNames.set(raw.rule, await fetchRuleName(serverUrl, token, raw.rule, signal));
|
|
992
|
-
}
|
|
993
|
-
issues.push({
|
|
994
|
-
key: raw.key,
|
|
995
|
-
rule: raw.rule,
|
|
996
|
-
ruleName: ruleNames.get(raw.rule),
|
|
997
|
-
severity: raw.severity,
|
|
998
|
-
message: raw.message,
|
|
999
|
-
filePath: toIssuePath(raw.component, projectKey),
|
|
1000
|
-
line: typeof raw.line === "number" ? raw.line : null,
|
|
1001
|
-
status: raw.status,
|
|
1002
|
-
});
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
if (issues.length >= total || result.issues.length === 0) break;
|
|
1006
|
-
page += 1;
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
if (normalizedFilters && issues.length > 1) {
|
|
1010
|
-
issues.sort(compareIssuesForContext);
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
return issues;
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
function createAnalysisState(
|
|
1017
|
-
config: Pick<SonarProjectConfig, "baseDir" | "serverUrl" | "projectKey">,
|
|
1018
|
-
issues: SonarIssue[],
|
|
1019
|
-
extras: Partial<Pick<SonarAnalysisState, "dashboardUrl" | "ceTaskUrl" | "analysisId" | "filters">> = {},
|
|
1020
|
-
): SonarAnalysisState {
|
|
1021
|
-
return {
|
|
1022
|
-
version: 1,
|
|
1023
|
-
analyzedAt: nowIso(),
|
|
1024
|
-
baseDir: config.baseDir,
|
|
1025
|
-
serverUrl: config.serverUrl,
|
|
1026
|
-
projectKey: config.projectKey,
|
|
1027
|
-
dashboardUrl: extras.dashboardUrl,
|
|
1028
|
-
ceTaskUrl: extras.ceTaskUrl,
|
|
1029
|
-
analysisId: extras.analysisId,
|
|
1030
|
-
filters: extras.filters,
|
|
1031
|
-
totalIssues: issues.length,
|
|
1032
|
-
issues,
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
async function hasProjectAnalyses(
|
|
1037
|
-
serverUrl: string,
|
|
1038
|
-
token: string | undefined,
|
|
1039
|
-
projectKey: string,
|
|
1040
|
-
signal?: AbortSignal,
|
|
1041
|
-
): Promise<boolean> {
|
|
1042
|
-
try {
|
|
1043
|
-
const result = await fetchJson<{ analyses?: Array<{ key?: string }> }>(
|
|
1044
|
-
`${serverUrl}/api/project_analyses/search?project=${encodeURIComponent(projectKey)}&ps=1`,
|
|
1045
|
-
token,
|
|
1046
|
-
signal,
|
|
1047
|
-
);
|
|
1048
|
-
return (result.analyses?.length ?? 0) > 0;
|
|
1049
|
-
} catch {
|
|
1050
|
-
return false;
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
async function loadProjectIssuesFromApi(
|
|
1055
|
-
ctx: ExtensionContext,
|
|
1056
|
-
inputPath?: string,
|
|
1057
|
-
filters?: SonarIssueFetchOptions,
|
|
1058
|
-
): Promise<SonarAnalysisState> {
|
|
1059
|
-
const config = await resolveConfig(ctx, inputPath);
|
|
1060
|
-
const normalizedFilters = normalizeIssueFilters(filters);
|
|
1061
|
-
const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal, normalizedFilters);
|
|
1062
|
-
return createAnalysisState(config, issues, { filters: normalizedFilters });
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
async function resolveTargetState(
|
|
1066
|
-
ctx: ExtensionContext,
|
|
1067
|
-
statesByBaseDir: Map<string, SonarAnalysisState>,
|
|
1068
|
-
targetInput?: string,
|
|
1069
|
-
filters?: SonarIssueFetchOptions,
|
|
1070
|
-
): Promise<SonarAnalysisState | undefined> {
|
|
1071
|
-
const resolvedTarget = await resolveTarget(ctx, targetInput);
|
|
1072
|
-
try {
|
|
1073
|
-
return await loadProjectIssuesFromApi(ctx, targetInput, filters);
|
|
1074
|
-
} catch (error) {
|
|
1075
|
-
const cached = statesByBaseDir.get(resolvedTarget.baseDir);
|
|
1076
|
-
if (cached) return cached;
|
|
1077
|
-
throw error;
|
|
1078
|
-
}
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
// ── Analysis orchestration ────────────────────────────────────────────────────
|
|
90
|
+
// ── Analysis orchestration ──────────────────────────────────────────────────
|
|
1082
91
|
|
|
1083
92
|
async function analyzeProject(
|
|
1084
93
|
pi: ExtensionAPI,
|
|
@@ -1103,12 +112,19 @@ async function analyzeProject(
|
|
|
1103
112
|
const ceTaskUrl = report["ceTaskUrl"];
|
|
1104
113
|
const dashboardUrl = report["dashboardUrl"];
|
|
1105
114
|
const analysisId = ceTaskUrl
|
|
1106
|
-
? (analysisUi.setPhase("Waiting for SonarQube analysis..."),
|
|
115
|
+
? (analysisUi.setPhase("Waiting for SonarQube analysis..."),
|
|
116
|
+
await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal))
|
|
1107
117
|
: undefined;
|
|
1108
118
|
|
|
1109
119
|
analysisUi.setPhase("Fetching issues...");
|
|
1110
120
|
const normalizedFilters = normalizeIssueFilters(filters);
|
|
1111
|
-
const issues = await fetchIssues(
|
|
121
|
+
const issues = await fetchIssues(
|
|
122
|
+
config.serverUrl,
|
|
123
|
+
config.token,
|
|
124
|
+
config.projectKey,
|
|
125
|
+
ctx.signal,
|
|
126
|
+
normalizedFilters,
|
|
127
|
+
);
|
|
1112
128
|
|
|
1113
129
|
const state = createAnalysisState(config, issues, {
|
|
1114
130
|
dashboardUrl,
|
|
@@ -1126,150 +142,129 @@ async function analyzeProject(
|
|
|
1126
142
|
}
|
|
1127
143
|
}
|
|
1128
144
|
|
|
1129
|
-
// ──
|
|
145
|
+
// ── Init command helpers (extracted to reduce cognitive complexity) ─────────
|
|
1130
146
|
|
|
1131
|
-
async function
|
|
147
|
+
async function confirmOverwrite(
|
|
1132
148
|
ctx: ExtensionCommandContext,
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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;
|
|
1139
165
|
}
|
|
1140
166
|
|
|
1141
|
-
async function
|
|
167
|
+
async function collectInitInputs(
|
|
1142
168
|
ctx: ExtensionCommandContext,
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
): Promise<
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
await ctx.ui.editor(title, preview);
|
|
1150
|
-
return;
|
|
1151
|
-
}
|
|
1152
|
-
ctx.ui.notify(`${title}\n${preview}`, "info");
|
|
1153
|
-
}
|
|
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");
|
|
1154
175
|
|
|
1155
|
-
|
|
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));
|
|
179
|
+
|
|
180
|
+
const tokenInput = await ctx.ui.input("SonarQube token (optional, press Enter to skip)", existing?.token ?? "");
|
|
181
|
+
if (tokenInput === undefined) return undefined;
|
|
1156
182
|
|
|
1157
|
-
|
|
1158
|
-
alias?: string;
|
|
1159
|
-
targetInput?: string;
|
|
183
|
+
return { serverUrl, projectKey, token: tokenInput.trim() || undefined };
|
|
1160
184
|
}
|
|
1161
185
|
|
|
1162
186
|
async function initCommandHandler(ctx: ExtensionCommandContext, options: InitCommandOptions = {}): Promise<void> {
|
|
1163
187
|
const { baseDir, repoRoot } = await resolveInitTarget(ctx, options.alias, options.targetInput);
|
|
1164
188
|
const existing = await loadProjectConfig(baseDir);
|
|
1165
189
|
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
"Overwrite?",
|
|
1169
|
-
`SonarQube config already exists at ${baseDir}:\n Server: ${existing.serverUrl}\n Project: ${existing.projectKey}\n\nOverwrite?`,
|
|
1170
|
-
);
|
|
1171
|
-
if (!ok) {
|
|
1172
|
-
if (ctx.hasUI) ctx.ui.notify("Init cancelled.", "info");
|
|
1173
|
-
return;
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
const serverUrlInput = await ctx.ui.editor("SonarQube server URL", existing?.serverUrl ?? "http://localhost:9000");
|
|
1178
|
-
if (serverUrlInput === undefined) return;
|
|
1179
|
-
const serverUrl = normalizeServerUrl(serverUrlInput || existing?.serverUrl || "http://localhost:9000");
|
|
1180
|
-
|
|
1181
|
-
const projectKeyInput = await ctx.ui.input("SonarQube project key", existing?.projectKey ?? basename(baseDir));
|
|
1182
|
-
if (projectKeyInput === undefined) return;
|
|
1183
|
-
const projectKey = projectKeyInput || existing?.projectKey || slugify(basename(baseDir));
|
|
190
|
+
const confirmed = existing ? await confirmOverwrite(ctx, baseDir, existing) : true;
|
|
191
|
+
if (!confirmed) return;
|
|
1184
192
|
|
|
1185
|
-
const
|
|
1186
|
-
if (
|
|
1187
|
-
const token = tokenInput.trim() || undefined;
|
|
193
|
+
const inputs = await collectInitInputs(ctx, existing, baseDir);
|
|
194
|
+
if (!inputs) return;
|
|
1188
195
|
|
|
1189
|
-
await saveProjectConfig(baseDir,
|
|
1190
|
-
|
|
1191
|
-
projectKey,
|
|
1192
|
-
token,
|
|
1193
|
-
});
|
|
196
|
+
await saveProjectConfig(baseDir, inputs);
|
|
197
|
+
const propertiesState = await ensureDefaultSonarProjectProperties(baseDir);
|
|
1194
198
|
|
|
1195
199
|
if (options.alias) {
|
|
1196
200
|
await saveWorkspaceRegistry(repoRoot, options.alias, baseDir);
|
|
1197
201
|
}
|
|
1198
202
|
|
|
1199
203
|
if (ctx.hasUI) {
|
|
1200
|
-
|
|
1201
|
-
ctx.ui.notify(`SonarQube config saved${targetLabel} to ${projectConfigPath(baseDir)}`, "info");
|
|
204
|
+
notifyInitResult(ctx, baseDir, propertiesState, options.alias);
|
|
1202
205
|
}
|
|
1203
206
|
}
|
|
1204
207
|
|
|
1205
|
-
function
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
"Autocomplete:",
|
|
1220
|
-
" type /sonarqube and press Tab to complete the subcommand or filters",
|
|
1221
|
-
"",
|
|
1222
|
-
"Defaults:",
|
|
1223
|
-
" config is stored in .pi/sonarqube.json",
|
|
1224
|
-
" use project paths directly (no alias needed)",
|
|
1225
|
-
].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");
|
|
1226
222
|
}
|
|
1227
223
|
|
|
1228
|
-
|
|
1229
|
-
|
|
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);
|
|
1230
228
|
}
|
|
1231
229
|
|
|
1232
|
-
function
|
|
1233
|
-
return
|
|
230
|
+
function renderEmptyResult(summary: string, theme: Theme): Text {
|
|
231
|
+
return new Text(theme.fg("success", summary), 0, 0);
|
|
1234
232
|
}
|
|
1235
233
|
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
}
|
|
1257
|
-
return { action: "init", alias: tokens[1], targetInput: tokens[2] };
|
|
1258
|
-
}
|
|
1259
|
-
if (head === "issues" || head === "view") {
|
|
1260
|
-
return { action: "issues", ...parseSonarIssueArgs(tokens.slice(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);
|
|
1261
254
|
}
|
|
1262
|
-
|
|
1263
|
-
|
|
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)));
|
|
1264
260
|
}
|
|
1265
|
-
if (
|
|
1266
|
-
|
|
261
|
+
if (!expanded && state.issues.length > visible.length) {
|
|
262
|
+
lines.push(theme.fg("dim", `... ${state.issues.length - visible.length} more`));
|
|
1267
263
|
}
|
|
1268
|
-
|
|
1269
|
-
return { action: "analyze", ...parseSonarIssueArgs(tokens) };
|
|
264
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
1270
265
|
}
|
|
1271
266
|
|
|
1272
|
-
// ── Extension entrypoint
|
|
267
|
+
// ── Extension entrypoint ────────────────────────────────────────────────────
|
|
1273
268
|
|
|
1274
269
|
export default function sonarqube(pi: ExtensionAPI) {
|
|
1275
270
|
const statesByBaseDir = new Map<string, SonarAnalysisState>();
|
|
@@ -1292,10 +287,8 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1292
287
|
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
1293
288
|
const line = lines[cursorLine] ?? "";
|
|
1294
289
|
const beforeCursor = line.slice(0, cursorCol);
|
|
1295
|
-
const match =
|
|
1296
|
-
if (!match)
|
|
1297
|
-
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
1298
|
-
}
|
|
290
|
+
const match = /^\/sonarqube(?:\s+(.*))?$/.exec(beforeCursor);
|
|
291
|
+
if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
1299
292
|
|
|
1300
293
|
const argumentText = match[1] ?? "";
|
|
1301
294
|
const { current: currentToken } = splitSonarArgumentContext(argumentText);
|
|
@@ -1315,6 +308,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1315
308
|
}));
|
|
1316
309
|
}
|
|
1317
310
|
});
|
|
311
|
+
|
|
1318
312
|
pi.on("session_tree", async (_event, ctx) => {
|
|
1319
313
|
await restore(ctx);
|
|
1320
314
|
});
|
|
@@ -1342,10 +336,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1342
336
|
if (params.action === "analyze") {
|
|
1343
337
|
const state = await analyzeProject(pi, ctx, params.path, filters);
|
|
1344
338
|
rememberState(state);
|
|
1345
|
-
return {
|
|
1346
|
-
content: [{ type: "text", text: formatReport(state) }],
|
|
1347
|
-
details: state,
|
|
1348
|
-
};
|
|
339
|
+
return { content: [{ type: "text", text: formatReport(state) }], details: state };
|
|
1349
340
|
}
|
|
1350
341
|
|
|
1351
342
|
const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path, filters);
|
|
@@ -1359,10 +350,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1359
350
|
rememberState(targetState);
|
|
1360
351
|
|
|
1361
352
|
if (params.action === "issues") {
|
|
1362
|
-
return {
|
|
1363
|
-
content: [{ type: "text", text: formatReport(targetState) }],
|
|
1364
|
-
details: targetState,
|
|
1365
|
-
};
|
|
353
|
+
return { content: [{ type: "text", text: formatReport(targetState) }], details: targetState };
|
|
1366
354
|
}
|
|
1367
355
|
|
|
1368
356
|
const index = params.issueIndex ?? 1;
|
|
@@ -1380,8 +368,8 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1380
368
|
details: { ...targetState, selectedIssue: issue },
|
|
1381
369
|
};
|
|
1382
370
|
} catch (error) {
|
|
1383
|
-
const
|
|
1384
|
-
return { content: [{ type: "text", text:
|
|
371
|
+
const msg = sonarErrorMessage(error);
|
|
372
|
+
return { content: [{ type: "text", text: msg }], details: { error: msg } };
|
|
1385
373
|
}
|
|
1386
374
|
},
|
|
1387
375
|
|
|
@@ -1403,37 +391,28 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1403
391
|
},
|
|
1404
392
|
|
|
1405
393
|
renderResult(result, { expanded }, theme, _context) {
|
|
1406
|
-
const state =
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
if (state
|
|
1412
|
-
|
|
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);
|
|
1413
402
|
}
|
|
1414
403
|
|
|
1415
|
-
if (
|
|
1416
|
-
|
|
1417
|
-
const text = result.content[0]?.type === "text" ? result.content[0].text : formatIssue(issue);
|
|
1418
|
-
const preview = expanded ? text.split("\n").slice(1).join("\n") : "";
|
|
1419
|
-
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);
|
|
1420
406
|
}
|
|
1421
407
|
|
|
1422
|
-
if (
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
+
}
|
|
1427
413
|
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
visible.forEach((issue, index) => {
|
|
1431
|
-
lines.push(theme.fg("muted", formatIssue(issue, index + 1)));
|
|
1432
|
-
});
|
|
1433
|
-
if (!expanded && state.issues.length > visible.length) {
|
|
1434
|
-
lines.push(theme.fg("dim", `... ${state.issues.length - visible.length} more`));
|
|
1435
|
-
}
|
|
1436
|
-
return new Text(lines.join("\n"), 0, 0);
|
|
414
|
+
if ("issues" in state) {
|
|
415
|
+
return renderIssueListResult(state, expanded, theme);
|
|
1437
416
|
}
|
|
1438
417
|
|
|
1439
418
|
const text = result.content[0];
|
|
@@ -1442,72 +421,25 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1442
421
|
});
|
|
1443
422
|
|
|
1444
423
|
pi.registerCommand("sonarqube", {
|
|
1445
|
-
description:
|
|
1446
|
-
"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",
|
|
1447
425
|
getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues),
|
|
1448
426
|
handler: async (args, ctx) => {
|
|
1449
427
|
try {
|
|
1450
428
|
const parsed = parseCommandArgs(args);
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
// --- Init ---
|
|
1461
|
-
if (parsed.action === "init") {
|
|
1462
|
-
await initCommandHandler(ctx, { alias: parsed.alias, targetInput: parsed.targetInput });
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
// --- Analyze ---
|
|
1467
|
-
if (parsed.action === "analyze") {
|
|
1468
|
-
const state = await analyzeProject(pi, ctx, parsed.targetInput, parsed.filters);
|
|
1469
|
-
rememberState(state);
|
|
1470
|
-
if (ctx.hasUI) {
|
|
1471
|
-
const scope = targetLabel(parsed.targetInput);
|
|
1472
|
-
ctx.ui.notify(`${formatSummary(state)}. Use /sonarqube issues${scope} to browse.`, state.issues.length === 0 ? "info" : "warning");
|
|
1473
|
-
}
|
|
1474
|
-
return;
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput, parsed.filters);
|
|
1478
|
-
if (!targetState) {
|
|
1479
|
-
if (ctx.hasUI) {
|
|
1480
|
-
ctx.ui.notify(
|
|
1481
|
-
`No SonarQube analysis found for this target. Run /sonarqube analyze${targetLabel(parsed.targetInput)} first.`,
|
|
1482
|
-
"warning",
|
|
1483
|
-
);
|
|
1484
|
-
}
|
|
1485
|
-
return;
|
|
1486
|
-
}
|
|
1487
|
-
rememberState(targetState);
|
|
1488
|
-
|
|
1489
|
-
// --- Issues ---
|
|
1490
|
-
if (parsed.action === "issues") {
|
|
1491
|
-
if (targetState.issues.length === 0) {
|
|
1492
|
-
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);
|
|
1493
438
|
return;
|
|
1494
439
|
}
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
const issue = targetState.issues[choice];
|
|
1498
|
-
if (issue) await openIssuePreview(ctx, targetState, issue);
|
|
1499
|
-
}
|
|
1500
|
-
return;
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
// --- Open ---
|
|
1504
|
-
const index = parsed.issueIndex ?? 1;
|
|
1505
|
-
const issue = targetState.issues[index - 1];
|
|
1506
|
-
if (!issue) {
|
|
1507
|
-
if (ctx.hasUI) ctx.ui.notify(`Issue #${index} was not found.`, "error");
|
|
1508
|
-
return;
|
|
440
|
+
default:
|
|
441
|
+
await commandIssuesOrOpen(pi, ctx, statesByBaseDir, parsed, rememberState);
|
|
1509
442
|
}
|
|
1510
|
-
await openIssuePreview(ctx, targetState, issue);
|
|
1511
443
|
} catch (error) {
|
|
1512
444
|
if (ctx.hasUI) {
|
|
1513
445
|
ctx.ui.notify(sonarErrorMessage(error), "error");
|
|
@@ -1518,3 +450,83 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
1518
450
|
},
|
|
1519
451
|
});
|
|
1520
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";
|