@jonathan0823/pi-sonarqube 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +38 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +367 -0
- package/dist/api.js.map +1 -0
- package/dist/commands.d.ts +54 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +192 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +278 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -54
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +149 -975
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +64 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +31 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +193 -0
- package/dist/ui.js.map +1 -0
- package/package.json +1 -1
- package/src/api.ts +476 -0
- package/src/commands.ts +248 -0
- package/src/config.ts +340 -0
- package/src/index.ts +274 -1262
- package/src/types.ts +79 -0
- package/src/ui.ts +271 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// ── Public types ──────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type SonarAction = "analyze" | "issues" | "open" | "init";
|
|
4
|
+
|
|
5
|
+
export interface SonarIssue {
|
|
6
|
+
key: string;
|
|
7
|
+
rule: string;
|
|
8
|
+
ruleName?: string;
|
|
9
|
+
severity: string;
|
|
10
|
+
message: string;
|
|
11
|
+
filePath: string;
|
|
12
|
+
line: number | null;
|
|
13
|
+
status?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SonarAnalysisState {
|
|
17
|
+
version: 1;
|
|
18
|
+
analyzedAt: string;
|
|
19
|
+
baseDir: string;
|
|
20
|
+
serverUrl: string;
|
|
21
|
+
projectKey: string;
|
|
22
|
+
dashboardUrl?: string;
|
|
23
|
+
ceTaskUrl?: string;
|
|
24
|
+
analysisId?: string;
|
|
25
|
+
filters?: SonarIssueFetchOptions;
|
|
26
|
+
totalIssues: number;
|
|
27
|
+
issues: SonarIssue[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SonarProjectConfig {
|
|
31
|
+
baseDir: string;
|
|
32
|
+
serverUrl: string;
|
|
33
|
+
projectKey: string;
|
|
34
|
+
token?: string;
|
|
35
|
+
hasProperties: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SonarIssueFetchOptions {
|
|
39
|
+
severities?: string[];
|
|
40
|
+
statuses?: string[];
|
|
41
|
+
types?: string[];
|
|
42
|
+
rules?: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SonarInitConfig {
|
|
46
|
+
serverUrl: string;
|
|
47
|
+
projectKey: string;
|
|
48
|
+
token?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Internal types shared by adapter and extension ──────────────────────────
|
|
52
|
+
|
|
53
|
+
export interface SonarWorkspaceRegistry {
|
|
54
|
+
version: 1;
|
|
55
|
+
workspaces: Record<string, string>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ResolvedTarget {
|
|
59
|
+
baseDir: string;
|
|
60
|
+
repoRoot: string;
|
|
61
|
+
alias?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ParsedSonarIssueArgs {
|
|
65
|
+
targetInput?: string;
|
|
66
|
+
issueIndex?: number;
|
|
67
|
+
filters?: SonarIssueFetchOptions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface InitCommandOptions {
|
|
71
|
+
alias?: string;
|
|
72
|
+
targetInput?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── Constants shared by adapter and extension ───────────────────────────────
|
|
76
|
+
|
|
77
|
+
export const SONAR_SEVERITIES = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"] as const;
|
|
78
|
+
export const SONAR_STATUSES = ["OPEN", "CONFIRMED", "REOPENED", "RESOLVED", "CLOSED"] as const;
|
|
79
|
+
export const SONAR_TYPES = ["BUG", "VULNERABILITY", "CODE_SMELL"] as const;
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import type { Theme, ExtensionContext, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions } from "./types.js";
|
|
6
|
+
import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters } from "./api.js";
|
|
7
|
+
import { resolveConfig, resolveTarget } from "./config.js";
|
|
8
|
+
|
|
9
|
+
// ── Issue preview ───────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export async function buildIssuePreview(baseDir: string, issue: SonarIssue, radius = 3): Promise<string> {
|
|
12
|
+
const filePath = resolve(baseDir, issue.filePath);
|
|
13
|
+
const content = await readFile(filePath, "utf8");
|
|
14
|
+
const lines = content.split(/\r?\n/);
|
|
15
|
+
const target = issue.line ?? 1;
|
|
16
|
+
const start = Math.max(1, target - radius);
|
|
17
|
+
const end = Math.min(lines.length, target + radius);
|
|
18
|
+
const width = String(end).length;
|
|
19
|
+
|
|
20
|
+
const rendered: string[] = [];
|
|
21
|
+
for (let line = start; line <= end; line++) {
|
|
22
|
+
const marker = line === target ? ">" : " ";
|
|
23
|
+
const num = String(line).padStart(width, " ");
|
|
24
|
+
rendered.push(`${marker} ${num} | ${lines[line - 1] ?? ""}`);
|
|
25
|
+
}
|
|
26
|
+
return rendered.join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Analysis UI ─────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const ANALYSIS_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
32
|
+
const ANALYSIS_WIDGET_KEY = "sonarqube-analysis";
|
|
33
|
+
|
|
34
|
+
export interface AnalysisUiHandle {
|
|
35
|
+
setPhase(phase: string): void;
|
|
36
|
+
stop(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function startAnalysisUi(ctx: ExtensionContext, projectKey: string): AnalysisUiHandle {
|
|
40
|
+
let frameIndex = 0;
|
|
41
|
+
let phase = "Starting...";
|
|
42
|
+
const render = () => {
|
|
43
|
+
const frame = ANALYSIS_SPINNER[frameIndex];
|
|
44
|
+
ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, [`${frame} SonarQube ${projectKey}`, phase]);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
ctx.ui.setWorkingMessage(`Analyzing ${projectKey}...`);
|
|
48
|
+
ctx.ui.setWorkingIndicator({ frames: ANALYSIS_SPINNER as unknown as string[], intervalMs: 80 });
|
|
49
|
+
render();
|
|
50
|
+
|
|
51
|
+
const timer = setInterval(() => {
|
|
52
|
+
frameIndex = (frameIndex + 1) % ANALYSIS_SPINNER.length;
|
|
53
|
+
render();
|
|
54
|
+
}, 80);
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
setPhase(nextPhase: string) {
|
|
58
|
+
phase = nextPhase;
|
|
59
|
+
render();
|
|
60
|
+
},
|
|
61
|
+
stop() {
|
|
62
|
+
clearInterval(timer);
|
|
63
|
+
ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, undefined);
|
|
64
|
+
ctx.ui.setWorkingMessage();
|
|
65
|
+
ctx.ui.setWorkingIndicator();
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Issue Browser UI component ──────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export class IssueBrowser {
|
|
73
|
+
private selected = 0;
|
|
74
|
+
private cachedWidth: number | undefined;
|
|
75
|
+
private cachedLines: string[] | undefined;
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly state: SonarAnalysisState,
|
|
79
|
+
private readonly theme: Theme,
|
|
80
|
+
private readonly done: (result: number | null) => void,
|
|
81
|
+
) {}
|
|
82
|
+
|
|
83
|
+
invalidate(): void {
|
|
84
|
+
this.cachedWidth = undefined;
|
|
85
|
+
this.cachedLines = undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
handleInput(data: string): void {
|
|
89
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
|
|
90
|
+
this.done(null);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (matchesKey(data, Key.up)) {
|
|
94
|
+
this.selected = Math.max(0, this.selected - 1);
|
|
95
|
+
this.invalidate();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (matchesKey(data, Key.down)) {
|
|
99
|
+
this.selected = Math.min(this.state.issues.length - 1, this.selected + 1);
|
|
100
|
+
this.invalidate();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (matchesKey(data, Key.enter)) {
|
|
104
|
+
this.done(this.selected);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
render(width: number): string[] {
|
|
109
|
+
if (this.cachedLines && this.cachedWidth === width) {
|
|
110
|
+
return this.cachedLines;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
const title = this.theme.fg("accent", this.theme.bold(" SonarQube Issues "));
|
|
115
|
+
const filterSuffix = this.state.filters ? ` • ${issueFilterLabel(this.state.filters)}` : "";
|
|
116
|
+
const subtitle = this.theme.fg(
|
|
117
|
+
"dim",
|
|
118
|
+
`${this.state.projectKey} • ${this.state.totalIssues} issue(s)${filterSuffix}`,
|
|
119
|
+
);
|
|
120
|
+
lines.push(
|
|
121
|
+
truncateToWidth(title, width),
|
|
122
|
+
truncateToWidth(subtitle, width),
|
|
123
|
+
"",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const pageSize = Math.min(20, this.state.issues.length);
|
|
127
|
+
const halfWindow = Math.floor(pageSize / 2);
|
|
128
|
+
const maxStart = Math.max(0, this.state.issues.length - pageSize);
|
|
129
|
+
const start = Math.max(0, Math.min(this.selected - halfWindow, maxStart));
|
|
130
|
+
const end = Math.min(this.state.issues.length, start + pageSize);
|
|
131
|
+
const visibleIssues = this.state.issues.slice(start, end);
|
|
132
|
+
|
|
133
|
+
if (visibleIssues.length === 0) {
|
|
134
|
+
lines.push(truncateToWidth(this.theme.fg("success", "No open issues found."), width));
|
|
135
|
+
return finalize(lines, width, this.theme, this.cachedWidth, this.cachedLines);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (start > 0) {
|
|
139
|
+
lines.push(truncateToWidth(this.theme.fg("dim", `... ${start} more above`), width));
|
|
140
|
+
}
|
|
141
|
+
for (const [offset, issue] of visibleIssues.entries()) {
|
|
142
|
+
const issueIndex = start + offset;
|
|
143
|
+
const isSelected = issueIndex === this.selected;
|
|
144
|
+
const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
145
|
+
const location = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
|
|
146
|
+
const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
|
|
147
|
+
const severity = severityColor(this.theme, issue.severity);
|
|
148
|
+
const summary = [
|
|
149
|
+
marker,
|
|
150
|
+
String(issueIndex + 1).padStart(2, " "),
|
|
151
|
+
".",
|
|
152
|
+
severity,
|
|
153
|
+
this.theme.fg("accent", location),
|
|
154
|
+
this.theme.fg("muted", rule),
|
|
155
|
+
this.theme.fg("text", `— ${issue.message}`),
|
|
156
|
+
].join(" ");
|
|
157
|
+
lines.push(truncateToWidth(summary, width));
|
|
158
|
+
}
|
|
159
|
+
if (end < this.state.issues.length) {
|
|
160
|
+
lines.push(
|
|
161
|
+
truncateToWidth(
|
|
162
|
+
this.theme.fg("dim", `... ${this.state.issues.length - end} more below`),
|
|
163
|
+
width,
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
lines.push(
|
|
169
|
+
"",
|
|
170
|
+
truncateToWidth(
|
|
171
|
+
this.theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"),
|
|
172
|
+
width,
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
this.cachedWidth = width;
|
|
177
|
+
this.cachedLines = lines;
|
|
178
|
+
return lines;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function finalize(
|
|
183
|
+
lines: string[],
|
|
184
|
+
width: number,
|
|
185
|
+
theme: Theme,
|
|
186
|
+
cachedWidth: number | undefined,
|
|
187
|
+
cachedLines: string[] | undefined,
|
|
188
|
+
): string[] {
|
|
189
|
+
lines.push(
|
|
190
|
+
"",
|
|
191
|
+
truncateToWidth(theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"), width),
|
|
192
|
+
);
|
|
193
|
+
const result = cachedLines ?? lines;
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Severity colors ─────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
function severityColor(theme: Theme, severity: string): string {
|
|
200
|
+
switch (severity.toLowerCase()) {
|
|
201
|
+
case "blocker":
|
|
202
|
+
case "critical":
|
|
203
|
+
return theme.fg("error", severity);
|
|
204
|
+
case "major":
|
|
205
|
+
return theme.fg("warning", severity);
|
|
206
|
+
case "minor":
|
|
207
|
+
return theme.fg("accent", severity);
|
|
208
|
+
default:
|
|
209
|
+
return theme.fg("muted", severity);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Issue browser / preview helpers (used by index.ts) ──────────────────────
|
|
214
|
+
|
|
215
|
+
export async function showIssueBrowser(
|
|
216
|
+
ctx: ExtensionCommandContext,
|
|
217
|
+
state: SonarAnalysisState,
|
|
218
|
+
): Promise<number | null> {
|
|
219
|
+
if (ctx.mode !== "tui") return null;
|
|
220
|
+
if (state.issues.length === 0) return null;
|
|
221
|
+
return await ctx.ui.custom<number | null>((_tui, theme, _kb, done) => new IssueBrowser(state, theme, done));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function openIssuePreview(
|
|
225
|
+
ctx: ExtensionCommandContext,
|
|
226
|
+
state: SonarAnalysisState,
|
|
227
|
+
issue: SonarIssue,
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
const preview = await buildIssuePreview(state.baseDir, issue);
|
|
230
|
+
const title = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
|
|
231
|
+
if (ctx.mode === "tui") {
|
|
232
|
+
await ctx.ui.editor(title, preview);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
ctx.ui.notify(`${title}\n${preview}`, "info");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Issue loading / target resolution (orchestration) ───────────────────────
|
|
239
|
+
|
|
240
|
+
export async function loadProjectIssuesFromApi(
|
|
241
|
+
ctx: { signal?: AbortSignal; cwd: string },
|
|
242
|
+
inputPath?: string,
|
|
243
|
+
filters?: SonarIssueFetchOptions,
|
|
244
|
+
): Promise<SonarAnalysisState> {
|
|
245
|
+
const config = await resolveConfig(ctx, inputPath);
|
|
246
|
+
const normalizedFilters = normalizeIssueFilters(filters);
|
|
247
|
+
const issues = await fetchIssues(
|
|
248
|
+
config.serverUrl,
|
|
249
|
+
config.token,
|
|
250
|
+
config.projectKey,
|
|
251
|
+
ctx.signal,
|
|
252
|
+
normalizedFilters,
|
|
253
|
+
);
|
|
254
|
+
return createAnalysisState(config, issues, { filters: normalizedFilters });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function resolveTargetState(
|
|
258
|
+
ctx: { signal?: AbortSignal; cwd: string },
|
|
259
|
+
statesByBaseDir: Map<string, SonarAnalysisState>,
|
|
260
|
+
targetInput?: string,
|
|
261
|
+
filters?: SonarIssueFetchOptions,
|
|
262
|
+
): Promise<SonarAnalysisState | undefined> {
|
|
263
|
+
const resolvedTarget = await resolveTarget(ctx, targetInput);
|
|
264
|
+
try {
|
|
265
|
+
return await loadProjectIssuesFromApi(ctx, targetInput, filters);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const cached = statesByBaseDir.get(resolvedTarget.baseDir);
|
|
268
|
+
if (cached) return cached;
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
}
|