@kakarot-ci/core 0.2.0 → 0.3.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/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +2288 -0
- package/dist/cli/index.js.map +7 -0
- package/dist/core/orchestrator.d.ts +32 -0
- package/dist/core/orchestrator.d.ts.map +1 -0
- package/dist/index.cjs +862 -101
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +851 -100
- package/dist/index.js.map +4 -4
- package/dist/llm/prompts/coverage-summary.d.ts +8 -0
- package/dist/llm/prompts/coverage-summary.d.ts.map +1 -0
- package/dist/llm/test-generator.d.ts +7 -0
- package/dist/llm/test-generator.d.ts.map +1 -1
- package/dist/types/config.d.ts +9 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/coverage.d.ts +40 -0
- package/dist/types/coverage.d.ts.map +1 -0
- package/dist/types/test-runner.d.ts +30 -0
- package/dist/types/test-runner.d.ts.map +1 -0
- package/dist/utils/config-loader.d.ts +4 -0
- package/dist/utils/config-loader.d.ts.map +1 -1
- package/dist/utils/coverage-reader.d.ts +6 -0
- package/dist/utils/coverage-reader.d.ts.map +1 -0
- package/dist/utils/package-manager-detector.d.ts +6 -0
- package/dist/utils/package-manager-detector.d.ts.map +1 -0
- package/dist/utils/test-file-path.d.ts +7 -0
- package/dist/utils/test-file-path.d.ts.map +1 -0
- package/dist/utils/test-file-writer.d.ts +8 -0
- package/dist/utils/test-file-writer.d.ts.map +1 -0
- package/dist/utils/test-runner/factory.d.ts +6 -0
- package/dist/utils/test-runner/factory.d.ts.map +1 -0
- package/dist/utils/test-runner/jest-runner.d.ts +5 -0
- package/dist/utils/test-runner/jest-runner.d.ts.map +1 -0
- package/dist/utils/test-runner/vitest-runner.d.ts +5 -0
- package/dist/utils/test-runner/vitest-runner.d.ts.map +1 -0
- package/package.json +10 -2
|
@@ -0,0 +1,2288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
#!/usr/bin/env node
|
|
3
|
+
|
|
4
|
+
// src/cli/index.ts
|
|
5
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
6
|
+
import { simpleGit } from "simple-git";
|
|
7
|
+
import gitUrlParse from "git-url-parse";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
// src/github/client.ts
|
|
11
|
+
import { Octokit } from "@octokit/rest";
|
|
12
|
+
|
|
13
|
+
// src/utils/logger.ts
|
|
14
|
+
var debugMode = false;
|
|
15
|
+
var jsonMode = false;
|
|
16
|
+
function initLogger(config) {
|
|
17
|
+
debugMode = config.debug ?? process.env.KAKAROT_DEBUG === "true";
|
|
18
|
+
jsonMode = process.env.KAKAROT_OUTPUT === "json";
|
|
19
|
+
}
|
|
20
|
+
function info(message, ...args) {
|
|
21
|
+
if (jsonMode) {
|
|
22
|
+
console.log(JSON.stringify({ level: "info", message, ...args }));
|
|
23
|
+
} else {
|
|
24
|
+
console.log(`[kakarot-ci] ${message}`, ...args);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function debug(message, ...args) {
|
|
28
|
+
if (debugMode) {
|
|
29
|
+
if (jsonMode) {
|
|
30
|
+
console.debug(JSON.stringify({ level: "debug", message, ...args }));
|
|
31
|
+
} else {
|
|
32
|
+
console.debug(`[kakarot-ci:debug] ${message}`, ...args);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function warn(message, ...args) {
|
|
37
|
+
if (jsonMode) {
|
|
38
|
+
console.warn(JSON.stringify({ level: "warn", message, ...args }));
|
|
39
|
+
} else {
|
|
40
|
+
console.warn(`[kakarot-ci] \u26A0 ${message}`, ...args);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function error(message, ...args) {
|
|
44
|
+
if (jsonMode) {
|
|
45
|
+
console.error(JSON.stringify({ level: "error", message, ...args }));
|
|
46
|
+
} else {
|
|
47
|
+
console.error(`[kakarot-ci] \u2717 ${message}`, ...args);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function success(message, ...args) {
|
|
51
|
+
if (jsonMode) {
|
|
52
|
+
console.log(JSON.stringify({ level: "success", message, ...args }));
|
|
53
|
+
} else {
|
|
54
|
+
console.log(`[kakarot-ci] \u2713 ${message}`, ...args);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function progress(step, total, message, ...args) {
|
|
58
|
+
if (jsonMode) {
|
|
59
|
+
console.log(JSON.stringify({ level: "info", step, total, message, ...args }));
|
|
60
|
+
} else {
|
|
61
|
+
console.log(`[kakarot-ci] Step ${step}/${total}: ${message}`, ...args);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/github/client.ts
|
|
66
|
+
var GitHubClient = class {
|
|
67
|
+
// 1 second
|
|
68
|
+
constructor(options) {
|
|
69
|
+
this.maxRetries = 3;
|
|
70
|
+
this.retryDelay = 1e3;
|
|
71
|
+
this.owner = options.owner;
|
|
72
|
+
this.repo = options.repo;
|
|
73
|
+
this.octokit = new Octokit({
|
|
74
|
+
auth: options.token,
|
|
75
|
+
request: {
|
|
76
|
+
retries: this.maxRetries,
|
|
77
|
+
retryAfter: this.retryDelay / 1e3
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Retry wrapper with exponential backoff
|
|
83
|
+
*/
|
|
84
|
+
async withRetry(fn, operation, retries = this.maxRetries) {
|
|
85
|
+
try {
|
|
86
|
+
return await fn();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (retries <= 0) {
|
|
89
|
+
error(`${operation} failed after ${this.maxRetries} retries: ${err instanceof Error ? err.message : String(err)}`);
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
const isRateLimit = err instanceof Error && err.message.includes("rate limit");
|
|
93
|
+
const isServerError = err instanceof Error && (err.message.includes("500") || err.message.includes("502") || err.message.includes("503") || err.message.includes("504"));
|
|
94
|
+
if (isRateLimit || isServerError) {
|
|
95
|
+
const delay = this.retryDelay * Math.pow(2, this.maxRetries - retries);
|
|
96
|
+
warn(`${operation} failed, retrying in ${delay}ms... (${retries} retries left)`);
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
98
|
+
return this.withRetry(fn, operation, retries - 1);
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Get pull request details
|
|
105
|
+
*/
|
|
106
|
+
async getPullRequest(prNumber) {
|
|
107
|
+
return this.withRetry(async () => {
|
|
108
|
+
debug(`Fetching PR #${prNumber}`);
|
|
109
|
+
const response = await this.octokit.rest.pulls.get({
|
|
110
|
+
owner: this.owner,
|
|
111
|
+
repo: this.repo,
|
|
112
|
+
pull_number: prNumber
|
|
113
|
+
});
|
|
114
|
+
return response.data;
|
|
115
|
+
}, `getPullRequest(${prNumber})`);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* List all files changed in a pull request with patches
|
|
119
|
+
*/
|
|
120
|
+
async listPullRequestFiles(prNumber) {
|
|
121
|
+
return this.withRetry(async () => {
|
|
122
|
+
debug(`Fetching files for PR #${prNumber}`);
|
|
123
|
+
const response = await this.octokit.rest.pulls.listFiles({
|
|
124
|
+
owner: this.owner,
|
|
125
|
+
repo: this.repo,
|
|
126
|
+
pull_number: prNumber
|
|
127
|
+
});
|
|
128
|
+
return response.data.map((file) => ({
|
|
129
|
+
filename: file.filename,
|
|
130
|
+
status: file.status,
|
|
131
|
+
additions: file.additions,
|
|
132
|
+
deletions: file.deletions,
|
|
133
|
+
changes: file.changes,
|
|
134
|
+
patch: file.patch || void 0,
|
|
135
|
+
previous_filename: file.previous_filename || void 0
|
|
136
|
+
}));
|
|
137
|
+
}, `listPullRequestFiles(${prNumber})`);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Get file contents from a specific ref (branch, commit, etc.)
|
|
141
|
+
*/
|
|
142
|
+
async getFileContents(ref, path) {
|
|
143
|
+
return this.withRetry(async () => {
|
|
144
|
+
debug(`Fetching file contents: ${path}@${ref}`);
|
|
145
|
+
const response = await this.octokit.rest.repos.getContent({
|
|
146
|
+
owner: this.owner,
|
|
147
|
+
repo: this.repo,
|
|
148
|
+
path,
|
|
149
|
+
ref
|
|
150
|
+
});
|
|
151
|
+
if (Array.isArray(response.data)) {
|
|
152
|
+
throw new Error(`Expected file but got directory: ${path}`);
|
|
153
|
+
}
|
|
154
|
+
const data = response.data;
|
|
155
|
+
let content;
|
|
156
|
+
if (data.encoding === "base64") {
|
|
157
|
+
content = Buffer.from(data.content, "base64").toString("utf-8");
|
|
158
|
+
} else {
|
|
159
|
+
content = data.content;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
content,
|
|
163
|
+
encoding: data.encoding,
|
|
164
|
+
sha: data.sha,
|
|
165
|
+
size: data.size
|
|
166
|
+
};
|
|
167
|
+
}, `getFileContents(${ref}, ${path})`);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Commit multiple files in a single commit using Git tree API
|
|
171
|
+
*/
|
|
172
|
+
async commitFiles(options) {
|
|
173
|
+
return this.withRetry(async () => {
|
|
174
|
+
debug(`Committing ${options.files.length} file(s) to branch ${options.branch}`);
|
|
175
|
+
const baseCommit = await this.octokit.rest.repos.getCommit({
|
|
176
|
+
owner: this.owner,
|
|
177
|
+
repo: this.repo,
|
|
178
|
+
ref: options.baseSha
|
|
179
|
+
});
|
|
180
|
+
const baseTreeSha = baseCommit.data.commit.tree.sha;
|
|
181
|
+
const blobPromises = options.files.map(async (file) => {
|
|
182
|
+
const blobResponse = await this.octokit.rest.git.createBlob({
|
|
183
|
+
owner: this.owner,
|
|
184
|
+
repo: this.repo,
|
|
185
|
+
content: Buffer.from(file.content, "utf-8").toString("base64"),
|
|
186
|
+
encoding: "base64"
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
path: file.path,
|
|
190
|
+
sha: blobResponse.data.sha,
|
|
191
|
+
mode: "100644",
|
|
192
|
+
type: "blob"
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
const treeItems = await Promise.all(blobPromises);
|
|
196
|
+
const treeResponse = await this.octokit.rest.git.createTree({
|
|
197
|
+
owner: this.owner,
|
|
198
|
+
repo: this.repo,
|
|
199
|
+
base_tree: baseTreeSha,
|
|
200
|
+
tree: treeItems
|
|
201
|
+
});
|
|
202
|
+
const commitResponse = await this.octokit.rest.git.createCommit({
|
|
203
|
+
owner: this.owner,
|
|
204
|
+
repo: this.repo,
|
|
205
|
+
message: options.message,
|
|
206
|
+
tree: treeResponse.data.sha,
|
|
207
|
+
parents: [options.baseSha]
|
|
208
|
+
});
|
|
209
|
+
await this.octokit.rest.git.updateRef({
|
|
210
|
+
owner: this.owner,
|
|
211
|
+
repo: this.repo,
|
|
212
|
+
ref: `heads/${options.branch}`,
|
|
213
|
+
sha: commitResponse.data.sha
|
|
214
|
+
});
|
|
215
|
+
return commitResponse.data.sha;
|
|
216
|
+
}, `commitFiles(${options.files.length} files)`);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Create a new branch from a base ref
|
|
220
|
+
*/
|
|
221
|
+
async createBranch(branchName, baseRef) {
|
|
222
|
+
return this.withRetry(async () => {
|
|
223
|
+
debug(`Creating branch ${branchName} from ${baseRef}`);
|
|
224
|
+
const baseRefResponse = await this.octokit.rest.git.getRef({
|
|
225
|
+
owner: this.owner,
|
|
226
|
+
repo: this.repo,
|
|
227
|
+
ref: baseRef.startsWith("refs/") ? baseRef : `heads/${baseRef}`
|
|
228
|
+
});
|
|
229
|
+
const baseSha = baseRefResponse.data.object.sha;
|
|
230
|
+
await this.octokit.rest.git.createRef({
|
|
231
|
+
owner: this.owner,
|
|
232
|
+
repo: this.repo,
|
|
233
|
+
ref: `refs/heads/${branchName}`,
|
|
234
|
+
sha: baseSha
|
|
235
|
+
});
|
|
236
|
+
return baseSha;
|
|
237
|
+
}, `createBranch(${branchName})`);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Create a pull request
|
|
241
|
+
*/
|
|
242
|
+
async createPullRequest(title, body, head, base) {
|
|
243
|
+
return this.withRetry(async () => {
|
|
244
|
+
debug(`Creating PR: ${head} -> ${base}`);
|
|
245
|
+
const response = await this.octokit.rest.pulls.create({
|
|
246
|
+
owner: this.owner,
|
|
247
|
+
repo: this.repo,
|
|
248
|
+
title,
|
|
249
|
+
body,
|
|
250
|
+
head,
|
|
251
|
+
base
|
|
252
|
+
});
|
|
253
|
+
return response.data;
|
|
254
|
+
}, `createPullRequest(${head} -> ${base})`);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Post a comment on a pull request
|
|
258
|
+
*/
|
|
259
|
+
async commentPR(prNumber, body) {
|
|
260
|
+
await this.withRetry(async () => {
|
|
261
|
+
debug(`Posting comment on PR #${prNumber}`);
|
|
262
|
+
await this.octokit.rest.issues.createComment({
|
|
263
|
+
owner: this.owner,
|
|
264
|
+
repo: this.repo,
|
|
265
|
+
issue_number: prNumber,
|
|
266
|
+
body
|
|
267
|
+
});
|
|
268
|
+
}, `commentPR(${prNumber})`);
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Check if a file exists in the repository
|
|
272
|
+
*/
|
|
273
|
+
async fileExists(ref, path) {
|
|
274
|
+
return this.withRetry(async () => {
|
|
275
|
+
try {
|
|
276
|
+
await this.octokit.rest.repos.getContent({
|
|
277
|
+
owner: this.owner,
|
|
278
|
+
repo: this.repo,
|
|
279
|
+
path,
|
|
280
|
+
ref
|
|
281
|
+
});
|
|
282
|
+
return true;
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (err instanceof Error && err.message.includes("404")) {
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
}, `fileExists(${ref}, ${path})`);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Get the current rate limit status
|
|
293
|
+
*/
|
|
294
|
+
async getRateLimit() {
|
|
295
|
+
const response = await this.octokit.rest.rateLimit.get();
|
|
296
|
+
return {
|
|
297
|
+
remaining: response.data.rate.remaining,
|
|
298
|
+
reset: response.data.rate.reset
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// src/utils/config-loader.ts
|
|
304
|
+
import { cosmiconfig } from "cosmiconfig";
|
|
305
|
+
import { findUp } from "find-up";
|
|
306
|
+
|
|
307
|
+
// src/types/config.ts
|
|
308
|
+
import { z } from "zod";
|
|
309
|
+
var KakarotConfigSchema = z.object({
|
|
310
|
+
apiKey: z.string(),
|
|
311
|
+
githubToken: z.string().optional(),
|
|
312
|
+
githubOwner: z.string().optional(),
|
|
313
|
+
githubRepo: z.string().optional(),
|
|
314
|
+
provider: z.enum(["openai", "anthropic", "google"]).optional(),
|
|
315
|
+
model: z.string().optional(),
|
|
316
|
+
maxTokens: z.number().int().min(1).max(1e5).optional(),
|
|
317
|
+
temperature: z.number().min(0).max(2).optional(),
|
|
318
|
+
fixTemperature: z.number().min(0).max(2).optional(),
|
|
319
|
+
maxFixAttempts: z.number().int().min(0).max(5).default(3),
|
|
320
|
+
framework: z.enum(["jest", "vitest"]),
|
|
321
|
+
testLocation: z.enum(["separate", "co-located"]).default("separate"),
|
|
322
|
+
testDirectory: z.string().default("__tests__"),
|
|
323
|
+
testFilePattern: z.string().default("*.test.ts"),
|
|
324
|
+
includePatterns: z.array(z.string()).default(["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"]),
|
|
325
|
+
excludePatterns: z.array(z.string()).default(["**/*.test.ts", "**/*.spec.ts", "**/*.test.js", "**/*.spec.js", "**/node_modules/**"]),
|
|
326
|
+
maxTestsPerPR: z.number().int().min(1).default(50),
|
|
327
|
+
enableAutoCommit: z.boolean().default(true),
|
|
328
|
+
commitStrategy: z.enum(["direct", "branch-pr"]).default("direct"),
|
|
329
|
+
enablePRComments: z.boolean().default(true),
|
|
330
|
+
debug: z.boolean().default(false)
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// src/utils/config-loader.ts
|
|
334
|
+
async function findProjectRoot(startPath) {
|
|
335
|
+
const packageJsonPath = await findUp("package.json", {
|
|
336
|
+
cwd: startPath ?? process.cwd()
|
|
337
|
+
});
|
|
338
|
+
if (packageJsonPath) {
|
|
339
|
+
const { dirname: dirname2 } = await import("path");
|
|
340
|
+
return dirname2(packageJsonPath);
|
|
341
|
+
}
|
|
342
|
+
return startPath ?? process.cwd();
|
|
343
|
+
}
|
|
344
|
+
async function loadConfig() {
|
|
345
|
+
const explorer = cosmiconfig("kakarot", {
|
|
346
|
+
searchPlaces: [
|
|
347
|
+
"kakarot.config.ts",
|
|
348
|
+
"kakarot.config.js",
|
|
349
|
+
".kakarot-ci.config.ts",
|
|
350
|
+
".kakarot-ci.config.js",
|
|
351
|
+
".kakarot-ci.config.json",
|
|
352
|
+
"package.json"
|
|
353
|
+
],
|
|
354
|
+
loaders: {
|
|
355
|
+
".ts": async (filepath) => {
|
|
356
|
+
try {
|
|
357
|
+
const configModule = await import(filepath);
|
|
358
|
+
return configModule.default || configModule.config || null;
|
|
359
|
+
} catch (err) {
|
|
360
|
+
error(`Failed to load TypeScript config: ${err instanceof Error ? err.message : String(err)}`);
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
try {
|
|
367
|
+
const result = await explorer.search();
|
|
368
|
+
let config = {};
|
|
369
|
+
if (result?.config) {
|
|
370
|
+
config = result.config;
|
|
371
|
+
}
|
|
372
|
+
if (!result || result.filepath?.endsWith("package.json")) {
|
|
373
|
+
const packageJsonPath = await findUp("package.json");
|
|
374
|
+
if (packageJsonPath) {
|
|
375
|
+
const { readFileSync: readFileSync3 } = await import("fs");
|
|
376
|
+
try {
|
|
377
|
+
const pkg = JSON.parse(readFileSync3(packageJsonPath, "utf-8"));
|
|
378
|
+
if (pkg.kakarotCi) {
|
|
379
|
+
config = { ...config, ...pkg.kakarotCi };
|
|
380
|
+
}
|
|
381
|
+
} catch {
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (!config.apiKey && process.env.KAKAROT_API_KEY) {
|
|
386
|
+
config.apiKey = process.env.KAKAROT_API_KEY;
|
|
387
|
+
}
|
|
388
|
+
if (!config.githubToken && process.env.GITHUB_TOKEN) {
|
|
389
|
+
config.githubToken = process.env.GITHUB_TOKEN;
|
|
390
|
+
}
|
|
391
|
+
return KakarotConfigSchema.parse(config);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (err instanceof Error && err.message.includes("apiKey")) {
|
|
394
|
+
error(
|
|
395
|
+
"Missing required apiKey. Provide it via:\n - Config file (kakarot.config.ts, .kakarot-ci.config.js/json, or package.json)\n - Environment variable: KAKAROT_API_KEY"
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
throw err;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// src/utils/diff-parser.ts
|
|
403
|
+
function parseUnifiedDiff(patch) {
|
|
404
|
+
const hunks = [];
|
|
405
|
+
const lines = patch.split("\n");
|
|
406
|
+
let i = 0;
|
|
407
|
+
while (i < lines.length) {
|
|
408
|
+
const line = lines[i];
|
|
409
|
+
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
410
|
+
if (hunkMatch) {
|
|
411
|
+
const oldStart = parseInt(hunkMatch[1], 10);
|
|
412
|
+
const oldLines = parseInt(hunkMatch[2] || "1", 10);
|
|
413
|
+
const newStart = parseInt(hunkMatch[3], 10);
|
|
414
|
+
const newLines = parseInt(hunkMatch[4] || "1", 10);
|
|
415
|
+
const hunkLines = [];
|
|
416
|
+
i++;
|
|
417
|
+
while (i < lines.length && !lines[i].startsWith("@@")) {
|
|
418
|
+
hunkLines.push(lines[i]);
|
|
419
|
+
i++;
|
|
420
|
+
}
|
|
421
|
+
hunks.push({
|
|
422
|
+
oldStart,
|
|
423
|
+
oldLines,
|
|
424
|
+
newStart,
|
|
425
|
+
newLines,
|
|
426
|
+
lines: hunkLines
|
|
427
|
+
});
|
|
428
|
+
} else {
|
|
429
|
+
i++;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return hunks;
|
|
433
|
+
}
|
|
434
|
+
function hunksToChangedRanges(hunks) {
|
|
435
|
+
const ranges = [];
|
|
436
|
+
for (const hunk of hunks) {
|
|
437
|
+
let oldLine = hunk.oldStart;
|
|
438
|
+
let newLine = hunk.newStart;
|
|
439
|
+
for (const line of hunk.lines) {
|
|
440
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
441
|
+
ranges.push({
|
|
442
|
+
start: newLine,
|
|
443
|
+
end: newLine,
|
|
444
|
+
type: "addition"
|
|
445
|
+
});
|
|
446
|
+
newLine++;
|
|
447
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
448
|
+
ranges.push({
|
|
449
|
+
start: oldLine,
|
|
450
|
+
end: oldLine,
|
|
451
|
+
type: "deletion"
|
|
452
|
+
});
|
|
453
|
+
oldLine++;
|
|
454
|
+
} else if (!line.startsWith("\\")) {
|
|
455
|
+
oldLine++;
|
|
456
|
+
newLine++;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return mergeRanges(ranges);
|
|
461
|
+
}
|
|
462
|
+
function mergeRanges(ranges) {
|
|
463
|
+
if (ranges.length === 0)
|
|
464
|
+
return [];
|
|
465
|
+
const sorted = [...ranges].sort((a, b) => a.start - b.start);
|
|
466
|
+
const merged = [];
|
|
467
|
+
let current = sorted[0];
|
|
468
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
469
|
+
const next = sorted[i];
|
|
470
|
+
if (next.start <= current.end + 2 && next.type === current.type) {
|
|
471
|
+
current = {
|
|
472
|
+
start: current.start,
|
|
473
|
+
end: Math.max(current.end, next.end),
|
|
474
|
+
type: current.type
|
|
475
|
+
};
|
|
476
|
+
} else {
|
|
477
|
+
merged.push(current);
|
|
478
|
+
current = next;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
merged.push(current);
|
|
482
|
+
return merged;
|
|
483
|
+
}
|
|
484
|
+
function parsePullRequestFiles(files) {
|
|
485
|
+
const diffs = [];
|
|
486
|
+
for (const file of files) {
|
|
487
|
+
if (!file.filename.match(/\.(ts|tsx|js|jsx)$/)) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (!file.patch) {
|
|
491
|
+
diffs.push({
|
|
492
|
+
filename: file.filename,
|
|
493
|
+
status: file.status,
|
|
494
|
+
hunks: [],
|
|
495
|
+
additions: file.additions,
|
|
496
|
+
deletions: file.deletions
|
|
497
|
+
});
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const hunks = parseUnifiedDiff(file.patch);
|
|
501
|
+
diffs.push({
|
|
502
|
+
filename: file.filename,
|
|
503
|
+
status: file.status,
|
|
504
|
+
hunks,
|
|
505
|
+
additions: file.additions,
|
|
506
|
+
deletions: file.deletions
|
|
507
|
+
});
|
|
508
|
+
debug(`Parsed ${hunks.length} hunk(s) for ${file.filename}`);
|
|
509
|
+
}
|
|
510
|
+
return diffs;
|
|
511
|
+
}
|
|
512
|
+
function getChangedRanges(diff, fileContent) {
|
|
513
|
+
if (diff.status === "added") {
|
|
514
|
+
if (!fileContent) {
|
|
515
|
+
throw new Error("fileContent is required for added files to determine line count");
|
|
516
|
+
}
|
|
517
|
+
const lineCount = fileContent.split("\n").length;
|
|
518
|
+
return [{ start: 1, end: lineCount, type: "addition" }];
|
|
519
|
+
}
|
|
520
|
+
if (diff.status === "removed") {
|
|
521
|
+
return [];
|
|
522
|
+
}
|
|
523
|
+
return hunksToChangedRanges(diff.hunks);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/utils/ast-analyzer.ts
|
|
527
|
+
import * as ts from "typescript";
|
|
528
|
+
function extractFunctions(sourceFile) {
|
|
529
|
+
const functions = [];
|
|
530
|
+
function visit(node) {
|
|
531
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
532
|
+
const isExported = node.modifiers?.some(
|
|
533
|
+
(m) => m.kind === ts.SyntaxKind.ExportKeyword || m.kind === ts.SyntaxKind.DefaultKeyword
|
|
534
|
+
);
|
|
535
|
+
if (node.name) {
|
|
536
|
+
functions.push({
|
|
537
|
+
name: node.name.text,
|
|
538
|
+
type: "function",
|
|
539
|
+
start: node.getStart(sourceFile),
|
|
540
|
+
end: node.getEnd(),
|
|
541
|
+
node
|
|
542
|
+
});
|
|
543
|
+
} else if (isExported) {
|
|
544
|
+
functions.push({
|
|
545
|
+
name: "default",
|
|
546
|
+
type: "function",
|
|
547
|
+
start: node.getStart(sourceFile),
|
|
548
|
+
end: node.getEnd(),
|
|
549
|
+
node
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (ts.isExportAssignment(node) && node.isExportEquals === false && ts.isFunctionExpression(node.expression)) {
|
|
554
|
+
const func = node.expression;
|
|
555
|
+
const name = func.name ? func.name.text : "default";
|
|
556
|
+
functions.push({
|
|
557
|
+
name,
|
|
558
|
+
type: "function",
|
|
559
|
+
start: node.getStart(sourceFile),
|
|
560
|
+
end: node.getEnd(),
|
|
561
|
+
node
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
if (ts.isMethodDeclaration(node) && node.name && ts.isIdentifier(node.name)) {
|
|
565
|
+
functions.push({
|
|
566
|
+
name: node.name.text,
|
|
567
|
+
type: "class-method",
|
|
568
|
+
start: node.getStart(sourceFile),
|
|
569
|
+
end: node.getEnd(),
|
|
570
|
+
node
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
if (ts.isVariableStatement(node)) {
|
|
574
|
+
for (const declaration of node.declarationList.declarations) {
|
|
575
|
+
if (declaration.initializer) {
|
|
576
|
+
if (ts.isArrowFunction(declaration.initializer)) {
|
|
577
|
+
if (ts.isIdentifier(declaration.name)) {
|
|
578
|
+
functions.push({
|
|
579
|
+
name: declaration.name.text,
|
|
580
|
+
type: "arrow-function",
|
|
581
|
+
start: declaration.getStart(sourceFile),
|
|
582
|
+
end: declaration.getEnd(),
|
|
583
|
+
node: declaration
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
} else if (ts.isFunctionExpression(declaration.initializer)) {
|
|
587
|
+
const funcExpr = declaration.initializer;
|
|
588
|
+
const name = funcExpr.name ? funcExpr.name.text : ts.isIdentifier(declaration.name) ? declaration.name.text : "anonymous";
|
|
589
|
+
if (name !== "anonymous") {
|
|
590
|
+
functions.push({
|
|
591
|
+
name,
|
|
592
|
+
type: "function",
|
|
593
|
+
start: declaration.getStart(sourceFile),
|
|
594
|
+
end: declaration.getEnd(),
|
|
595
|
+
node: declaration
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name)) {
|
|
603
|
+
if (ts.isFunctionExpression(node.initializer) || ts.isArrowFunction(node.initializer)) {
|
|
604
|
+
functions.push({
|
|
605
|
+
name: node.name.text,
|
|
606
|
+
type: "method",
|
|
607
|
+
start: node.getStart(sourceFile),
|
|
608
|
+
end: node.getEnd(),
|
|
609
|
+
node
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
ts.forEachChild(node, visit);
|
|
614
|
+
}
|
|
615
|
+
visit(sourceFile);
|
|
616
|
+
return functions;
|
|
617
|
+
}
|
|
618
|
+
function getLineNumber(source, position) {
|
|
619
|
+
return source.substring(0, position).split("\n").length;
|
|
620
|
+
}
|
|
621
|
+
function functionOverlapsChanges(func, changedRanges, source) {
|
|
622
|
+
const funcStartLine = getLineNumber(source, func.start);
|
|
623
|
+
const funcEndLine = getLineNumber(source, func.end);
|
|
624
|
+
const additionRanges = changedRanges.filter((r) => r.type === "addition");
|
|
625
|
+
for (const range of additionRanges) {
|
|
626
|
+
if (range.start >= funcStartLine && range.start <= funcEndLine || range.end >= funcStartLine && range.end <= funcEndLine || range.start <= funcStartLine && range.end >= funcEndLine) {
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return false;
|
|
631
|
+
}
|
|
632
|
+
function extractCodeSnippet(source, func) {
|
|
633
|
+
return source.substring(func.start, func.end);
|
|
634
|
+
}
|
|
635
|
+
function extractContext(source, func, allFunctions) {
|
|
636
|
+
const funcStartLine = getLineNumber(source, func.start);
|
|
637
|
+
const funcEndLine = getLineNumber(source, func.end);
|
|
638
|
+
const previousFunc = allFunctions.filter((f) => getLineNumber(source, f.end) < funcStartLine).sort((a, b) => getLineNumber(source, b.end) - getLineNumber(source, a.end))[0];
|
|
639
|
+
const contextStart = previousFunc ? getLineNumber(source, previousFunc.start) : Math.max(1, funcStartLine - 10);
|
|
640
|
+
const lines = source.split("\n");
|
|
641
|
+
const contextLines = lines.slice(contextStart - 1, funcEndLine + 5);
|
|
642
|
+
return contextLines.join("\n");
|
|
643
|
+
}
|
|
644
|
+
async function detectTestFile(filePath, ref, githubClient, testDirectory) {
|
|
645
|
+
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
|
|
646
|
+
const baseName = filePath.substring(filePath.lastIndexOf("/") + 1).replace(/\.(ts|tsx|js|jsx)$/, "");
|
|
647
|
+
let ext;
|
|
648
|
+
if (filePath.endsWith(".tsx"))
|
|
649
|
+
ext = "tsx";
|
|
650
|
+
else if (filePath.endsWith(".jsx"))
|
|
651
|
+
ext = "jsx";
|
|
652
|
+
else if (filePath.endsWith(".ts"))
|
|
653
|
+
ext = "ts";
|
|
654
|
+
else
|
|
655
|
+
ext = "js";
|
|
656
|
+
const testPatterns = ext === "tsx" ? [`.test.tsx`, `.spec.tsx`, `.test.ts`, `.spec.ts`] : ext === "jsx" ? [`.test.jsx`, `.spec.jsx`, `.test.js`, `.spec.js`] : ext === "ts" ? [`.test.ts`, `.spec.ts`] : [`.test.js`, `.spec.js`];
|
|
657
|
+
const locations = [
|
|
658
|
+
// Co-located in same directory
|
|
659
|
+
...testPatterns.map((pattern) => `${dir}/${baseName}${pattern}`),
|
|
660
|
+
// Co-located __tests__ directory
|
|
661
|
+
...testPatterns.map((pattern) => `${dir}/__tests__/${baseName}${pattern}`),
|
|
662
|
+
// Test directory at root
|
|
663
|
+
...testPatterns.map((pattern) => `${testDirectory}/${baseName}${pattern}`),
|
|
664
|
+
// Nested test directory matching source structure
|
|
665
|
+
...testPatterns.map((pattern) => `${testDirectory}${dir}/${baseName}${pattern}`),
|
|
666
|
+
// __tests__ at root
|
|
667
|
+
...testPatterns.map((pattern) => `__tests__/${baseName}${pattern}`)
|
|
668
|
+
];
|
|
669
|
+
for (const testPath of locations) {
|
|
670
|
+
const exists = await githubClient.fileExists(ref, testPath);
|
|
671
|
+
if (exists) {
|
|
672
|
+
return testPath;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return void 0;
|
|
676
|
+
}
|
|
677
|
+
async function analyzeFile(filePath, content, changedRanges, ref, githubClient, testDirectory) {
|
|
678
|
+
const sourceFile = ts.createSourceFile(
|
|
679
|
+
filePath,
|
|
680
|
+
content,
|
|
681
|
+
ts.ScriptTarget.Latest,
|
|
682
|
+
true
|
|
683
|
+
);
|
|
684
|
+
const functions = extractFunctions(sourceFile);
|
|
685
|
+
const existingTestFile = await detectTestFile(filePath, ref, githubClient, testDirectory);
|
|
686
|
+
const targets = [];
|
|
687
|
+
for (const func of functions) {
|
|
688
|
+
if (functionOverlapsChanges(func, changedRanges, content)) {
|
|
689
|
+
const startLine = getLineNumber(content, func.start);
|
|
690
|
+
const endLine = getLineNumber(content, func.end);
|
|
691
|
+
targets.push({
|
|
692
|
+
filePath,
|
|
693
|
+
functionName: func.name,
|
|
694
|
+
functionType: func.type,
|
|
695
|
+
startLine,
|
|
696
|
+
endLine,
|
|
697
|
+
code: extractCodeSnippet(content, func),
|
|
698
|
+
context: extractContext(content, func, functions),
|
|
699
|
+
existingTestFile,
|
|
700
|
+
changedRanges: changedRanges.filter(
|
|
701
|
+
(r) => r.start >= startLine && r.end <= endLine
|
|
702
|
+
)
|
|
703
|
+
});
|
|
704
|
+
debug(`Found test target: ${func.name} (${func.type}) in ${filePath}${existingTestFile ? ` - existing test: ${existingTestFile}` : ""}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return targets;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/utils/test-target-extractor.ts
|
|
711
|
+
async function extractTestTargets(files, githubClient, prHeadRef, config) {
|
|
712
|
+
info(`Analyzing ${files.length} file(s) for test targets`);
|
|
713
|
+
const diffs = parsePullRequestFiles(files);
|
|
714
|
+
const filteredDiffs = diffs.filter((diff) => {
|
|
715
|
+
const matchesInclude = config.includePatterns.some((pattern) => {
|
|
716
|
+
const regex = new RegExp(pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*"));
|
|
717
|
+
return regex.test(diff.filename);
|
|
718
|
+
});
|
|
719
|
+
if (!matchesInclude)
|
|
720
|
+
return false;
|
|
721
|
+
const matchesExclude = config.excludePatterns.some((pattern) => {
|
|
722
|
+
const regex = new RegExp(pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*"));
|
|
723
|
+
return regex.test(diff.filename);
|
|
724
|
+
});
|
|
725
|
+
return !matchesExclude;
|
|
726
|
+
});
|
|
727
|
+
debug(`Filtered to ${filteredDiffs.length} file(s) after pattern matching`);
|
|
728
|
+
const targets = [];
|
|
729
|
+
for (const diff of filteredDiffs) {
|
|
730
|
+
if (diff.status === "removed") {
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
const fileContents = await githubClient.getFileContents(prHeadRef, diff.filename);
|
|
735
|
+
const changedRanges = getChangedRanges(diff, fileContents.content);
|
|
736
|
+
if (changedRanges.length === 0) {
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const ranges = changedRanges.map((r) => ({
|
|
740
|
+
start: r.start,
|
|
741
|
+
end: r.end,
|
|
742
|
+
type: r.type
|
|
743
|
+
}));
|
|
744
|
+
const fileTargets = await analyzeFile(
|
|
745
|
+
diff.filename,
|
|
746
|
+
fileContents.content,
|
|
747
|
+
ranges,
|
|
748
|
+
prHeadRef,
|
|
749
|
+
githubClient,
|
|
750
|
+
config.testDirectory
|
|
751
|
+
);
|
|
752
|
+
targets.push(...fileTargets);
|
|
753
|
+
if (fileTargets.length > 0) {
|
|
754
|
+
info(`Found ${fileTargets.length} test target(s) in ${diff.filename}`);
|
|
755
|
+
}
|
|
756
|
+
} catch (error2) {
|
|
757
|
+
debug(`Failed to analyze ${diff.filename}: ${error2 instanceof Error ? error2.message : String(error2)}`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
info(`Extracted ${targets.length} total test target(s)`);
|
|
761
|
+
return targets;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// src/llm/providers/base.ts
|
|
765
|
+
var BaseLLMProvider = class {
|
|
766
|
+
constructor(apiKey, model, defaultOptions) {
|
|
767
|
+
this.apiKey = apiKey;
|
|
768
|
+
this.model = model;
|
|
769
|
+
this.defaultOptions = {
|
|
770
|
+
temperature: defaultOptions?.temperature ?? 0.2,
|
|
771
|
+
maxTokens: defaultOptions?.maxTokens ?? 4e3,
|
|
772
|
+
stopSequences: defaultOptions?.stopSequences ?? []
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
mergeOptions(options) {
|
|
776
|
+
return {
|
|
777
|
+
temperature: options?.temperature ?? this.defaultOptions.temperature,
|
|
778
|
+
maxTokens: options?.maxTokens ?? this.defaultOptions.maxTokens,
|
|
779
|
+
stopSequences: options?.stopSequences ?? this.defaultOptions.stopSequences
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
validateApiKey() {
|
|
783
|
+
if (!this.apiKey || this.apiKey.trim().length === 0) {
|
|
784
|
+
error("LLM API key is required but not provided");
|
|
785
|
+
throw new Error("LLM API key is required");
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
logUsage(usage, operation) {
|
|
789
|
+
if (usage) {
|
|
790
|
+
debug(
|
|
791
|
+
`${operation} usage: ${usage.totalTokens ?? "unknown"} tokens (prompt: ${usage.promptTokens ?? "unknown"}, completion: ${usage.completionTokens ?? "unknown"})`
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
// src/llm/providers/openai.ts
|
|
798
|
+
var OpenAIProvider = class extends BaseLLMProvider {
|
|
799
|
+
constructor() {
|
|
800
|
+
super(...arguments);
|
|
801
|
+
this.baseUrl = "https://api.openai.com/v1";
|
|
802
|
+
}
|
|
803
|
+
async generate(messages, options) {
|
|
804
|
+
this.validateApiKey();
|
|
805
|
+
const mergedOptions = this.mergeOptions(options);
|
|
806
|
+
const requestBody = {
|
|
807
|
+
model: this.model,
|
|
808
|
+
messages: messages.map((msg) => ({
|
|
809
|
+
role: msg.role,
|
|
810
|
+
content: msg.content
|
|
811
|
+
})),
|
|
812
|
+
temperature: mergedOptions.temperature,
|
|
813
|
+
max_tokens: mergedOptions.maxTokens,
|
|
814
|
+
...mergedOptions.stopSequences.length > 0 && { stop: mergedOptions.stopSequences }
|
|
815
|
+
};
|
|
816
|
+
debug(`Calling OpenAI API with model: ${this.model}`);
|
|
817
|
+
try {
|
|
818
|
+
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
819
|
+
method: "POST",
|
|
820
|
+
headers: {
|
|
821
|
+
"Content-Type": "application/json",
|
|
822
|
+
Authorization: `Bearer ${this.apiKey}`
|
|
823
|
+
},
|
|
824
|
+
body: JSON.stringify(requestBody)
|
|
825
|
+
});
|
|
826
|
+
if (!response.ok) {
|
|
827
|
+
const errorText = await response.text();
|
|
828
|
+
error(`OpenAI API error: ${response.status} ${response.statusText} - ${errorText}`);
|
|
829
|
+
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
|
|
830
|
+
}
|
|
831
|
+
const data = await response.json();
|
|
832
|
+
if (!data.choices || data.choices.length === 0) {
|
|
833
|
+
error("OpenAI API returned no choices");
|
|
834
|
+
throw new Error("OpenAI API returned no choices");
|
|
835
|
+
}
|
|
836
|
+
const content = data.choices[0]?.message?.content ?? "";
|
|
837
|
+
const usage = data.usage ? {
|
|
838
|
+
promptTokens: data.usage.prompt_tokens,
|
|
839
|
+
completionTokens: data.usage.completion_tokens,
|
|
840
|
+
totalTokens: data.usage.total_tokens
|
|
841
|
+
} : void 0;
|
|
842
|
+
this.logUsage(usage, "OpenAI");
|
|
843
|
+
return {
|
|
844
|
+
content,
|
|
845
|
+
usage
|
|
846
|
+
};
|
|
847
|
+
} catch (err) {
|
|
848
|
+
if (err instanceof Error) {
|
|
849
|
+
error(`OpenAI API request failed: ${err.message}`);
|
|
850
|
+
throw err;
|
|
851
|
+
}
|
|
852
|
+
throw new Error("Unknown error calling OpenAI API");
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
// src/llm/providers/anthropic.ts
|
|
858
|
+
var AnthropicProvider = class extends BaseLLMProvider {
|
|
859
|
+
constructor() {
|
|
860
|
+
super(...arguments);
|
|
861
|
+
this.baseUrl = "https://api.anthropic.com/v1";
|
|
862
|
+
}
|
|
863
|
+
async generate(messages, options) {
|
|
864
|
+
this.validateApiKey();
|
|
865
|
+
const mergedOptions = this.mergeOptions(options);
|
|
866
|
+
const systemMessage = messages.find((m) => m.role === "system")?.content ?? "";
|
|
867
|
+
const conversationMessages = messages.filter((m) => m.role !== "system");
|
|
868
|
+
const requestBody = {
|
|
869
|
+
model: this.model,
|
|
870
|
+
max_tokens: mergedOptions.maxTokens,
|
|
871
|
+
temperature: mergedOptions.temperature,
|
|
872
|
+
messages: conversationMessages.map((msg) => ({
|
|
873
|
+
role: msg.role === "assistant" ? "assistant" : "user",
|
|
874
|
+
content: msg.content
|
|
875
|
+
})),
|
|
876
|
+
...systemMessage && { system: systemMessage },
|
|
877
|
+
...mergedOptions.stopSequences.length > 0 && { stop_sequences: mergedOptions.stopSequences }
|
|
878
|
+
};
|
|
879
|
+
debug(`Calling Anthropic API with model: ${this.model}`);
|
|
880
|
+
try {
|
|
881
|
+
const response = await fetch(`${this.baseUrl}/messages`, {
|
|
882
|
+
method: "POST",
|
|
883
|
+
headers: {
|
|
884
|
+
"Content-Type": "application/json",
|
|
885
|
+
"x-api-key": this.apiKey,
|
|
886
|
+
"anthropic-version": "2023-06-01"
|
|
887
|
+
},
|
|
888
|
+
body: JSON.stringify(requestBody)
|
|
889
|
+
});
|
|
890
|
+
if (!response.ok) {
|
|
891
|
+
const errorText = await response.text();
|
|
892
|
+
error(`Anthropic API error: ${response.status} ${response.statusText} - ${errorText}`);
|
|
893
|
+
throw new Error(`Anthropic API error: ${response.status} ${response.statusText}`);
|
|
894
|
+
}
|
|
895
|
+
const data = await response.json();
|
|
896
|
+
if (!data.content || data.content.length === 0) {
|
|
897
|
+
error("Anthropic API returned no content");
|
|
898
|
+
throw new Error("Anthropic API returned no content");
|
|
899
|
+
}
|
|
900
|
+
const content = data.content.map((c) => c.text).join("\n");
|
|
901
|
+
const usage = data.usage ? {
|
|
902
|
+
promptTokens: data.usage.input_tokens,
|
|
903
|
+
completionTokens: data.usage.output_tokens,
|
|
904
|
+
totalTokens: data.usage.input_tokens + data.usage.output_tokens
|
|
905
|
+
} : void 0;
|
|
906
|
+
this.logUsage(usage, "Anthropic");
|
|
907
|
+
return {
|
|
908
|
+
content,
|
|
909
|
+
usage
|
|
910
|
+
};
|
|
911
|
+
} catch (err) {
|
|
912
|
+
if (err instanceof Error) {
|
|
913
|
+
error(`Anthropic API request failed: ${err.message}`);
|
|
914
|
+
throw err;
|
|
915
|
+
}
|
|
916
|
+
throw new Error("Unknown error calling Anthropic API");
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
// src/llm/providers/google.ts
|
|
922
|
+
var GoogleProvider = class extends BaseLLMProvider {
|
|
923
|
+
constructor() {
|
|
924
|
+
super(...arguments);
|
|
925
|
+
this.baseUrl = "https://generativelanguage.googleapis.com/v1beta";
|
|
926
|
+
}
|
|
927
|
+
async generate(messages, options) {
|
|
928
|
+
this.validateApiKey();
|
|
929
|
+
const mergedOptions = this.mergeOptions(options);
|
|
930
|
+
const systemInstruction = messages.find((m) => m.role === "system")?.content;
|
|
931
|
+
const conversationMessages = messages.filter((m) => m.role !== "system");
|
|
932
|
+
const contents = conversationMessages.map((msg) => ({
|
|
933
|
+
role: msg.role === "assistant" ? "model" : "user",
|
|
934
|
+
parts: [{ text: msg.content }]
|
|
935
|
+
}));
|
|
936
|
+
const generationConfig = {
|
|
937
|
+
temperature: mergedOptions.temperature,
|
|
938
|
+
maxOutputTokens: mergedOptions.maxTokens,
|
|
939
|
+
...mergedOptions.stopSequences.length > 0 && { stopSequences: mergedOptions.stopSequences }
|
|
940
|
+
};
|
|
941
|
+
const requestBody = {
|
|
942
|
+
contents,
|
|
943
|
+
generationConfig,
|
|
944
|
+
...systemInstruction && { systemInstruction: { parts: [{ text: systemInstruction }] } }
|
|
945
|
+
};
|
|
946
|
+
debug(`Calling Google API with model: ${this.model}`);
|
|
947
|
+
try {
|
|
948
|
+
const response = await fetch(`${this.baseUrl}/${this.model}:generateContent?key=${this.apiKey}`, {
|
|
949
|
+
method: "POST",
|
|
950
|
+
headers: {
|
|
951
|
+
"Content-Type": "application/json"
|
|
952
|
+
},
|
|
953
|
+
body: JSON.stringify(requestBody)
|
|
954
|
+
});
|
|
955
|
+
if (!response.ok) {
|
|
956
|
+
const errorText = await response.text();
|
|
957
|
+
error(`Google API error: ${response.status} ${response.statusText} - ${errorText}`);
|
|
958
|
+
throw new Error(`Google API error: ${response.status} ${response.statusText}`);
|
|
959
|
+
}
|
|
960
|
+
const data = await response.json();
|
|
961
|
+
if (!data.candidates || data.candidates.length === 0) {
|
|
962
|
+
error("Google API returned no candidates");
|
|
963
|
+
throw new Error("Google API returned no candidates");
|
|
964
|
+
}
|
|
965
|
+
const content = data.candidates[0]?.content?.parts?.map((p) => p.text).join("\n") ?? "";
|
|
966
|
+
const usage = data.usageMetadata ? {
|
|
967
|
+
promptTokens: data.usageMetadata.promptTokenCount,
|
|
968
|
+
completionTokens: data.usageMetadata.candidatesTokenCount,
|
|
969
|
+
totalTokens: data.usageMetadata.totalTokenCount
|
|
970
|
+
} : void 0;
|
|
971
|
+
this.logUsage(usage, "Google");
|
|
972
|
+
return {
|
|
973
|
+
content,
|
|
974
|
+
usage
|
|
975
|
+
};
|
|
976
|
+
} catch (err) {
|
|
977
|
+
if (err instanceof Error) {
|
|
978
|
+
error(`Google API request failed: ${err.message}`);
|
|
979
|
+
throw err;
|
|
980
|
+
}
|
|
981
|
+
throw new Error("Unknown error calling Google API");
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
// src/llm/factory.ts
|
|
987
|
+
function createLLMProvider(config) {
|
|
988
|
+
const provider = config.provider ?? "openai";
|
|
989
|
+
const model = config.model ?? getDefaultModel(provider);
|
|
990
|
+
const defaultOptions = config.maxTokens ? { maxTokens: config.maxTokens } : void 0;
|
|
991
|
+
switch (provider) {
|
|
992
|
+
case "openai":
|
|
993
|
+
return new OpenAIProvider(config.apiKey, model, defaultOptions);
|
|
994
|
+
case "anthropic":
|
|
995
|
+
return new AnthropicProvider(config.apiKey, model, defaultOptions);
|
|
996
|
+
case "google":
|
|
997
|
+
return new GoogleProvider(config.apiKey, model, defaultOptions);
|
|
998
|
+
default:
|
|
999
|
+
error(`Unknown LLM provider: ${provider}`);
|
|
1000
|
+
throw new Error(`Unknown LLM provider: ${provider}`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
function getDefaultModel(provider) {
|
|
1004
|
+
switch (provider) {
|
|
1005
|
+
case "openai":
|
|
1006
|
+
return "gpt-4-turbo-preview";
|
|
1007
|
+
case "anthropic":
|
|
1008
|
+
return "claude-3-5-sonnet-20241022";
|
|
1009
|
+
case "google":
|
|
1010
|
+
return "gemini-1.5-pro";
|
|
1011
|
+
default:
|
|
1012
|
+
return "gpt-4-turbo-preview";
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// src/llm/prompts/test-generation.ts
|
|
1017
|
+
function buildTestGenerationPrompt(context) {
|
|
1018
|
+
const { target, framework, existingTestFile, relatedFunctions } = context;
|
|
1019
|
+
const systemPrompt = buildSystemPrompt(framework);
|
|
1020
|
+
const userPrompt = buildUserPrompt(target, framework, existingTestFile, relatedFunctions);
|
|
1021
|
+
return [
|
|
1022
|
+
{ role: "system", content: systemPrompt },
|
|
1023
|
+
{ role: "user", content: userPrompt }
|
|
1024
|
+
];
|
|
1025
|
+
}
|
|
1026
|
+
function buildSystemPrompt(framework) {
|
|
1027
|
+
const frameworkName = framework === "jest" ? "Jest" : "Vitest";
|
|
1028
|
+
const importStatement = framework === "jest" ? "import { describe, it, expect } from 'jest';" : "import { describe, it, expect } from 'vitest';";
|
|
1029
|
+
return `You are an expert ${frameworkName} test writer. Your task is to generate comprehensive unit tests for TypeScript/JavaScript functions.
|
|
1030
|
+
|
|
1031
|
+
Requirements:
|
|
1032
|
+
1. Generate complete, runnable ${frameworkName} test code
|
|
1033
|
+
2. Use ${frameworkName} syntax and best practices
|
|
1034
|
+
3. Test edge cases, error conditions, and normal operation
|
|
1035
|
+
4. Use descriptive test names that explain what is being tested
|
|
1036
|
+
5. Include proper setup/teardown if needed
|
|
1037
|
+
6. Mock external dependencies appropriately
|
|
1038
|
+
7. Test both success and failure scenarios
|
|
1039
|
+
8. Follow the existing test file structure if one exists
|
|
1040
|
+
|
|
1041
|
+
Output format:
|
|
1042
|
+
- Return ONLY the test code, no explanations or markdown code blocks
|
|
1043
|
+
- The code should be ready to run in a ${frameworkName} environment
|
|
1044
|
+
- Include necessary imports at the top
|
|
1045
|
+
- Use proper TypeScript types if the source code uses TypeScript
|
|
1046
|
+
|
|
1047
|
+
${frameworkName} example structure:
|
|
1048
|
+
${importStatement}
|
|
1049
|
+
|
|
1050
|
+
describe('FunctionName', () => {
|
|
1051
|
+
it('should handle normal case', () => {
|
|
1052
|
+
// test implementation
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it('should handle edge case', () => {
|
|
1056
|
+
// test implementation
|
|
1057
|
+
});
|
|
1058
|
+
});`;
|
|
1059
|
+
}
|
|
1060
|
+
function buildUserPrompt(target, framework, existingTestFile, relatedFunctions) {
|
|
1061
|
+
let prompt = `Generate ${framework} unit tests for the following function:
|
|
1062
|
+
|
|
1063
|
+
`;
|
|
1064
|
+
prompt += `File: ${target.filePath}
|
|
1065
|
+
`;
|
|
1066
|
+
prompt += `Function: ${target.functionName}
|
|
1067
|
+
`;
|
|
1068
|
+
prompt += `Type: ${target.functionType}
|
|
1069
|
+
|
|
1070
|
+
`;
|
|
1071
|
+
prompt += `Function code:
|
|
1072
|
+
\`\`\`typescript
|
|
1073
|
+
${target.code}
|
|
1074
|
+
\`\`\`
|
|
1075
|
+
|
|
1076
|
+
`;
|
|
1077
|
+
if (target.context) {
|
|
1078
|
+
prompt += `Context (surrounding code):
|
|
1079
|
+
\`\`\`typescript
|
|
1080
|
+
${target.context}
|
|
1081
|
+
\`\`\`
|
|
1082
|
+
|
|
1083
|
+
`;
|
|
1084
|
+
}
|
|
1085
|
+
if (relatedFunctions && relatedFunctions.length > 0) {
|
|
1086
|
+
prompt += `Related functions (for context):
|
|
1087
|
+
`;
|
|
1088
|
+
relatedFunctions.forEach((fn) => {
|
|
1089
|
+
prompt += `
|
|
1090
|
+
${fn.name}:
|
|
1091
|
+
\`\`\`typescript
|
|
1092
|
+
${fn.code}
|
|
1093
|
+
\`\`\`
|
|
1094
|
+
`;
|
|
1095
|
+
});
|
|
1096
|
+
prompt += "\n";
|
|
1097
|
+
}
|
|
1098
|
+
if (existingTestFile) {
|
|
1099
|
+
prompt += `Existing test file structure (follow this pattern):
|
|
1100
|
+
\`\`\`typescript
|
|
1101
|
+
${existingTestFile}
|
|
1102
|
+
\`\`\`
|
|
1103
|
+
|
|
1104
|
+
`;
|
|
1105
|
+
prompt += `Note: Add new tests to this file, maintaining the existing structure and style.
|
|
1106
|
+
|
|
1107
|
+
`;
|
|
1108
|
+
}
|
|
1109
|
+
prompt += `Generate comprehensive unit tests for ${target.functionName}. Include:
|
|
1110
|
+
`;
|
|
1111
|
+
prompt += `- Tests for normal operation with various inputs
|
|
1112
|
+
`;
|
|
1113
|
+
prompt += `- Tests for edge cases (null, undefined, empty arrays, etc.)
|
|
1114
|
+
`;
|
|
1115
|
+
prompt += `- Tests for error conditions if applicable
|
|
1116
|
+
`;
|
|
1117
|
+
prompt += `- Tests for boundary conditions
|
|
1118
|
+
`;
|
|
1119
|
+
prompt += `- Proper mocking of dependencies if needed
|
|
1120
|
+
|
|
1121
|
+
`;
|
|
1122
|
+
prompt += `Return ONLY the test code, no explanations or markdown formatting.`;
|
|
1123
|
+
return prompt;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// src/llm/prompts/test-fix.ts
|
|
1127
|
+
function buildTestFixPrompt(context) {
|
|
1128
|
+
const { testCode, errorMessage, testOutput, originalCode, framework, attempt, maxAttempts } = context;
|
|
1129
|
+
const systemPrompt = buildSystemPrompt2(framework, attempt, maxAttempts);
|
|
1130
|
+
const userPrompt = buildUserPrompt2(testCode, errorMessage, testOutput, originalCode, framework, attempt);
|
|
1131
|
+
return [
|
|
1132
|
+
{ role: "system", content: systemPrompt },
|
|
1133
|
+
{ role: "user", content: userPrompt }
|
|
1134
|
+
];
|
|
1135
|
+
}
|
|
1136
|
+
function buildSystemPrompt2(framework, attempt, maxAttempts) {
|
|
1137
|
+
const frameworkName = framework === "jest" ? "Jest" : "Vitest";
|
|
1138
|
+
return `You are an expert ${frameworkName} test debugger. Your task is to fix failing unit tests.
|
|
1139
|
+
|
|
1140
|
+
Context:
|
|
1141
|
+
- This is fix attempt ${attempt} of ${maxAttempts}
|
|
1142
|
+
- The test code failed to run or produced incorrect results
|
|
1143
|
+
- You need to analyze the error and fix the test code
|
|
1144
|
+
|
|
1145
|
+
Requirements:
|
|
1146
|
+
1. Fix the test code to make it pass
|
|
1147
|
+
2. Maintain the original test intent
|
|
1148
|
+
3. Use proper ${frameworkName} syntax
|
|
1149
|
+
4. Ensure all imports and dependencies are correct
|
|
1150
|
+
5. Fix any syntax errors, type errors, or logical errors
|
|
1151
|
+
6. If the original code being tested has issues, note that but focus on fixing the test
|
|
1152
|
+
|
|
1153
|
+
Output format:
|
|
1154
|
+
- Return ONLY the fixed test code, no explanations or markdown code blocks
|
|
1155
|
+
- The code should be complete and runnable
|
|
1156
|
+
- Include all necessary imports`;
|
|
1157
|
+
}
|
|
1158
|
+
function buildUserPrompt2(testCode, errorMessage, testOutput, originalCode, framework, attempt) {
|
|
1159
|
+
let prompt = `The following ${framework} test is failing. Fix it:
|
|
1160
|
+
|
|
1161
|
+
`;
|
|
1162
|
+
prompt += `Original function code:
|
|
1163
|
+
\`\`\`typescript
|
|
1164
|
+
${originalCode}
|
|
1165
|
+
\`\`\`
|
|
1166
|
+
|
|
1167
|
+
`;
|
|
1168
|
+
prompt += `Failing test code:
|
|
1169
|
+
\`\`\`typescript
|
|
1170
|
+
${testCode}
|
|
1171
|
+
\`\`\`
|
|
1172
|
+
|
|
1173
|
+
`;
|
|
1174
|
+
prompt += `Error message:
|
|
1175
|
+
\`\`\`
|
|
1176
|
+
${errorMessage}
|
|
1177
|
+
\`\`\`
|
|
1178
|
+
|
|
1179
|
+
`;
|
|
1180
|
+
if (testOutput) {
|
|
1181
|
+
prompt += `Test output:
|
|
1182
|
+
\`\`\`
|
|
1183
|
+
${testOutput}
|
|
1184
|
+
\`\`\`
|
|
1185
|
+
|
|
1186
|
+
`;
|
|
1187
|
+
}
|
|
1188
|
+
if (attempt > 1) {
|
|
1189
|
+
prompt += `Note: This is fix attempt ${attempt}. Previous attempts failed. Please analyze the error more carefully.
|
|
1190
|
+
|
|
1191
|
+
`;
|
|
1192
|
+
}
|
|
1193
|
+
prompt += `Fix the test code to resolve the error. Return ONLY the corrected test code, no explanations.`;
|
|
1194
|
+
return prompt;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// src/llm/parser.ts
|
|
1198
|
+
function parseTestCode(response) {
|
|
1199
|
+
let code = response.trim();
|
|
1200
|
+
const codeBlockRegex = /^```(?:typescript|ts|javascript|js)?\s*\n([\s\S]*?)\n```$/;
|
|
1201
|
+
const match = code.match(codeBlockRegex);
|
|
1202
|
+
if (match) {
|
|
1203
|
+
code = match[1].trim();
|
|
1204
|
+
} else {
|
|
1205
|
+
const inlineCodeRegex = /```([\s\S]*?)```/g;
|
|
1206
|
+
const inlineMatches = Array.from(code.matchAll(inlineCodeRegex));
|
|
1207
|
+
if (inlineMatches.length > 0) {
|
|
1208
|
+
code = inlineMatches.reduce((largest, match2) => {
|
|
1209
|
+
return match2[1].length > largest.length ? match2[1] : largest;
|
|
1210
|
+
}, "");
|
|
1211
|
+
code = code.trim();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
const explanationPatterns = [
|
|
1215
|
+
/^Here'?s?\s+(?:the\s+)?(?:test\s+)?code:?\s*/i,
|
|
1216
|
+
/^Test\s+code:?\s*/i,
|
|
1217
|
+
/^Generated\s+test:?\s*/i,
|
|
1218
|
+
/^Here\s+is\s+the\s+test:?\s*/i
|
|
1219
|
+
];
|
|
1220
|
+
for (const pattern of explanationPatterns) {
|
|
1221
|
+
if (pattern.test(code)) {
|
|
1222
|
+
code = code.replace(pattern, "").trim();
|
|
1223
|
+
const codeBlockMatch = code.match(/```[\s\S]*?```/);
|
|
1224
|
+
if (codeBlockMatch) {
|
|
1225
|
+
code = codeBlockMatch[0];
|
|
1226
|
+
code = code.replace(/^```(?:typescript|ts|javascript|js)?\s*\n?/, "").replace(/\n?```$/, "").trim();
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
code = code.replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "").trim();
|
|
1231
|
+
if (!code) {
|
|
1232
|
+
warn("Failed to extract test code from LLM response");
|
|
1233
|
+
return response;
|
|
1234
|
+
}
|
|
1235
|
+
return code;
|
|
1236
|
+
}
|
|
1237
|
+
function validateTestCodeStructure(code, framework) {
|
|
1238
|
+
const errors = [];
|
|
1239
|
+
if (!code.includes("describe") && !code.includes("it(") && !code.includes("test(")) {
|
|
1240
|
+
errors.push("Missing test structure (describe/it/test)");
|
|
1241
|
+
}
|
|
1242
|
+
if (framework === "jest") {
|
|
1243
|
+
if (!code.includes("from 'jest'") && !code.includes('from "jest"') && !code.includes("require(")) {
|
|
1244
|
+
if (!code.includes("describe") && !code.includes("it") && !code.includes("test")) {
|
|
1245
|
+
errors.push("Missing Jest test functions");
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
} else if (framework === "vitest") {
|
|
1249
|
+
if (!code.includes("from 'vitest'") && !code.includes('from "vitest"')) {
|
|
1250
|
+
errors.push("Missing Vitest import");
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
if (code.trim().length < 20) {
|
|
1254
|
+
errors.push("Test code appears too short or empty");
|
|
1255
|
+
}
|
|
1256
|
+
if (!code.match(/(describe|it|test)\s*\(/)) {
|
|
1257
|
+
errors.push("Missing test function calls (describe/it/test)");
|
|
1258
|
+
}
|
|
1259
|
+
return {
|
|
1260
|
+
valid: errors.length === 0,
|
|
1261
|
+
errors
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// src/llm/test-generator.ts
|
|
1266
|
+
var TestGenerator = class {
|
|
1267
|
+
constructor(config) {
|
|
1268
|
+
this.provider = createLLMProvider(config);
|
|
1269
|
+
this.config = {
|
|
1270
|
+
maxFixAttempts: config.maxFixAttempts,
|
|
1271
|
+
temperature: config.temperature,
|
|
1272
|
+
fixTemperature: config.fixTemperature
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Generate test code for a test target
|
|
1277
|
+
*/
|
|
1278
|
+
async generateTest(context) {
|
|
1279
|
+
const { target, framework } = context;
|
|
1280
|
+
info(`Generating ${framework} tests for ${target.functionName} in ${target.filePath}`);
|
|
1281
|
+
try {
|
|
1282
|
+
const messages = buildTestGenerationPrompt(context);
|
|
1283
|
+
debug(`Sending test generation request to LLM for ${target.functionName}`);
|
|
1284
|
+
const response = await this.provider.generate(messages, {
|
|
1285
|
+
temperature: this.config.temperature ?? 0.2,
|
|
1286
|
+
// Lower temperature for more consistent test generation
|
|
1287
|
+
maxTokens: 4e3
|
|
1288
|
+
});
|
|
1289
|
+
const testCode = parseTestCode(response.content);
|
|
1290
|
+
const validation = validateTestCodeStructure(testCode, framework);
|
|
1291
|
+
if (!validation.valid) {
|
|
1292
|
+
warn(`Test code validation warnings for ${target.functionName}: ${validation.errors.join(", ")}`);
|
|
1293
|
+
}
|
|
1294
|
+
debug(`Successfully generated test code for ${target.functionName}`);
|
|
1295
|
+
return {
|
|
1296
|
+
testCode,
|
|
1297
|
+
explanation: response.content !== testCode ? "Code extracted from LLM response" : void 0,
|
|
1298
|
+
usage: response.usage
|
|
1299
|
+
};
|
|
1300
|
+
} catch (err) {
|
|
1301
|
+
error(`Failed to generate test for ${target.functionName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1302
|
+
throw err;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Fix a failing test by generating a corrected version
|
|
1307
|
+
*/
|
|
1308
|
+
async fixTest(context) {
|
|
1309
|
+
const { framework, attempt } = context;
|
|
1310
|
+
info(`Fixing test (attempt ${attempt}/${this.config.maxFixAttempts})`);
|
|
1311
|
+
try {
|
|
1312
|
+
const messages = buildTestFixPrompt(context);
|
|
1313
|
+
debug(`Sending test fix request to LLM (attempt ${attempt})`);
|
|
1314
|
+
const response = await this.provider.generate(messages, {
|
|
1315
|
+
temperature: this.config.fixTemperature ?? 0.1,
|
|
1316
|
+
// Very low temperature for fix attempts
|
|
1317
|
+
maxTokens: 4e3
|
|
1318
|
+
});
|
|
1319
|
+
const fixedCode = parseTestCode(response.content);
|
|
1320
|
+
const validation = validateTestCodeStructure(fixedCode, framework);
|
|
1321
|
+
if (!validation.valid) {
|
|
1322
|
+
warn(`Fixed test code validation warnings: ${validation.errors.join(", ")}`);
|
|
1323
|
+
}
|
|
1324
|
+
debug(`Successfully generated fixed test code (attempt ${attempt})`);
|
|
1325
|
+
return {
|
|
1326
|
+
testCode: fixedCode,
|
|
1327
|
+
explanation: `Fixed test code (attempt ${attempt})`,
|
|
1328
|
+
usage: response.usage
|
|
1329
|
+
};
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
error(`Failed to fix test (attempt ${attempt}): ${err instanceof Error ? err.message : String(err)}`);
|
|
1332
|
+
throw err;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Generate a human-readable coverage summary
|
|
1337
|
+
*/
|
|
1338
|
+
async generateCoverageSummary(messages) {
|
|
1339
|
+
try {
|
|
1340
|
+
const response = await this.provider.generate(messages, {
|
|
1341
|
+
temperature: 0.3,
|
|
1342
|
+
maxTokens: 500
|
|
1343
|
+
});
|
|
1344
|
+
return response.content;
|
|
1345
|
+
} catch (err) {
|
|
1346
|
+
error(`Failed to generate coverage summary: ${err instanceof Error ? err.message : String(err)}`);
|
|
1347
|
+
throw err;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
// src/utils/test-file-path.ts
|
|
1353
|
+
function getTestFilePath(target, config) {
|
|
1354
|
+
const sourcePath = target.filePath;
|
|
1355
|
+
const dir = sourcePath.substring(0, sourcePath.lastIndexOf("/"));
|
|
1356
|
+
const baseName = sourcePath.substring(sourcePath.lastIndexOf("/") + 1).replace(/\.(ts|tsx|js|jsx)$/, "");
|
|
1357
|
+
let ext;
|
|
1358
|
+
if (sourcePath.endsWith(".tsx"))
|
|
1359
|
+
ext = "tsx";
|
|
1360
|
+
else if (sourcePath.endsWith(".jsx"))
|
|
1361
|
+
ext = "jsx";
|
|
1362
|
+
else if (sourcePath.endsWith(".ts"))
|
|
1363
|
+
ext = "ts";
|
|
1364
|
+
else
|
|
1365
|
+
ext = "js";
|
|
1366
|
+
const testExt = ext === "tsx" || ext === "ts" ? "ts" : "js";
|
|
1367
|
+
if (config.testLocation === "co-located") {
|
|
1368
|
+
return `${dir}/${baseName}.test.${testExt}`;
|
|
1369
|
+
} else {
|
|
1370
|
+
const testFileName = config.testFilePattern.replace("*", baseName);
|
|
1371
|
+
return `${config.testDirectory}/${testFileName}`;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// src/utils/package-manager-detector.ts
|
|
1376
|
+
import { existsSync } from "fs";
|
|
1377
|
+
import { join } from "path";
|
|
1378
|
+
function detectPackageManager(projectRoot) {
|
|
1379
|
+
if (existsSync(join(projectRoot, "pnpm-lock.yaml"))) {
|
|
1380
|
+
return "pnpm";
|
|
1381
|
+
}
|
|
1382
|
+
if (existsSync(join(projectRoot, "yarn.lock"))) {
|
|
1383
|
+
return "yarn";
|
|
1384
|
+
}
|
|
1385
|
+
if (existsSync(join(projectRoot, "package-lock.json"))) {
|
|
1386
|
+
return "npm";
|
|
1387
|
+
}
|
|
1388
|
+
return "npm";
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// src/utils/test-runner/jest-runner.ts
|
|
1392
|
+
import { exec } from "child_process";
|
|
1393
|
+
import { promisify } from "util";
|
|
1394
|
+
var execAsync = promisify(exec);
|
|
1395
|
+
var JestRunner = class {
|
|
1396
|
+
async runTests(options) {
|
|
1397
|
+
const { testFiles, packageManager, projectRoot, coverage } = options;
|
|
1398
|
+
debug(`Running Jest tests for ${testFiles.length} file(s)`);
|
|
1399
|
+
const testFilesArg = testFiles.map((f) => `"${f}"`).join(" ");
|
|
1400
|
+
const coverageFlag = coverage ? "--coverage --coverageReporters=json" : "--no-coverage";
|
|
1401
|
+
const cmd = `${packageManager} test -- --json ${coverageFlag} ${testFilesArg}`;
|
|
1402
|
+
try {
|
|
1403
|
+
const { stdout, stderr } = await execAsync(cmd, {
|
|
1404
|
+
cwd: projectRoot,
|
|
1405
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1406
|
+
// 10MB
|
|
1407
|
+
});
|
|
1408
|
+
if (stderr && !stderr.includes("PASS") && !stderr.includes("FAIL")) {
|
|
1409
|
+
debug(`Jest stderr: ${stderr}`);
|
|
1410
|
+
}
|
|
1411
|
+
const result = JSON.parse(stdout);
|
|
1412
|
+
return testFiles.map((testFile, index) => {
|
|
1413
|
+
const testResult = result.testResults[index] || result.testResults[0];
|
|
1414
|
+
const failures = [];
|
|
1415
|
+
if (testResult) {
|
|
1416
|
+
for (const assertion of testResult.assertionResults) {
|
|
1417
|
+
if (assertion.status === "failed" && assertion.failureMessages.length > 0) {
|
|
1418
|
+
const failureMessage = assertion.failureMessages[0];
|
|
1419
|
+
failures.push({
|
|
1420
|
+
testName: assertion.title,
|
|
1421
|
+
message: failureMessage,
|
|
1422
|
+
stack: failureMessage
|
|
1423
|
+
});
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
return {
|
|
1428
|
+
success: result.numFailedTests === 0,
|
|
1429
|
+
testFile,
|
|
1430
|
+
passed: result.numPassedTests,
|
|
1431
|
+
failed: result.numFailedTests,
|
|
1432
|
+
total: result.numTotalTests,
|
|
1433
|
+
duration: 0,
|
|
1434
|
+
// Jest JSON doesn't include duration per file
|
|
1435
|
+
failures
|
|
1436
|
+
};
|
|
1437
|
+
});
|
|
1438
|
+
} catch (err) {
|
|
1439
|
+
if (err && typeof err === "object" && "stdout" in err) {
|
|
1440
|
+
try {
|
|
1441
|
+
const result = JSON.parse(err.stdout);
|
|
1442
|
+
return testFiles.map((testFile) => {
|
|
1443
|
+
const failures = [];
|
|
1444
|
+
for (const testResult of result.testResults) {
|
|
1445
|
+
for (const assertion of testResult.assertionResults) {
|
|
1446
|
+
if (assertion.status === "failed" && assertion.failureMessages.length > 0) {
|
|
1447
|
+
failures.push({
|
|
1448
|
+
testName: assertion.title,
|
|
1449
|
+
message: assertion.failureMessages[0],
|
|
1450
|
+
stack: assertion.failureMessages[0]
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return {
|
|
1456
|
+
success: result.numFailedTests === 0,
|
|
1457
|
+
testFile,
|
|
1458
|
+
passed: result.numPassedTests,
|
|
1459
|
+
failed: result.numFailedTests,
|
|
1460
|
+
total: result.numTotalTests,
|
|
1461
|
+
duration: 0,
|
|
1462
|
+
failures
|
|
1463
|
+
};
|
|
1464
|
+
});
|
|
1465
|
+
} catch (parseErr) {
|
|
1466
|
+
error(`Failed to parse Jest output: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
|
|
1467
|
+
throw err;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
error(`Jest test execution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1471
|
+
throw err;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1476
|
+
// src/utils/test-runner/vitest-runner.ts
|
|
1477
|
+
import { exec as exec2 } from "child_process";
|
|
1478
|
+
import { promisify as promisify2 } from "util";
|
|
1479
|
+
var execAsync2 = promisify2(exec2);
|
|
1480
|
+
var VitestRunner = class {
|
|
1481
|
+
async runTests(options) {
|
|
1482
|
+
const { testFiles, packageManager, projectRoot, coverage } = options;
|
|
1483
|
+
debug(`Running Vitest tests for ${testFiles.length} file(s)`);
|
|
1484
|
+
const testFilesArg = testFiles.map((f) => `"${f}"`).join(" ");
|
|
1485
|
+
const coverageFlag = coverage ? "--coverage" : "";
|
|
1486
|
+
const cmd = `${packageManager} test -- --reporter=json ${coverageFlag} ${testFilesArg}`;
|
|
1487
|
+
try {
|
|
1488
|
+
const { stdout, stderr } = await execAsync2(cmd, {
|
|
1489
|
+
cwd: projectRoot,
|
|
1490
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1491
|
+
// 10MB
|
|
1492
|
+
});
|
|
1493
|
+
if (stderr && !stderr.includes("PASS") && !stderr.includes("FAIL")) {
|
|
1494
|
+
debug(`Vitest stderr: ${stderr}`);
|
|
1495
|
+
}
|
|
1496
|
+
const lines = stdout.trim().split("\n");
|
|
1497
|
+
const jsonLine = lines[lines.length - 1];
|
|
1498
|
+
if (!jsonLine || !jsonLine.startsWith("{")) {
|
|
1499
|
+
throw new Error("No valid JSON output from Vitest");
|
|
1500
|
+
}
|
|
1501
|
+
const result = JSON.parse(jsonLine);
|
|
1502
|
+
return testFiles.map((testFile, index) => {
|
|
1503
|
+
const testResult = result.testResults[index] || result.testResults[0];
|
|
1504
|
+
const failures = [];
|
|
1505
|
+
if (testResult) {
|
|
1506
|
+
for (const assertion of testResult.assertionResults) {
|
|
1507
|
+
if (assertion.status === "failed" && assertion.failureMessages.length > 0) {
|
|
1508
|
+
const failureMessage = assertion.failureMessages[0];
|
|
1509
|
+
failures.push({
|
|
1510
|
+
testName: assertion.title,
|
|
1511
|
+
message: failureMessage,
|
|
1512
|
+
stack: failureMessage
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
return {
|
|
1518
|
+
success: result.numFailedTests === 0,
|
|
1519
|
+
testFile,
|
|
1520
|
+
passed: result.numPassedTests,
|
|
1521
|
+
failed: result.numFailedTests,
|
|
1522
|
+
total: result.numTotalTests,
|
|
1523
|
+
duration: 0,
|
|
1524
|
+
// Vitest JSON doesn't include duration per file
|
|
1525
|
+
failures
|
|
1526
|
+
};
|
|
1527
|
+
});
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
if (err && typeof err === "object" && "stdout" in err) {
|
|
1530
|
+
try {
|
|
1531
|
+
const lines = err.stdout.trim().split("\n");
|
|
1532
|
+
const jsonLine = lines[lines.length - 1];
|
|
1533
|
+
if (jsonLine && jsonLine.startsWith("{")) {
|
|
1534
|
+
const result = JSON.parse(jsonLine);
|
|
1535
|
+
return testFiles.map((testFile) => {
|
|
1536
|
+
const failures = [];
|
|
1537
|
+
for (const testResult of result.testResults) {
|
|
1538
|
+
for (const assertion of testResult.assertionResults) {
|
|
1539
|
+
if (assertion.status === "failed" && assertion.failureMessages.length > 0) {
|
|
1540
|
+
failures.push({
|
|
1541
|
+
testName: assertion.title,
|
|
1542
|
+
message: assertion.failureMessages[0],
|
|
1543
|
+
stack: assertion.failureMessages[0]
|
|
1544
|
+
});
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return {
|
|
1549
|
+
success: result.numFailedTests === 0,
|
|
1550
|
+
testFile,
|
|
1551
|
+
passed: result.numPassedTests,
|
|
1552
|
+
failed: result.numFailedTests,
|
|
1553
|
+
total: result.numTotalTests,
|
|
1554
|
+
duration: 0,
|
|
1555
|
+
failures
|
|
1556
|
+
};
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
} catch (parseErr) {
|
|
1560
|
+
error(`Failed to parse Vitest output: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
|
|
1561
|
+
throw err;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
error(`Vitest test execution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1565
|
+
throw err;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1570
|
+
// src/utils/test-runner/factory.ts
|
|
1571
|
+
function createTestRunner(framework) {
|
|
1572
|
+
switch (framework) {
|
|
1573
|
+
case "jest":
|
|
1574
|
+
return new JestRunner();
|
|
1575
|
+
case "vitest":
|
|
1576
|
+
return new VitestRunner();
|
|
1577
|
+
default:
|
|
1578
|
+
throw new Error(`Unsupported test framework: ${framework}`);
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// src/utils/test-file-writer.ts
|
|
1583
|
+
import { writeFileSync, mkdirSync, existsSync as existsSync2 } from "fs";
|
|
1584
|
+
import { dirname, join as join2 } from "path";
|
|
1585
|
+
function writeTestFiles(testFiles, projectRoot) {
|
|
1586
|
+
const writtenPaths = [];
|
|
1587
|
+
for (const [relativePath, fileData] of testFiles.entries()) {
|
|
1588
|
+
const fullPath = join2(projectRoot, relativePath);
|
|
1589
|
+
const dir = dirname(fullPath);
|
|
1590
|
+
if (!existsSync2(dir)) {
|
|
1591
|
+
mkdirSync(dir, { recursive: true });
|
|
1592
|
+
debug(`Created directory: ${dir}`);
|
|
1593
|
+
}
|
|
1594
|
+
writeFileSync(fullPath, fileData.content, "utf-8");
|
|
1595
|
+
writtenPaths.push(relativePath);
|
|
1596
|
+
debug(`Wrote test file: ${relativePath}`);
|
|
1597
|
+
}
|
|
1598
|
+
return writtenPaths;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// src/utils/coverage-reader.ts
|
|
1602
|
+
import { readFileSync, existsSync as existsSync3 } from "fs";
|
|
1603
|
+
import { join as join3 } from "path";
|
|
1604
|
+
function parseJestCoverage(data) {
|
|
1605
|
+
const files = [];
|
|
1606
|
+
let totalStatements = 0;
|
|
1607
|
+
let coveredStatements = 0;
|
|
1608
|
+
let totalBranches = 0;
|
|
1609
|
+
let coveredBranches = 0;
|
|
1610
|
+
let totalFunctions = 0;
|
|
1611
|
+
let coveredFunctions = 0;
|
|
1612
|
+
let totalLines = 0;
|
|
1613
|
+
let coveredLines = 0;
|
|
1614
|
+
for (const [filePath, coverage] of Object.entries(data)) {
|
|
1615
|
+
const statementCounts = Object.values(coverage.statements);
|
|
1616
|
+
const branchCounts = Object.values(coverage.branches);
|
|
1617
|
+
const functionCounts = Object.values(coverage.functions);
|
|
1618
|
+
const lineCounts = Object.values(coverage.lines);
|
|
1619
|
+
const fileStatements = {
|
|
1620
|
+
total: statementCounts.length,
|
|
1621
|
+
covered: statementCounts.filter((c) => c > 0).length,
|
|
1622
|
+
percentage: statementCounts.length > 0 ? statementCounts.filter((c) => c > 0).length / statementCounts.length * 100 : 100
|
|
1623
|
+
};
|
|
1624
|
+
const fileBranches = {
|
|
1625
|
+
total: branchCounts.length,
|
|
1626
|
+
covered: branchCounts.filter((c) => c > 0).length,
|
|
1627
|
+
percentage: branchCounts.length > 0 ? branchCounts.filter((c) => c > 0).length / branchCounts.length * 100 : 100
|
|
1628
|
+
};
|
|
1629
|
+
const fileFunctions = {
|
|
1630
|
+
total: functionCounts.length,
|
|
1631
|
+
covered: functionCounts.filter((c) => c > 0).length,
|
|
1632
|
+
percentage: functionCounts.length > 0 ? functionCounts.filter((c) => c > 0).length / functionCounts.length * 100 : 100
|
|
1633
|
+
};
|
|
1634
|
+
const fileLines = {
|
|
1635
|
+
total: lineCounts.length,
|
|
1636
|
+
covered: lineCounts.filter((c) => c > 0).length,
|
|
1637
|
+
percentage: lineCounts.length > 0 ? lineCounts.filter((c) => c > 0).length / lineCounts.length * 100 : 100
|
|
1638
|
+
};
|
|
1639
|
+
files.push({
|
|
1640
|
+
path: filePath,
|
|
1641
|
+
metrics: {
|
|
1642
|
+
statements: fileStatements,
|
|
1643
|
+
branches: fileBranches,
|
|
1644
|
+
functions: fileFunctions,
|
|
1645
|
+
lines: fileLines
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
totalStatements += fileStatements.total;
|
|
1649
|
+
coveredStatements += fileStatements.covered;
|
|
1650
|
+
totalBranches += fileBranches.total;
|
|
1651
|
+
coveredBranches += fileBranches.covered;
|
|
1652
|
+
totalFunctions += fileFunctions.total;
|
|
1653
|
+
coveredFunctions += fileFunctions.covered;
|
|
1654
|
+
totalLines += fileLines.total;
|
|
1655
|
+
coveredLines += fileLines.covered;
|
|
1656
|
+
}
|
|
1657
|
+
return {
|
|
1658
|
+
total: {
|
|
1659
|
+
statements: {
|
|
1660
|
+
total: totalStatements,
|
|
1661
|
+
covered: coveredStatements,
|
|
1662
|
+
percentage: totalStatements > 0 ? coveredStatements / totalStatements * 100 : 100
|
|
1663
|
+
},
|
|
1664
|
+
branches: {
|
|
1665
|
+
total: totalBranches,
|
|
1666
|
+
covered: coveredBranches,
|
|
1667
|
+
percentage: totalBranches > 0 ? coveredBranches / totalBranches * 100 : 100
|
|
1668
|
+
},
|
|
1669
|
+
functions: {
|
|
1670
|
+
total: totalFunctions,
|
|
1671
|
+
covered: coveredFunctions,
|
|
1672
|
+
percentage: totalFunctions > 0 ? coveredFunctions / totalFunctions * 100 : 100
|
|
1673
|
+
},
|
|
1674
|
+
lines: {
|
|
1675
|
+
total: totalLines,
|
|
1676
|
+
covered: coveredLines,
|
|
1677
|
+
percentage: totalLines > 0 ? coveredLines / totalLines * 100 : 100
|
|
1678
|
+
}
|
|
1679
|
+
},
|
|
1680
|
+
files
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
function parseVitestCoverage(data) {
|
|
1684
|
+
return parseJestCoverage(data);
|
|
1685
|
+
}
|
|
1686
|
+
function readCoverageReport(projectRoot, framework) {
|
|
1687
|
+
const coveragePath = join3(projectRoot, "coverage", "coverage-final.json");
|
|
1688
|
+
if (!existsSync3(coveragePath)) {
|
|
1689
|
+
debug(`Coverage file not found at ${coveragePath}`);
|
|
1690
|
+
return null;
|
|
1691
|
+
}
|
|
1692
|
+
try {
|
|
1693
|
+
const content = readFileSync(coveragePath, "utf-8");
|
|
1694
|
+
const data = JSON.parse(content);
|
|
1695
|
+
if (framework === "jest") {
|
|
1696
|
+
return parseJestCoverage(data);
|
|
1697
|
+
} else {
|
|
1698
|
+
return parseVitestCoverage(data);
|
|
1699
|
+
}
|
|
1700
|
+
} catch (err) {
|
|
1701
|
+
warn(`Failed to read coverage report: ${err instanceof Error ? err.message : String(err)}`);
|
|
1702
|
+
return null;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// src/llm/prompts/coverage-summary.ts
|
|
1707
|
+
function buildCoverageSummaryPrompt(coverageReport, testResults, functionsTested, coverageDelta) {
|
|
1708
|
+
const systemPrompt = `You are a technical writer specializing in test coverage reports. Your task is to generate a clear, concise, and actionable summary of test coverage metrics.
|
|
1709
|
+
|
|
1710
|
+
Requirements:
|
|
1711
|
+
1. Use clear, professional language
|
|
1712
|
+
2. Highlight key metrics (lines, branches, functions, statements)
|
|
1713
|
+
3. Mention which functions were tested
|
|
1714
|
+
4. If coverage delta is provided, explain the change
|
|
1715
|
+
5. Provide actionable insights or recommendations
|
|
1716
|
+
6. Format as markdown suitable for GitHub PR comments
|
|
1717
|
+
7. Keep it concise (2-3 paragraphs max)`;
|
|
1718
|
+
const totalTests = testResults.reduce((sum, r) => sum + r.total, 0);
|
|
1719
|
+
const passedTests = testResults.reduce((sum, r) => sum + r.passed, 0);
|
|
1720
|
+
const failedTests = testResults.reduce((sum, r) => sum + r.failed, 0);
|
|
1721
|
+
const userPrompt = `Generate a human-readable test coverage summary with the following information:
|
|
1722
|
+
|
|
1723
|
+
**Coverage Metrics:**
|
|
1724
|
+
- Lines: ${coverageReport.total.lines.percentage.toFixed(1)}% (${coverageReport.total.lines.covered}/${coverageReport.total.lines.total})
|
|
1725
|
+
- Branches: ${coverageReport.total.branches.percentage.toFixed(1)}% (${coverageReport.total.branches.covered}/${coverageReport.total.branches.total})
|
|
1726
|
+
- Functions: ${coverageReport.total.functions.percentage.toFixed(1)}% (${coverageReport.total.functions.covered}/${coverageReport.total.functions.total})
|
|
1727
|
+
- Statements: ${coverageReport.total.statements.percentage.toFixed(1)}% (${coverageReport.total.statements.covered}/${coverageReport.total.statements.total})
|
|
1728
|
+
|
|
1729
|
+
**Test Results:**
|
|
1730
|
+
- Total tests: ${totalTests}
|
|
1731
|
+
- Passed: ${passedTests}
|
|
1732
|
+
- Failed: ${failedTests}
|
|
1733
|
+
|
|
1734
|
+
**Functions Tested:**
|
|
1735
|
+
${functionsTested.length > 0 ? functionsTested.map((f) => `- ${f}`).join("\n") : "None"}
|
|
1736
|
+
|
|
1737
|
+
${coverageDelta ? `**Coverage Changes:**
|
|
1738
|
+
- Lines: ${coverageDelta.lines > 0 ? "+" : ""}${coverageDelta.lines.toFixed(1)}%
|
|
1739
|
+
- Branches: ${coverageDelta.branches > 0 ? "+" : ""}${coverageDelta.branches.toFixed(1)}%
|
|
1740
|
+
- Functions: ${coverageDelta.functions > 0 ? "+" : ""}${coverageDelta.functions.toFixed(1)}%
|
|
1741
|
+
- Statements: ${coverageDelta.statements > 0 ? "+" : ""}${coverageDelta.statements.toFixed(1)}%
|
|
1742
|
+
` : ""}
|
|
1743
|
+
|
|
1744
|
+
Generate a concise, professional summary that explains what was tested and the coverage achieved.`;
|
|
1745
|
+
return [
|
|
1746
|
+
{ role: "system", content: systemPrompt },
|
|
1747
|
+
{ role: "user", content: userPrompt }
|
|
1748
|
+
];
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// src/core/orchestrator.ts
|
|
1752
|
+
async function runPullRequest(context) {
|
|
1753
|
+
const config = await loadConfig();
|
|
1754
|
+
initLogger(config);
|
|
1755
|
+
info(`Processing PR #${context.prNumber} for ${context.owner}/${context.repo}`);
|
|
1756
|
+
const githubToken = context.githubToken || config.githubToken;
|
|
1757
|
+
if (!githubToken) {
|
|
1758
|
+
throw new Error("GitHub token is required. Provide it via config.githubToken or context.githubToken");
|
|
1759
|
+
}
|
|
1760
|
+
const githubClient = new GitHubClient({
|
|
1761
|
+
token: githubToken,
|
|
1762
|
+
owner: context.owner,
|
|
1763
|
+
repo: context.repo
|
|
1764
|
+
});
|
|
1765
|
+
const pr = await githubClient.getPullRequest(context.prNumber);
|
|
1766
|
+
if (pr.state !== "open") {
|
|
1767
|
+
warn(`PR #${context.prNumber} is ${pr.state}, skipping test generation`);
|
|
1768
|
+
return {
|
|
1769
|
+
targetsProcessed: 0,
|
|
1770
|
+
testsGenerated: 0,
|
|
1771
|
+
testsFailed: 0,
|
|
1772
|
+
testFiles: [],
|
|
1773
|
+
errors: []
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
info(`PR: ${pr.title} (${pr.head.ref} -> ${pr.base.ref})`);
|
|
1777
|
+
const prFiles = await githubClient.listPullRequestFiles(context.prNumber);
|
|
1778
|
+
if (prFiles.length === 0) {
|
|
1779
|
+
info("No files changed in this PR");
|
|
1780
|
+
return {
|
|
1781
|
+
targetsProcessed: 0,
|
|
1782
|
+
testsGenerated: 0,
|
|
1783
|
+
testsFailed: 0,
|
|
1784
|
+
testFiles: [],
|
|
1785
|
+
errors: []
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
info(`Found ${prFiles.length} file(s) changed in PR`);
|
|
1789
|
+
const prHeadRef = pr.head.sha;
|
|
1790
|
+
const targets = await extractTestTargets(
|
|
1791
|
+
prFiles,
|
|
1792
|
+
githubClient,
|
|
1793
|
+
prHeadRef,
|
|
1794
|
+
config
|
|
1795
|
+
);
|
|
1796
|
+
if (targets.length === 0) {
|
|
1797
|
+
info("No test targets found in changed files");
|
|
1798
|
+
return {
|
|
1799
|
+
targetsProcessed: 0,
|
|
1800
|
+
testsGenerated: 0,
|
|
1801
|
+
testsFailed: 0,
|
|
1802
|
+
testFiles: [],
|
|
1803
|
+
errors: []
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
const limitedTargets = targets.slice(0, config.maxTestsPerPR);
|
|
1807
|
+
if (targets.length > limitedTargets.length) {
|
|
1808
|
+
warn(`Limiting to ${config.maxTestsPerPR} test targets (found ${targets.length})`);
|
|
1809
|
+
}
|
|
1810
|
+
info(`Found ${limitedTargets.length} test target(s)`);
|
|
1811
|
+
const framework = config.framework;
|
|
1812
|
+
info(`Using test framework: ${framework}`);
|
|
1813
|
+
const testGenerator = new TestGenerator({
|
|
1814
|
+
apiKey: config.apiKey,
|
|
1815
|
+
provider: config.provider,
|
|
1816
|
+
model: config.model,
|
|
1817
|
+
maxTokens: config.maxTokens,
|
|
1818
|
+
maxFixAttempts: config.maxFixAttempts,
|
|
1819
|
+
temperature: config.temperature,
|
|
1820
|
+
fixTemperature: config.fixTemperature
|
|
1821
|
+
});
|
|
1822
|
+
let testFiles = /* @__PURE__ */ new Map();
|
|
1823
|
+
const errors = [];
|
|
1824
|
+
let testsGenerated = 0;
|
|
1825
|
+
let testsFailed = 0;
|
|
1826
|
+
for (let i = 0; i < limitedTargets.length; i++) {
|
|
1827
|
+
const target = limitedTargets[i];
|
|
1828
|
+
progress(i + 1, limitedTargets.length, `Generating test for ${target.functionName}`);
|
|
1829
|
+
try {
|
|
1830
|
+
const testFilePath = getTestFilePath(target, config);
|
|
1831
|
+
let existingTestFile;
|
|
1832
|
+
const testFileExists = await githubClient.fileExists(prHeadRef, testFilePath);
|
|
1833
|
+
if (testFileExists) {
|
|
1834
|
+
try {
|
|
1835
|
+
const fileContents = await githubClient.getFileContents(prHeadRef, testFilePath);
|
|
1836
|
+
existingTestFile = fileContents.content;
|
|
1837
|
+
debug(`Found existing test file at ${testFilePath}`);
|
|
1838
|
+
} catch (err) {
|
|
1839
|
+
debug(`Could not fetch existing test file ${testFilePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1840
|
+
}
|
|
1841
|
+
} else {
|
|
1842
|
+
debug(`No existing test file at ${testFilePath}, will create new file`);
|
|
1843
|
+
}
|
|
1844
|
+
const result = await testGenerator.generateTest({
|
|
1845
|
+
target: {
|
|
1846
|
+
filePath: target.filePath,
|
|
1847
|
+
functionName: target.functionName,
|
|
1848
|
+
functionType: target.functionType,
|
|
1849
|
+
code: target.code,
|
|
1850
|
+
context: target.context
|
|
1851
|
+
},
|
|
1852
|
+
framework,
|
|
1853
|
+
existingTestFile
|
|
1854
|
+
});
|
|
1855
|
+
if (!testFiles.has(testFilePath)) {
|
|
1856
|
+
const baseContent = existingTestFile || "";
|
|
1857
|
+
testFiles.set(testFilePath, { content: baseContent, targets: [] });
|
|
1858
|
+
}
|
|
1859
|
+
const fileData = testFiles.get(testFilePath);
|
|
1860
|
+
if (fileData.content) {
|
|
1861
|
+
fileData.content += "\n\n" + result.testCode;
|
|
1862
|
+
} else {
|
|
1863
|
+
fileData.content = result.testCode;
|
|
1864
|
+
}
|
|
1865
|
+
fileData.targets.push(target.functionName);
|
|
1866
|
+
testsGenerated++;
|
|
1867
|
+
info(`\u2713 Generated test for ${target.functionName}`);
|
|
1868
|
+
} catch (err) {
|
|
1869
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1870
|
+
error(`\u2717 Failed to generate test for ${target.functionName}: ${errorMessage}`);
|
|
1871
|
+
errors.push({
|
|
1872
|
+
target: `${target.filePath}:${target.functionName}`,
|
|
1873
|
+
error: errorMessage
|
|
1874
|
+
});
|
|
1875
|
+
testsFailed++;
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
const projectRoot = await findProjectRoot();
|
|
1879
|
+
const packageManager = detectPackageManager(projectRoot);
|
|
1880
|
+
info(`Detected package manager: ${packageManager}`);
|
|
1881
|
+
if (testFiles.size > 0) {
|
|
1882
|
+
const writtenPaths = writeTestFiles(testFiles, projectRoot);
|
|
1883
|
+
info(`Wrote ${writtenPaths.length} test file(s) to disk`);
|
|
1884
|
+
const testRunner = createTestRunner(framework);
|
|
1885
|
+
const finalTestFiles = await runTestsAndFix(
|
|
1886
|
+
testRunner,
|
|
1887
|
+
testFiles,
|
|
1888
|
+
writtenPaths,
|
|
1889
|
+
framework,
|
|
1890
|
+
packageManager,
|
|
1891
|
+
projectRoot,
|
|
1892
|
+
testGenerator,
|
|
1893
|
+
config.maxFixAttempts
|
|
1894
|
+
);
|
|
1895
|
+
testFiles = finalTestFiles;
|
|
1896
|
+
}
|
|
1897
|
+
const summary = {
|
|
1898
|
+
targetsProcessed: limitedTargets.length,
|
|
1899
|
+
testsGenerated,
|
|
1900
|
+
testsFailed,
|
|
1901
|
+
testFiles: Array.from(testFiles.entries()).map(([path, data]) => ({
|
|
1902
|
+
path,
|
|
1903
|
+
targets: data.targets
|
|
1904
|
+
})),
|
|
1905
|
+
errors
|
|
1906
|
+
};
|
|
1907
|
+
if (testFiles.size > 0) {
|
|
1908
|
+
const testRunner = createTestRunner(framework);
|
|
1909
|
+
const writtenPaths = Array.from(testFiles.keys());
|
|
1910
|
+
info("Running tests with coverage...");
|
|
1911
|
+
const finalTestResults = await testRunner.runTests({
|
|
1912
|
+
testFiles: writtenPaths,
|
|
1913
|
+
framework,
|
|
1914
|
+
packageManager,
|
|
1915
|
+
projectRoot,
|
|
1916
|
+
coverage: true
|
|
1917
|
+
});
|
|
1918
|
+
const coverageReport = readCoverageReport(projectRoot, framework);
|
|
1919
|
+
if (coverageReport) {
|
|
1920
|
+
info(`Coverage collected: ${coverageReport.total.lines.percentage.toFixed(1)}% lines`);
|
|
1921
|
+
summary.coverageReport = coverageReport;
|
|
1922
|
+
summary.testResults = finalTestResults;
|
|
1923
|
+
} else {
|
|
1924
|
+
warn("Could not read coverage report");
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
if (config.enableAutoCommit && testFiles.size > 0) {
|
|
1928
|
+
await commitTests(
|
|
1929
|
+
githubClient,
|
|
1930
|
+
pr,
|
|
1931
|
+
Array.from(testFiles.entries()).map(([path, data]) => ({
|
|
1932
|
+
path,
|
|
1933
|
+
content: data.content
|
|
1934
|
+
})),
|
|
1935
|
+
config,
|
|
1936
|
+
summary
|
|
1937
|
+
);
|
|
1938
|
+
}
|
|
1939
|
+
if (config.enablePRComments) {
|
|
1940
|
+
await postPRComment(githubClient, context.prNumber, summary, framework, testGenerator);
|
|
1941
|
+
}
|
|
1942
|
+
success(`Completed: ${testsGenerated} test(s) generated, ${testsFailed} failed`);
|
|
1943
|
+
return summary;
|
|
1944
|
+
}
|
|
1945
|
+
async function runTestsAndFix(testRunner, testFiles, testFilePaths, framework, packageManager, projectRoot, testGenerator, maxFixAttempts) {
|
|
1946
|
+
const currentTestFiles = new Map(testFiles);
|
|
1947
|
+
let attempt = 0;
|
|
1948
|
+
while (attempt < maxFixAttempts) {
|
|
1949
|
+
info(`Running tests (attempt ${attempt + 1}/${maxFixAttempts})`);
|
|
1950
|
+
const results = await testRunner.runTests({
|
|
1951
|
+
testFiles: testFilePaths,
|
|
1952
|
+
framework,
|
|
1953
|
+
packageManager,
|
|
1954
|
+
projectRoot,
|
|
1955
|
+
coverage: false
|
|
1956
|
+
});
|
|
1957
|
+
const allPassed = results.every((r) => r.success);
|
|
1958
|
+
if (allPassed) {
|
|
1959
|
+
success(`All tests passed on attempt ${attempt + 1}`);
|
|
1960
|
+
return currentTestFiles;
|
|
1961
|
+
}
|
|
1962
|
+
const failures = [];
|
|
1963
|
+
for (const result of results) {
|
|
1964
|
+
if (!result.success && result.failures.length > 0) {
|
|
1965
|
+
failures.push({ testFile: result.testFile, result });
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
info(`Found ${failures.length} failing test file(s), attempting fixes...`);
|
|
1969
|
+
let fixedAny = false;
|
|
1970
|
+
for (const { testFile, result } of failures) {
|
|
1971
|
+
const testFileContent = currentTestFiles.get(testFile)?.content;
|
|
1972
|
+
if (!testFileContent) {
|
|
1973
|
+
warn(`Could not find content for test file: ${testFile}`);
|
|
1974
|
+
continue;
|
|
1975
|
+
}
|
|
1976
|
+
const firstFailure = result.failures[0];
|
|
1977
|
+
if (!firstFailure)
|
|
1978
|
+
continue;
|
|
1979
|
+
try {
|
|
1980
|
+
const fixedResult = await testGenerator.fixTest({
|
|
1981
|
+
testCode: testFileContent,
|
|
1982
|
+
errorMessage: firstFailure.message,
|
|
1983
|
+
testOutput: firstFailure.stack,
|
|
1984
|
+
originalCode: "",
|
|
1985
|
+
// We'd need to pass this from the target
|
|
1986
|
+
framework,
|
|
1987
|
+
attempt: attempt + 1,
|
|
1988
|
+
maxAttempts: maxFixAttempts
|
|
1989
|
+
});
|
|
1990
|
+
currentTestFiles.set(testFile, {
|
|
1991
|
+
content: fixedResult.testCode,
|
|
1992
|
+
targets: currentTestFiles.get(testFile)?.targets || []
|
|
1993
|
+
});
|
|
1994
|
+
const { writeFileSync: writeFileSync2 } = await import("fs");
|
|
1995
|
+
const { join: join4 } = await import("path");
|
|
1996
|
+
writeFileSync2(join4(projectRoot, testFile), fixedResult.testCode, "utf-8");
|
|
1997
|
+
fixedAny = true;
|
|
1998
|
+
info(`\u2713 Fixed test file: ${testFile}`);
|
|
1999
|
+
} catch (err) {
|
|
2000
|
+
error(`Failed to fix test file ${testFile}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
if (!fixedAny) {
|
|
2004
|
+
warn(`Could not fix any failing tests on attempt ${attempt + 1}`);
|
|
2005
|
+
break;
|
|
2006
|
+
}
|
|
2007
|
+
attempt++;
|
|
2008
|
+
}
|
|
2009
|
+
if (attempt >= maxFixAttempts) {
|
|
2010
|
+
warn(`Reached maximum fix attempts (${maxFixAttempts}), some tests may still be failing`);
|
|
2011
|
+
}
|
|
2012
|
+
return currentTestFiles;
|
|
2013
|
+
}
|
|
2014
|
+
async function commitTests(githubClient, pr, testFiles, config, summary) {
|
|
2015
|
+
info(`Committing ${testFiles.length} test file(s)`);
|
|
2016
|
+
try {
|
|
2017
|
+
if (config.commitStrategy === "branch-pr") {
|
|
2018
|
+
const branchName = `kakarot-ci/tests-pr-${pr.number}`;
|
|
2019
|
+
const baseSha = await githubClient.createBranch(branchName, pr.head.ref);
|
|
2020
|
+
await githubClient.commitFiles({
|
|
2021
|
+
files: testFiles.map((file) => ({
|
|
2022
|
+
path: file.path,
|
|
2023
|
+
content: file.content
|
|
2024
|
+
})),
|
|
2025
|
+
message: `test: add unit tests for PR #${pr.number}
|
|
2026
|
+
|
|
2027
|
+
Generated ${summary.testsGenerated} test(s) for ${summary.targetsProcessed} function(s)`,
|
|
2028
|
+
branch: branchName,
|
|
2029
|
+
baseSha
|
|
2030
|
+
});
|
|
2031
|
+
const testPR = await githubClient.createPullRequest(
|
|
2032
|
+
`test: Add unit tests for PR #${pr.number}`,
|
|
2033
|
+
`This PR contains automatically generated unit tests for PR #${pr.number}.
|
|
2034
|
+
|
|
2035
|
+
- ${summary.testsGenerated} test(s) generated
|
|
2036
|
+
- ${summary.targetsProcessed} function(s) tested
|
|
2037
|
+
- ${testFiles.length} test file(s) created/updated`,
|
|
2038
|
+
branchName,
|
|
2039
|
+
pr.head.ref
|
|
2040
|
+
);
|
|
2041
|
+
success(`Created PR #${testPR.number} with generated tests`);
|
|
2042
|
+
} else {
|
|
2043
|
+
await githubClient.commitFiles({
|
|
2044
|
+
files: testFiles.map((file) => ({
|
|
2045
|
+
path: file.path,
|
|
2046
|
+
content: file.content
|
|
2047
|
+
})),
|
|
2048
|
+
message: `test: add unit tests
|
|
2049
|
+
|
|
2050
|
+
Generated ${summary.testsGenerated} test(s) for ${summary.targetsProcessed} function(s)`,
|
|
2051
|
+
branch: pr.head.ref,
|
|
2052
|
+
baseSha: pr.head.sha
|
|
2053
|
+
});
|
|
2054
|
+
success(`Committed ${testFiles.length} test file(s) to ${pr.head.ref}`);
|
|
2055
|
+
}
|
|
2056
|
+
} catch (err) {
|
|
2057
|
+
error(`Failed to commit tests: ${err instanceof Error ? err.message : String(err)}`);
|
|
2058
|
+
throw err;
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
async function postPRComment(githubClient, prNumber, summary, framework, testGenerator) {
|
|
2062
|
+
let comment = `## \u{1F9EA} Kakarot CI Test Generation Summary
|
|
2063
|
+
|
|
2064
|
+
**Framework:** ${framework}
|
|
2065
|
+
**Targets Processed:** ${summary.targetsProcessed}
|
|
2066
|
+
**Tests Generated:** ${summary.testsGenerated}
|
|
2067
|
+
**Failures:** ${summary.testsFailed}
|
|
2068
|
+
|
|
2069
|
+
### Test Files
|
|
2070
|
+
${summary.testFiles.length > 0 ? summary.testFiles.map((f) => `- \`${f.path}\` (${f.targets.length} test(s))`).join("\n") : "No test files generated"}
|
|
2071
|
+
|
|
2072
|
+
${summary.errors.length > 0 ? `### Errors
|
|
2073
|
+
${summary.errors.map((e) => `- \`${e.target}\`: ${e.error}`).join("\n")}` : ""}`;
|
|
2074
|
+
if (summary.coverageReport && summary.testResults) {
|
|
2075
|
+
try {
|
|
2076
|
+
const functionsTested = summary.testFiles.flatMap((f) => f.targets);
|
|
2077
|
+
const messages = buildCoverageSummaryPrompt(
|
|
2078
|
+
summary.coverageReport,
|
|
2079
|
+
summary.testResults,
|
|
2080
|
+
functionsTested
|
|
2081
|
+
);
|
|
2082
|
+
const coverageSummary = await testGenerator.generateCoverageSummary(messages);
|
|
2083
|
+
comment += `
|
|
2084
|
+
|
|
2085
|
+
## \u{1F4CA} Coverage Summary
|
|
2086
|
+
|
|
2087
|
+
${coverageSummary}`;
|
|
2088
|
+
} catch (err) {
|
|
2089
|
+
warn(`Failed to generate coverage summary: ${err instanceof Error ? err.message : String(err)}`);
|
|
2090
|
+
const cov = summary.coverageReport.total;
|
|
2091
|
+
comment += `
|
|
2092
|
+
|
|
2093
|
+
## \u{1F4CA} Coverage Summary
|
|
2094
|
+
|
|
2095
|
+
- **Lines:** ${cov.lines.percentage.toFixed(1)}% (${cov.lines.covered}/${cov.lines.total})
|
|
2096
|
+
- **Branches:** ${cov.branches.percentage.toFixed(1)}% (${cov.branches.covered}/${cov.branches.total})
|
|
2097
|
+
- **Functions:** ${cov.functions.percentage.toFixed(1)}% (${cov.functions.covered}/${cov.functions.total})
|
|
2098
|
+
- **Statements:** ${cov.statements.percentage.toFixed(1)}% (${cov.statements.covered}/${cov.statements.total})`;
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
comment += `
|
|
2102
|
+
|
|
2103
|
+
---
|
|
2104
|
+
*Generated by [Kakarot CI](https://github.com/kakarot-ci)*`;
|
|
2105
|
+
try {
|
|
2106
|
+
await githubClient.commentPR(prNumber, comment);
|
|
2107
|
+
info("Posted PR comment with test generation summary");
|
|
2108
|
+
} catch (err) {
|
|
2109
|
+
warn(`Failed to post PR comment: ${err instanceof Error ? err.message : String(err)}`);
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// src/cli/index.ts
|
|
2114
|
+
function parseRepository(repo) {
|
|
2115
|
+
const parts = repo.split("/");
|
|
2116
|
+
if (parts.length !== 2) {
|
|
2117
|
+
throw new Error(`Invalid repository format: ${repo}. Expected "owner/repo"`);
|
|
2118
|
+
}
|
|
2119
|
+
return { owner: parts[0], repo: parts[1] };
|
|
2120
|
+
}
|
|
2121
|
+
async function detectGitRepository(projectRoot) {
|
|
2122
|
+
try {
|
|
2123
|
+
const git = simpleGit(projectRoot);
|
|
2124
|
+
let remoteUrl = null;
|
|
2125
|
+
try {
|
|
2126
|
+
remoteUrl = await git.getRemotes(true).then((remotes) => {
|
|
2127
|
+
const origin = remotes.find((r) => r.name === "origin");
|
|
2128
|
+
return origin?.refs?.fetch || origin?.refs?.push || null;
|
|
2129
|
+
});
|
|
2130
|
+
if (!remoteUrl) {
|
|
2131
|
+
const remotes = await git.getRemotes(true);
|
|
2132
|
+
if (remotes.length > 0) {
|
|
2133
|
+
remoteUrl = remotes[0].refs?.fetch || remotes[0].refs?.push || null;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
} catch {
|
|
2137
|
+
return null;
|
|
2138
|
+
}
|
|
2139
|
+
if (!remoteUrl) {
|
|
2140
|
+
return null;
|
|
2141
|
+
}
|
|
2142
|
+
try {
|
|
2143
|
+
const parsed = gitUrlParse(remoteUrl);
|
|
2144
|
+
if (parsed.resource === "github.com") {
|
|
2145
|
+
return {
|
|
2146
|
+
owner: parsed.owner,
|
|
2147
|
+
repo: parsed.name.replace(/\.git$/, "")
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
} catch {
|
|
2151
|
+
return null;
|
|
2152
|
+
}
|
|
2153
|
+
return null;
|
|
2154
|
+
} catch (err) {
|
|
2155
|
+
debug(`Failed to detect git repository: ${err instanceof Error ? err.message : String(err)}`);
|
|
2156
|
+
return null;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
function extractPRNumber(eventPath) {
|
|
2160
|
+
if (!eventPath) {
|
|
2161
|
+
return null;
|
|
2162
|
+
}
|
|
2163
|
+
try {
|
|
2164
|
+
const eventContent = readFileSync2(eventPath, "utf-8");
|
|
2165
|
+
const event = JSON.parse(eventContent);
|
|
2166
|
+
if (event.pull_request?.number) {
|
|
2167
|
+
return event.pull_request.number;
|
|
2168
|
+
}
|
|
2169
|
+
if (event.number) {
|
|
2170
|
+
return event.number;
|
|
2171
|
+
}
|
|
2172
|
+
return null;
|
|
2173
|
+
} catch (err) {
|
|
2174
|
+
error(`Failed to read GitHub event file: ${err instanceof Error ? err.message : String(err)}`);
|
|
2175
|
+
return null;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
async function main() {
|
|
2179
|
+
const program = new Command();
|
|
2180
|
+
program.name("kakarot-ci").description("AI-powered unit test generation for pull requests").version("0.2.0").option("--pr <number>", "Pull request number").option("--owner <string>", "Repository owner").option("--repo <string>", "Repository name").option("--token <string>", "GitHub token (or use GITHUB_TOKEN env var)").parse(process.argv);
|
|
2181
|
+
const options = program.opts();
|
|
2182
|
+
let config;
|
|
2183
|
+
try {
|
|
2184
|
+
config = await loadConfig();
|
|
2185
|
+
} catch (err) {
|
|
2186
|
+
config = null;
|
|
2187
|
+
}
|
|
2188
|
+
const githubRepository = process.env.GITHUB_REPOSITORY;
|
|
2189
|
+
const githubEventPath = process.env.GITHUB_EVENT_PATH;
|
|
2190
|
+
const githubToken = options.token || config?.githubToken || process.env.GITHUB_TOKEN;
|
|
2191
|
+
const prNumberEnv = process.env.PR_NUMBER;
|
|
2192
|
+
let owner = options.owner;
|
|
2193
|
+
let repo = options.repo;
|
|
2194
|
+
if (!owner || !repo) {
|
|
2195
|
+
if (githubRepository) {
|
|
2196
|
+
try {
|
|
2197
|
+
const parsed = parseRepository(githubRepository);
|
|
2198
|
+
owner = owner || parsed.owner;
|
|
2199
|
+
repo = repo || parsed.repo;
|
|
2200
|
+
} catch (err) {
|
|
2201
|
+
if (!owner && !repo) {
|
|
2202
|
+
error(err instanceof Error ? err.message : String(err));
|
|
2203
|
+
process.exit(1);
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
if (!owner || !repo) {
|
|
2209
|
+
owner = owner || config?.githubOwner;
|
|
2210
|
+
repo = repo || config?.githubRepo;
|
|
2211
|
+
}
|
|
2212
|
+
if (!owner || !repo) {
|
|
2213
|
+
const projectRoot = await findProjectRoot();
|
|
2214
|
+
const gitRepo = await detectGitRepository(projectRoot);
|
|
2215
|
+
if (gitRepo) {
|
|
2216
|
+
owner = owner || gitRepo.owner;
|
|
2217
|
+
repo = repo || gitRepo.repo;
|
|
2218
|
+
debug(`Detected repository from git: ${owner}/${repo}`);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
if (!owner || !repo) {
|
|
2222
|
+
error("Repository owner and name are required.");
|
|
2223
|
+
error("Provide via:");
|
|
2224
|
+
error(" - Config file: githubOwner and githubRepo");
|
|
2225
|
+
error(" - CLI flags: --owner and --repo");
|
|
2226
|
+
error(' - Environment: GITHUB_REPOSITORY (format: "owner/repo")');
|
|
2227
|
+
error(" - Git remote: auto-detected from current repository");
|
|
2228
|
+
process.exit(1);
|
|
2229
|
+
}
|
|
2230
|
+
if (!githubToken) {
|
|
2231
|
+
error("GitHub token is required.");
|
|
2232
|
+
error("Provide via:");
|
|
2233
|
+
error(" - Config file: githubToken");
|
|
2234
|
+
error(" - CLI flag: --token");
|
|
2235
|
+
error(" - Environment: GITHUB_TOKEN");
|
|
2236
|
+
process.exit(1);
|
|
2237
|
+
}
|
|
2238
|
+
let prNumber = null;
|
|
2239
|
+
if (options.pr) {
|
|
2240
|
+
prNumber = parseInt(String(options.pr), 10);
|
|
2241
|
+
if (isNaN(prNumber)) {
|
|
2242
|
+
error(`Invalid PR number: ${options.pr}`);
|
|
2243
|
+
process.exit(1);
|
|
2244
|
+
}
|
|
2245
|
+
} else if (prNumberEnv) {
|
|
2246
|
+
const parsed = parseInt(prNumberEnv, 10);
|
|
2247
|
+
if (!isNaN(parsed)) {
|
|
2248
|
+
prNumber = parsed;
|
|
2249
|
+
}
|
|
2250
|
+
} else if (githubEventPath) {
|
|
2251
|
+
prNumber = extractPRNumber(githubEventPath);
|
|
2252
|
+
}
|
|
2253
|
+
if (!prNumber) {
|
|
2254
|
+
error("Pull request number is required.");
|
|
2255
|
+
error("Provide via:");
|
|
2256
|
+
error(" - CLI flag: --pr <number>");
|
|
2257
|
+
error(" - Environment: PR_NUMBER");
|
|
2258
|
+
error(" - GitHub Actions: GITHUB_EVENT_PATH (auto-detected)");
|
|
2259
|
+
process.exit(1);
|
|
2260
|
+
}
|
|
2261
|
+
const context = {
|
|
2262
|
+
prNumber,
|
|
2263
|
+
owner,
|
|
2264
|
+
repo,
|
|
2265
|
+
githubToken
|
|
2266
|
+
};
|
|
2267
|
+
info(`Starting Kakarot CI for PR #${prNumber} in ${owner}/${repo}`);
|
|
2268
|
+
try {
|
|
2269
|
+
const summary = await runPullRequest(context);
|
|
2270
|
+
if (summary.errors.length > 0 || summary.testsFailed > 0) {
|
|
2271
|
+
error(`Test generation completed with errors: ${summary.errors.length} error(s), ${summary.testsFailed} test(s) failed`);
|
|
2272
|
+
process.exit(1);
|
|
2273
|
+
}
|
|
2274
|
+
info(`Test generation completed successfully: ${summary.testsGenerated} test(s) generated`);
|
|
2275
|
+
process.exit(0);
|
|
2276
|
+
} catch (err) {
|
|
2277
|
+
error(`Fatal error: ${err instanceof Error ? err.message : String(err)}`);
|
|
2278
|
+
if (err instanceof Error && err.stack) {
|
|
2279
|
+
error(err.stack);
|
|
2280
|
+
}
|
|
2281
|
+
process.exit(1);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
main().catch((err) => {
|
|
2285
|
+
error(`Unhandled error: ${err instanceof Error ? err.message : String(err)}`);
|
|
2286
|
+
process.exit(1);
|
|
2287
|
+
});
|
|
2288
|
+
//# sourceMappingURL=index.js.map
|