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