@jonathan0823/pi-sonarqube 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +882 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -0
- package/src/index.ts +1161 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
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
|
+
import { Type } from "typebox";
|
|
13
|
+
|
|
14
|
+
// ── Public types ──────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export type SonarAction = "analyze" | "issues" | "open" | "init";
|
|
17
|
+
|
|
18
|
+
export interface SonarIssue {
|
|
19
|
+
key: string;
|
|
20
|
+
rule: string;
|
|
21
|
+
ruleName?: string;
|
|
22
|
+
severity: string;
|
|
23
|
+
message: string;
|
|
24
|
+
filePath: string;
|
|
25
|
+
line: number | null;
|
|
26
|
+
status?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SonarAnalysisState {
|
|
30
|
+
version: 1;
|
|
31
|
+
analyzedAt: string;
|
|
32
|
+
baseDir: string;
|
|
33
|
+
serverUrl: string;
|
|
34
|
+
projectKey: string;
|
|
35
|
+
dashboardUrl?: string;
|
|
36
|
+
ceTaskUrl?: string;
|
|
37
|
+
analysisId?: string;
|
|
38
|
+
totalIssues: number;
|
|
39
|
+
issues: SonarIssue[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SonarProjectConfig {
|
|
43
|
+
baseDir: string;
|
|
44
|
+
serverUrl: string;
|
|
45
|
+
projectKey: string;
|
|
46
|
+
token?: string;
|
|
47
|
+
hasProperties: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SonarInitConfig {
|
|
51
|
+
serverUrl: string;
|
|
52
|
+
projectKey: string;
|
|
53
|
+
token?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Internal types / state keys ───────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const STATE_TYPE = "sonarqube-analysis-state";
|
|
59
|
+
|
|
60
|
+
const SonarToolParams = Type.Object({
|
|
61
|
+
action: StringEnum(["analyze", "issues", "open"] as const),
|
|
62
|
+
path: Type.Optional(Type.String({ description: "Target alias or project directory to analyze or inspect" })),
|
|
63
|
+
issueIndex: Type.Optional(Type.Number({ description: "1-based issue index to open" })),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
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 ─────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
async function restoreState(
|
|
507
|
+
pi: ExtensionAPI,
|
|
508
|
+
ctx: ExtensionContext,
|
|
509
|
+
statesByBaseDir: Map<string, SonarAnalysisState>,
|
|
510
|
+
): Promise<SonarAnalysisState | undefined> {
|
|
511
|
+
statesByBaseDir.clear();
|
|
512
|
+
let latest: SonarAnalysisState | undefined;
|
|
513
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
514
|
+
if (entry.type === "custom" && entry.customType === STATE_TYPE && entry.data) {
|
|
515
|
+
latest = entry.data as SonarAnalysisState;
|
|
516
|
+
statesByBaseDir.set(latest.baseDir, latest);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (latest) {
|
|
521
|
+
ctx.ui.setStatus("sonarqube", formatSummary(latest));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return latest;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── Error hints ───────────────────────────────────────────────────────────────
|
|
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 ────────────────────────────────────────────────────
|
|
764
|
+
|
|
765
|
+
async function analyzeProject(
|
|
766
|
+
pi: ExtensionAPI,
|
|
767
|
+
ctx: ExtensionContext,
|
|
768
|
+
inputPath?: string,
|
|
769
|
+
): Promise<SonarAnalysisState> {
|
|
770
|
+
const config = await resolveConfig(ctx, inputPath);
|
|
771
|
+
const baseDirStat = await stat(config.baseDir).catch(() => undefined);
|
|
772
|
+
if (!baseDirStat?.isDirectory()) {
|
|
773
|
+
throw new Error(`Project directory not found: ${config.baseDir}`);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
ctx.ui.setWorkingMessage(`Running SonarQube analysis for ${config.projectKey}`);
|
|
777
|
+
ctx.ui.setWorkingIndicator({
|
|
778
|
+
frames: [".", "..", "...", ".."],
|
|
779
|
+
intervalMs: 180,
|
|
780
|
+
});
|
|
781
|
+
ctx.ui.setStatus("sonarqube", `Analyzing ${config.projectKey}`);
|
|
782
|
+
|
|
783
|
+
try {
|
|
784
|
+
await runScanner(pi, config, ctx.signal);
|
|
785
|
+
const report = await readReportTask(config.baseDir);
|
|
786
|
+
const ceTaskUrl = report["ceTaskUrl"];
|
|
787
|
+
const dashboardUrl = report["dashboardUrl"];
|
|
788
|
+
const analysisId = ceTaskUrl
|
|
789
|
+
? await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal)
|
|
790
|
+
: undefined;
|
|
791
|
+
const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal);
|
|
792
|
+
|
|
793
|
+
const state = createAnalysisState(config, issues, {
|
|
794
|
+
dashboardUrl,
|
|
795
|
+
ceTaskUrl,
|
|
796
|
+
analysisId,
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
pi.appendEntry(STATE_TYPE, state);
|
|
800
|
+
ctx.ui.setStatus("sonarqube", formatSummary(state));
|
|
801
|
+
return state;
|
|
802
|
+
} catch (error) {
|
|
803
|
+
ctx.ui.setStatus("sonarqube", undefined);
|
|
804
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
805
|
+
} finally {
|
|
806
|
+
ctx.ui.setWorkingMessage();
|
|
807
|
+
ctx.ui.setWorkingIndicator();
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ── UI helpers ────────────────────────────────────────────────────────────────
|
|
812
|
+
|
|
813
|
+
async function showIssueBrowser(
|
|
814
|
+
ctx: ExtensionCommandContext,
|
|
815
|
+
state: SonarAnalysisState,
|
|
816
|
+
): Promise<number | null> {
|
|
817
|
+
if (ctx.mode !== "tui") return null;
|
|
818
|
+
if (state.issues.length === 0) return null;
|
|
819
|
+
|
|
820
|
+
return await ctx.ui.custom<number | null>((_tui, theme, _kb, done) => new IssueBrowser(state, theme, done));
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async function openIssuePreview(
|
|
824
|
+
ctx: ExtensionCommandContext,
|
|
825
|
+
state: SonarAnalysisState,
|
|
826
|
+
issue: SonarIssue,
|
|
827
|
+
): Promise<void> {
|
|
828
|
+
const preview = await buildIssuePreview(state.baseDir, issue);
|
|
829
|
+
const title = `${issue.filePath}${issue.line ? `:${issue.line}` : ""}`;
|
|
830
|
+
ctx.ui.setStatus("sonarqube", title);
|
|
831
|
+
if (ctx.mode === "tui") {
|
|
832
|
+
await ctx.ui.editor(title, preview);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
ctx.ui.notify(`${title}\n${preview}`, "info");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ── Init command ──────────────────────────────────────────────────────────────
|
|
839
|
+
|
|
840
|
+
interface InitCommandOptions {
|
|
841
|
+
alias?: string;
|
|
842
|
+
targetInput?: string;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function initCommandHandler(ctx: ExtensionCommandContext, options: InitCommandOptions = {}): Promise<void> {
|
|
846
|
+
const { baseDir, repoRoot } = await resolveInitTarget(ctx, options.alias, options.targetInput);
|
|
847
|
+
const existing = await loadProjectConfig(baseDir);
|
|
848
|
+
|
|
849
|
+
if (existing) {
|
|
850
|
+
const ok = await ctx.ui.confirm(
|
|
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
|
+
}
|
|
859
|
+
|
|
860
|
+
const serverUrlInput = await ctx.ui.editor("SonarQube server URL", existing?.serverUrl ?? "http://localhost:9000");
|
|
861
|
+
if (serverUrlInput === undefined) return;
|
|
862
|
+
const serverUrl = normalizeServerUrl(serverUrlInput || existing?.serverUrl || "http://localhost:9000");
|
|
863
|
+
|
|
864
|
+
const projectKeyInput = await ctx.ui.input("SonarQube project key", existing?.projectKey ?? basename(baseDir));
|
|
865
|
+
if (projectKeyInput === undefined) return;
|
|
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
|
+
});
|
|
877
|
+
|
|
878
|
+
if (options.alias) {
|
|
879
|
+
await saveWorkspaceRegistry(repoRoot, options.alias, baseDir);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (ctx.hasUI) {
|
|
883
|
+
const targetLabel = options.alias ? ` (${options.alias})` : "";
|
|
884
|
+
ctx.ui.notify(`SonarQube config saved${targetLabel} to ${projectConfigPath(baseDir)}`, "info");
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function helpText(): string {
|
|
889
|
+
return [
|
|
890
|
+
"SonarQube commands:",
|
|
891
|
+
"",
|
|
892
|
+
" /sonarqube init [alias] [path] configure target and optional alias",
|
|
893
|
+
" /sonarqube analyze [target] run analysis for alias or path",
|
|
894
|
+
" /sonarqube issues [target] browse latest issues for target",
|
|
895
|
+
" /sonarqube open [target] <n> preview issue #n for target",
|
|
896
|
+
" /sonarqube show this help",
|
|
897
|
+
"",
|
|
898
|
+
"Defaults:",
|
|
899
|
+
" config is stored in .pi/sonarqube.json",
|
|
900
|
+
" monorepo aliases live in .pi/sonarqube.workspaces.json",
|
|
901
|
+
].join("\n");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function targetLabel(targetInput?: string): string {
|
|
905
|
+
return targetInput ? ` ${targetInput}` : "";
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function sonarErrorMessage(error: unknown): string {
|
|
909
|
+
return error instanceof Error ? error.message : String(error);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// ── Command arg parsing ───────────────────────────────────────────────────────
|
|
913
|
+
|
|
914
|
+
type ParsedSonarCommand =
|
|
915
|
+
| { action: "help" }
|
|
916
|
+
| { action: "init"; alias?: string; targetInput?: string }
|
|
917
|
+
| { action: "analyze"; targetInput?: string }
|
|
918
|
+
| { action: "issues"; targetInput?: string }
|
|
919
|
+
| { action: "open"; targetInput?: string; issueIndex?: number };
|
|
920
|
+
|
|
921
|
+
function parseCommandArgs(args: string): ParsedSonarCommand {
|
|
922
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
923
|
+
if (tokens.length === 0) return { action: "help" };
|
|
924
|
+
|
|
925
|
+
const head = tokens[0].toLowerCase();
|
|
926
|
+
if (head === "init") {
|
|
927
|
+
if (tokens.length === 1) return { action: "init" };
|
|
928
|
+
if (tokens.length === 2) {
|
|
929
|
+
return looksLikePath(tokens[1])
|
|
930
|
+
? { action: "init", targetInput: tokens[1] }
|
|
931
|
+
: { action: "init", alias: tokens[1] };
|
|
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] };
|
|
937
|
+
}
|
|
938
|
+
if (head === "open") {
|
|
939
|
+
if (tokens.length === 2 && /^\d+$/.test(tokens[1])) {
|
|
940
|
+
return { action: "open", issueIndex: Number(tokens[1]) };
|
|
941
|
+
}
|
|
942
|
+
const targetInput = tokens[1];
|
|
943
|
+
const maybeIndex = tokens[2];
|
|
944
|
+
return {
|
|
945
|
+
action: "open",
|
|
946
|
+
targetInput,
|
|
947
|
+
issueIndex: maybeIndex && /^\d+$/.test(maybeIndex) ? Number(maybeIndex) : undefined,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
if (head === "analyze" || head === "run") {
|
|
951
|
+
return { action: "analyze", targetInput: tokens[1] };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return { action: "analyze", targetInput: tokens[0] };
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ── Extension entrypoint ──────────────────────────────────────────────────────
|
|
958
|
+
|
|
959
|
+
export default function sonarqube(pi: ExtensionAPI) {
|
|
960
|
+
const statesByBaseDir = new Map<string, SonarAnalysisState>();
|
|
961
|
+
let latestState: SonarAnalysisState | undefined;
|
|
962
|
+
|
|
963
|
+
const rememberState = (state: SonarAnalysisState) => {
|
|
964
|
+
statesByBaseDir.set(state.baseDir, state);
|
|
965
|
+
latestState = state;
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const restore = async (ctx: ExtensionContext) => {
|
|
969
|
+
latestState = await restoreState(pi, ctx, statesByBaseDir);
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
973
|
+
await restore(ctx);
|
|
974
|
+
});
|
|
975
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
976
|
+
await restore(ctx);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
pi.registerTool({
|
|
980
|
+
name: "sonarqube",
|
|
981
|
+
label: "SonarQube",
|
|
982
|
+
description: "Run local SonarQube analysis and inspect the latest issues.",
|
|
983
|
+
promptSnippet: "Run local SonarQube analysis or inspect issue results",
|
|
984
|
+
promptGuidelines: [
|
|
985
|
+
"Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
|
|
986
|
+
],
|
|
987
|
+
parameters: SonarToolParams,
|
|
988
|
+
|
|
989
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
990
|
+
try {
|
|
991
|
+
if (params.action === "analyze") {
|
|
992
|
+
const state = await analyzeProject(pi, ctx, params.path);
|
|
993
|
+
rememberState(state);
|
|
994
|
+
return {
|
|
995
|
+
content: [{ type: "text", text: formatReport(state) }],
|
|
996
|
+
details: state,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path);
|
|
1001
|
+
if (!targetState) {
|
|
1002
|
+
return {
|
|
1003
|
+
content: [{ type: "text", text: "No SonarQube analysis has been run for this target yet." }],
|
|
1004
|
+
details: { error: "No SonarQube analysis has been run for this target yet." },
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
rememberState(targetState);
|
|
1009
|
+
|
|
1010
|
+
if (params.action === "issues") {
|
|
1011
|
+
return {
|
|
1012
|
+
content: [{ type: "text", text: formatReport(targetState) }],
|
|
1013
|
+
details: targetState,
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const index = params.issueIndex ?? 1;
|
|
1018
|
+
const issue = targetState.issues[index - 1];
|
|
1019
|
+
if (!issue) {
|
|
1020
|
+
return {
|
|
1021
|
+
content: [{ type: "text", text: `Issue #${index} was not found.` }],
|
|
1022
|
+
details: { error: `Issue #${index} was not found.` },
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const preview = await buildIssuePreview(targetState.baseDir, issue);
|
|
1027
|
+
return {
|
|
1028
|
+
content: [{ type: "text", text: `${formatIssue(issue, index)}\n\n${preview}` }],
|
|
1029
|
+
details: { ...targetState, selectedIssue: issue },
|
|
1030
|
+
};
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
const message = sonarErrorMessage(error);
|
|
1033
|
+
return { content: [{ type: "text", text: message }], details: { error: message } };
|
|
1034
|
+
}
|
|
1035
|
+
},
|
|
1036
|
+
|
|
1037
|
+
renderCall(args, theme, _context) {
|
|
1038
|
+
return new Text(
|
|
1039
|
+
`${theme.fg("toolTitle", theme.bold("sonarqube"))} ${theme.fg("muted", args.action)}${
|
|
1040
|
+
args.path ? ` ${theme.fg("accent", args.path)}` : ""
|
|
1041
|
+
}`,
|
|
1042
|
+
0,
|
|
1043
|
+
0,
|
|
1044
|
+
);
|
|
1045
|
+
},
|
|
1046
|
+
|
|
1047
|
+
renderResult(result, { expanded }, theme, _context) {
|
|
1048
|
+
const state =
|
|
1049
|
+
result.details as
|
|
1050
|
+
| SonarAnalysisState
|
|
1051
|
+
| { error?: string; selectedIssue?: SonarIssue }
|
|
1052
|
+
| undefined;
|
|
1053
|
+
if (state && "error" in state && state.error) {
|
|
1054
|
+
return new Text(theme.fg("error", state.error), 0, 0);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (state && "selectedIssue" in state && state.selectedIssue) {
|
|
1058
|
+
const issue = state.selectedIssue;
|
|
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);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (state && "issues" in state) {
|
|
1065
|
+
const summary = formatSummary(state);
|
|
1066
|
+
if (state.issues.length === 0) {
|
|
1067
|
+
return new Text(theme.fg("success", summary), 0, 0);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const visible = expanded ? state.issues : state.issues.slice(0, 5);
|
|
1071
|
+
const lines = [theme.fg("accent", summary)];
|
|
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);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const text = result.content[0];
|
|
1082
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
1083
|
+
},
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
pi.registerCommand("sonarqube", {
|
|
1087
|
+
description:
|
|
1088
|
+
"Run a local SonarQube analysis, browse the latest issues, or init project config",
|
|
1089
|
+
handler: async (args, ctx) => {
|
|
1090
|
+
try {
|
|
1091
|
+
const parsed = parseCommandArgs(args);
|
|
1092
|
+
|
|
1093
|
+
// --- Help ---
|
|
1094
|
+
if (parsed.action === "help") {
|
|
1095
|
+
if (ctx.hasUI) {
|
|
1096
|
+
ctx.ui.notify(helpText(), "info");
|
|
1097
|
+
}
|
|
1098
|
+
return;
|
|
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");
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const choice = await showIssueBrowser(ctx, targetState);
|
|
1137
|
+
if (choice !== null && choice !== undefined) {
|
|
1138
|
+
const issue = targetState.issues[choice];
|
|
1139
|
+
if (issue) await openIssuePreview(ctx, targetState, issue);
|
|
1140
|
+
}
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
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
|
+
} catch (error) {
|
|
1153
|
+
if (ctx.hasUI) {
|
|
1154
|
+
ctx.ui.notify(sonarErrorMessage(error), "error");
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
throw error;
|
|
1158
|
+
}
|
|
1159
|
+
},
|
|
1160
|
+
});
|
|
1161
|
+
}
|