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