@skyramp/mcp 0.0.62 → 0.0.63-rc.2
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/build/index.js +18 -26
- package/build/prompts/test-maintenance/drift-analysis-prompt.js +59 -0
- package/build/prompts/test-maintenance/driftAnalysisSections.js +153 -0
- package/build/prompts/test-recommendation/analysisOutputPrompt.js +21 -9
- package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +34 -38
- package/build/prompts/test-recommendation/test-recommendation-prompt.js +56 -9
- package/build/prompts/testbot/testbot-prompts.js +113 -100
- package/build/services/DriftAnalysisService.js +1 -1
- package/build/services/ScenarioGenerationService.js +5 -1
- package/build/services/TestExecutionService.js +2 -24
- package/build/services/TestExecutionService.test.js +167 -0
- package/build/services/containerEnv.js +35 -0
- package/build/tools/generate-tests/generateScenarioRestTool.js +7 -1
- package/build/tools/submitReportTool.js +6 -6
- package/build/tools/test-management/actionsTool.js +396 -0
- package/build/tools/test-management/analyzeChangesTool.js +750 -0
- package/build/tools/test-management/analyzeTestHealthTool.js +132 -0
- package/build/tools/test-management/executeTestsTool.js +198 -0
- package/build/tools/test-management/index.js +5 -0
- package/build/tools/test-management/stateCleanupTool.js +163 -0
- package/build/tools/test-recommendation/recommendTestsTool.js +1 -1
- package/build/utils/analyze-openapi.js +2 -2
- package/build/utils/pr-comment-parser.js +157 -36
- package/build/utils/pr-comment-parser.test.js +427 -0
- package/package.json +1 -1
- package/build/tools/initTestbotTool.js +0 -187
- package/build/tools/initTestbotTool.test.js +0 -194
- package/build/tools/test-recommendation/analyzeRepositoryTool.js +0 -505
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { simpleGit } from "simple-git";
|
|
6
|
+
import { logger } from "../../utils/logger.js";
|
|
7
|
+
import { AnalyticsService } from "../../services/AnalyticsService.js";
|
|
8
|
+
import { StateManager, registerSession, storeSessionData, } from "../../utils/AnalysisStateManager.js";
|
|
9
|
+
import { buildRecommendationPrompt } from "../../prompts/test-recommendation/test-recommendation-prompt.js";
|
|
10
|
+
import { MAX_RECOMMENDATIONS } from "../../prompts/test-recommendation/recommendationSections.js";
|
|
11
|
+
import { WorkspaceConfigManager } from "@skyramp/skyramp";
|
|
12
|
+
import { TestDiscoveryService } from "../../services/TestDiscoveryService.js";
|
|
13
|
+
import { computeBranchDiff } from "../../utils/branchDiff.js";
|
|
14
|
+
import { parseEndpointsFromDiff, } from "../../utils/routeParsers.js";
|
|
15
|
+
import { scanAllRepoEndpoints, scanRelatedEndpoints, grepRouterMountingContext, } from "../../utils/repoScanner.js";
|
|
16
|
+
import { detectProjectMetadata } from "../../utils/projectMetadata.js";
|
|
17
|
+
import { draftScenariosFromEndpoints } from "../../utils/scenarioDrafting.js";
|
|
18
|
+
import { buildAnalysisOutputText } from "../../prompts/test-recommendation/analysisOutputPrompt.js";
|
|
19
|
+
import { parseTraceFile, discoverTraceFiles, } from "../../utils/trace-parser.js";
|
|
20
|
+
import { parsePRComments } from "../../utils/pr-comment-parser.js";
|
|
21
|
+
const TOOL_NAME = "skyramp_analyze_changes";
|
|
22
|
+
// Must match testbot/src/constants.ts BOT_EMAIL
|
|
23
|
+
const BOT_EMAIL = "test-bot@skyramp.dev";
|
|
24
|
+
/**
|
|
25
|
+
* Get files added by the last bot commit that still exist on disk.
|
|
26
|
+
* Fallback for when PR comment file names don't match actual files
|
|
27
|
+
* (e.g. after a force push that rewrote history).
|
|
28
|
+
*/
|
|
29
|
+
async function getBotCommittedFiles(repoPath) {
|
|
30
|
+
try {
|
|
31
|
+
const git = simpleGit(repoPath);
|
|
32
|
+
const botShaRaw = await git.raw([
|
|
33
|
+
"log", "--author=" + BOT_EMAIL, "-1", "--format=%H",
|
|
34
|
+
]);
|
|
35
|
+
const lastBotSha = botShaRaw.trim();
|
|
36
|
+
if (!lastBotSha)
|
|
37
|
+
return [];
|
|
38
|
+
const diffOutput = await git.raw([
|
|
39
|
+
"diff-tree", "--no-commit-id", "--name-only", "-r", "--diff-filter=A", lastBotSha,
|
|
40
|
+
]);
|
|
41
|
+
return diffOutput
|
|
42
|
+
.trim()
|
|
43
|
+
.split("\n")
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.filter(f => {
|
|
46
|
+
const abs = path.join(repoPath, f);
|
|
47
|
+
return fs.existsSync(abs);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Get files changed by user commits (excluding bot commits) since the last bot commit.
|
|
56
|
+
* Focuses on user intent — what did the developer push after the bot ran?
|
|
57
|
+
* Returns null if no bot commit exists (first run) or on error.
|
|
58
|
+
*
|
|
59
|
+
* Uses git.raw() throughout because simple-git's .log() parser cannot
|
|
60
|
+
* reliably handle custom --format flags. Filters out bot-authored commits
|
|
61
|
+
* client-side because git's --not flag applies to revision specs, not --author.
|
|
62
|
+
*/
|
|
63
|
+
async function getUserChangedFiles(repoPath) {
|
|
64
|
+
try {
|
|
65
|
+
const git = simpleGit(repoPath);
|
|
66
|
+
// Find the last bot commit — use git.raw() because simple-git's .log()
|
|
67
|
+
// parser cannot handle a custom --format flag reliably.
|
|
68
|
+
const botShaRaw = await git.raw([
|
|
69
|
+
"log", "--author=" + BOT_EMAIL, "-1", "--format=%H",
|
|
70
|
+
]);
|
|
71
|
+
const lastBotSha = botShaRaw.trim();
|
|
72
|
+
if (!lastBotSha)
|
|
73
|
+
return null;
|
|
74
|
+
// Get all commits in the range, then filter out bot-authored ones client-side.
|
|
75
|
+
// git's --not flag applies to revision specs, not --author.
|
|
76
|
+
const rangeLog = await git.raw([
|
|
77
|
+
"log", "--format=%H %ae", `${lastBotSha}..HEAD`,
|
|
78
|
+
]);
|
|
79
|
+
const userShas = rangeLog
|
|
80
|
+
.trim()
|
|
81
|
+
.split("\n")
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.filter(line => !line.endsWith(BOT_EMAIL))
|
|
84
|
+
.map(line => line.split(" ")[0]);
|
|
85
|
+
if (userShas.length === 0)
|
|
86
|
+
return [];
|
|
87
|
+
// Union all files touched by user commits
|
|
88
|
+
const fileSet = new Set();
|
|
89
|
+
for (const sha of userShas) {
|
|
90
|
+
const filesRaw = await git.raw([
|
|
91
|
+
"diff-tree", "--no-commit-id", "--name-only", "-r", sha,
|
|
92
|
+
]);
|
|
93
|
+
filesRaw.trim().split("\n").filter(Boolean).forEach(f => fileSet.add(f));
|
|
94
|
+
}
|
|
95
|
+
return Array.from(fileSet);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const NON_APP_PATTERNS = [
|
|
102
|
+
// CI/CD
|
|
103
|
+
/^\.github\//,
|
|
104
|
+
/^\.circleci\//,
|
|
105
|
+
/^\.gitlab-ci/,
|
|
106
|
+
/^\.travis\.yml$/,
|
|
107
|
+
/^Jenkinsfile/,
|
|
108
|
+
/^\.buildkite\//,
|
|
109
|
+
/^\.drone\.yml$/,
|
|
110
|
+
// Docs
|
|
111
|
+
/\.md$/i,
|
|
112
|
+
/\.mdx$/i,
|
|
113
|
+
/\.rst$/i,
|
|
114
|
+
/\.txt$/i,
|
|
115
|
+
/^docs\//i,
|
|
116
|
+
/^documentation\//i,
|
|
117
|
+
/^CHANGELOG/i,
|
|
118
|
+
/^CONTRIBUTING/i,
|
|
119
|
+
/^README/i,
|
|
120
|
+
/^LICENSE/i,
|
|
121
|
+
// Lock files & dependency manifests (not the manifest itself, just locks)
|
|
122
|
+
/package-lock\.json$/,
|
|
123
|
+
/yarn\.lock$/,
|
|
124
|
+
/pnpm-lock\.yaml$/,
|
|
125
|
+
/poetry\.lock$/,
|
|
126
|
+
/Pipfile\.lock$/,
|
|
127
|
+
/Gemfile\.lock$/,
|
|
128
|
+
/composer\.lock$/,
|
|
129
|
+
/go\.sum$/,
|
|
130
|
+
/Cargo\.lock$/,
|
|
131
|
+
// Config / infra (non-code)
|
|
132
|
+
/^\.env/,
|
|
133
|
+
/\.eslintrc/,
|
|
134
|
+
/\.prettierrc/,
|
|
135
|
+
/\.editorconfig$/,
|
|
136
|
+
/^\.vscode\//,
|
|
137
|
+
/^\.idea\//,
|
|
138
|
+
/docker-compose.*\.ya?ml$/i,
|
|
139
|
+
/^Dockerfile/i,
|
|
140
|
+
/^\.dockerignore$/,
|
|
141
|
+
/^\.gitignore$/,
|
|
142
|
+
/^\.gitattributes$/,
|
|
143
|
+
/tsconfig.*\.json$/,
|
|
144
|
+
/jest\.config\./,
|
|
145
|
+
/babel\.config\./,
|
|
146
|
+
/^renovate\.json$/,
|
|
147
|
+
/^\.pre-commit-config/,
|
|
148
|
+
];
|
|
149
|
+
function isNonApplicationFile(filePath) {
|
|
150
|
+
return NON_APP_PATTERNS.some((p) => p.test(filePath));
|
|
151
|
+
}
|
|
152
|
+
const analyzeChangesSchema = {
|
|
153
|
+
repositoryPath: z
|
|
154
|
+
.string()
|
|
155
|
+
.describe("Absolute path to the repository root"),
|
|
156
|
+
scope: z
|
|
157
|
+
.enum(["full_repo", "branch_diff"])
|
|
158
|
+
.default("branch_diff")
|
|
159
|
+
.optional()
|
|
160
|
+
.describe("Analysis scope: 'full_repo' scans entire repo, 'branch_diff' focuses on current branch changes"),
|
|
161
|
+
baseBranch: z
|
|
162
|
+
.string()
|
|
163
|
+
.optional()
|
|
164
|
+
.describe("Base branch for diff (auto-detected if omitted)"),
|
|
165
|
+
testDirectory: z
|
|
166
|
+
.string()
|
|
167
|
+
.optional()
|
|
168
|
+
.describe("Directory containing existing tests (auto-detected if omitted)"),
|
|
169
|
+
topN: z
|
|
170
|
+
.number()
|
|
171
|
+
.default(MAX_RECOMMENDATIONS)
|
|
172
|
+
.optional()
|
|
173
|
+
.describe(`Number of ranked test recommendations to generate. Defaults to ${MAX_RECOMMENDATIONS}.`),
|
|
174
|
+
prNumber: z
|
|
175
|
+
.number()
|
|
176
|
+
.optional()
|
|
177
|
+
.describe("GitHub PR number. When provided, fetches previous TestBot comments for recommendation deduplication across commits."),
|
|
178
|
+
};
|
|
179
|
+
export function registerAnalyzeChangesTool(server) {
|
|
180
|
+
server.registerTool(TOOL_NAME, {
|
|
181
|
+
description: `Analyze repository changes and discover existing tests — first step of the unified Test Health Analysis Flow.
|
|
182
|
+
|
|
183
|
+
This tool combines repository analysis (endpoint scanning, branch diff) with test discovery
|
|
184
|
+
to produce a unified state file for the test health workflow.
|
|
185
|
+
|
|
186
|
+
**Workflow:**
|
|
187
|
+
1. Call \`skyramp_analyze_changes\` → discovers existing tests, scans endpoints, computes branch diff → returns a stateFile
|
|
188
|
+
2. Call \`skyramp_analyze_test_health\` with stateFile → drift analysis + health scoring
|
|
189
|
+
3. (Optional) Call \`skyramp_execute_tests\` with stateFile → run tests live
|
|
190
|
+
4. Call \`skyramp_actions\` with stateFile → execute UPDATE/REGENERATE/ADD recommendations
|
|
191
|
+
|
|
192
|
+
**Output:** stateFile path + LLM instructions for enrichment and calling skyramp_analyze_test_health`,
|
|
193
|
+
inputSchema: analyzeChangesSchema,
|
|
194
|
+
}, async (params, extra) => {
|
|
195
|
+
let errorResult;
|
|
196
|
+
const sendProgress = async (progress, total, message) => {
|
|
197
|
+
const progressToken = extra._meta?.progressToken;
|
|
198
|
+
if (progressToken !== undefined) {
|
|
199
|
+
const notification = {
|
|
200
|
+
method: "notifications/progress",
|
|
201
|
+
params: { progressToken, progress, total, message },
|
|
202
|
+
};
|
|
203
|
+
await extra.sendNotification(notification);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
try {
|
|
207
|
+
const scope = params.scope ?? "branch_diff";
|
|
208
|
+
const analysisScope = scope === "branch_diff" ? "current_branch_diff" : "full_repo";
|
|
209
|
+
logger.info("Analyze changes tool invoked", {
|
|
210
|
+
repositoryPath: params.repositoryPath,
|
|
211
|
+
scope,
|
|
212
|
+
});
|
|
213
|
+
await sendProgress(0, 100, "Starting analysis...");
|
|
214
|
+
// ── Step 1: Branch diff ──
|
|
215
|
+
let diffData;
|
|
216
|
+
if (scope === "branch_diff") {
|
|
217
|
+
await sendProgress(10, 100, "Computing branch diff...");
|
|
218
|
+
try {
|
|
219
|
+
diffData = await computeBranchDiff(params.repositoryPath, params.baseBranch);
|
|
220
|
+
logger.info("Branch diff computed", {
|
|
221
|
+
currentBranch: diffData.currentBranch,
|
|
222
|
+
baseBranch: diffData.baseBranch,
|
|
223
|
+
changedFiles: diffData.changedFiles.length,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
228
|
+
logger.warning("Failed to obtain branch diff, continuing without diff", { error: msg });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// ── Early return: all changed files are non-application ──
|
|
232
|
+
// Use user-only commits since last bot run (if a bot commit exists) so that
|
|
233
|
+
// bot-committed test files don't count as application changes.
|
|
234
|
+
if (scope === "branch_diff" && diffData && diffData.changedFiles.length > 0) {
|
|
235
|
+
const userFiles = await getUserChangedFiles(params.repositoryPath);
|
|
236
|
+
const filesToCheck = userFiles ?? diffData.changedFiles;
|
|
237
|
+
const appFiles = filesToCheck.filter(f => !isNonApplicationFile(f));
|
|
238
|
+
if (filesToCheck.length > 0 && appFiles.length === 0) {
|
|
239
|
+
logger.info("All user-changed files are non-application — skipping analysis", {
|
|
240
|
+
changedFiles: filesToCheck,
|
|
241
|
+
});
|
|
242
|
+
return {
|
|
243
|
+
content: [{
|
|
244
|
+
type: "text",
|
|
245
|
+
text: `All ${filesToCheck.length} changed file(s) are non-application (CI/CD, docs, lock files, config). No test analysis needed for this diff.\n\nChanged files: ${filesToCheck.join(", ")}`,
|
|
246
|
+
}],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
await sendProgress(25, 100, "Parsing endpoints from diff...");
|
|
251
|
+
const parsedDiff = diffData
|
|
252
|
+
? parseEndpointsFromDiff(diffData)
|
|
253
|
+
: undefined;
|
|
254
|
+
const newEndpoints = parsedDiff
|
|
255
|
+
? parsedDiff.newEndpoints
|
|
256
|
+
: [];
|
|
257
|
+
// ── Step 2: Scan endpoints ──
|
|
258
|
+
let scannedEndpoints = [];
|
|
259
|
+
if (scope !== "branch_diff") {
|
|
260
|
+
await sendProgress(35, 100, "Scanning all repository endpoints...");
|
|
261
|
+
try {
|
|
262
|
+
scannedEndpoints = scanAllRepoEndpoints(params.repositoryPath);
|
|
263
|
+
logger.info("Pre-scanned repo endpoints", {
|
|
264
|
+
count: scannedEndpoints.length,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
logger.warning("Endpoint pre-scan failed", {
|
|
269
|
+
error: err instanceof Error ? err.message : String(err),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else if (diffData) {
|
|
274
|
+
await sendProgress(35, 100, "Scanning related endpoints from diff...");
|
|
275
|
+
try {
|
|
276
|
+
scannedEndpoints = scanRelatedEndpoints(params.repositoryPath, diffData.changedFiles);
|
|
277
|
+
logger.info("Scanned related endpoints", {
|
|
278
|
+
count: scannedEndpoints.length,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
logger.warning("Related endpoint scan failed", {
|
|
283
|
+
error: err instanceof Error ? err.message : String(err),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
if (scannedEndpoints.length === 0) {
|
|
287
|
+
try {
|
|
288
|
+
scannedEndpoints = scanAllRepoEndpoints(params.repositoryPath);
|
|
289
|
+
logger.info("Fallback: scanned all repo endpoints", {
|
|
290
|
+
count: scannedEndpoints.length,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
logger.warning("Full repo scan fallback also failed", {
|
|
295
|
+
error: err instanceof Error ? err.message : String(err),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
await sendProgress(50, 100, "Discovering existing tests...");
|
|
301
|
+
// ── Step 3: Discover existing tests ──
|
|
302
|
+
let existingTests = [];
|
|
303
|
+
try {
|
|
304
|
+
const testDiscoveryService = new TestDiscoveryService();
|
|
305
|
+
const discoveryResult = await testDiscoveryService.discoverTests(params.repositoryPath);
|
|
306
|
+
existingTests = discoveryResult.tests.map((test) => ({
|
|
307
|
+
testFile: test.testFile,
|
|
308
|
+
testType: test.testType,
|
|
309
|
+
language: test.language,
|
|
310
|
+
framework: test.framework,
|
|
311
|
+
apiSchema: test.apiSchema,
|
|
312
|
+
apiEndpoint: test.apiEndpoint,
|
|
313
|
+
generatedAt: test.generatedAt,
|
|
314
|
+
}));
|
|
315
|
+
logger.info("Test discovery completed", {
|
|
316
|
+
totalTests: existingTests.length,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
logger.warning("Test discovery failed, continuing with empty list", {
|
|
321
|
+
error: err instanceof Error ? err.message : String(err),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
await sendProgress(65, 100, "Reading workspace config...");
|
|
325
|
+
// ── Step 4: Read workspace config ──
|
|
326
|
+
let wsBaseUrl = "";
|
|
327
|
+
let wsAuthHeader = "";
|
|
328
|
+
let wsSchemaPath = "";
|
|
329
|
+
let wsAuthMethod = "none";
|
|
330
|
+
try {
|
|
331
|
+
const wsMgr = new WorkspaceConfigManager(params.repositoryPath);
|
|
332
|
+
if (await wsMgr.exists()) {
|
|
333
|
+
const wsConfig = await wsMgr.read();
|
|
334
|
+
const svc = wsConfig.services?.[0];
|
|
335
|
+
if (svc?.api?.baseUrl)
|
|
336
|
+
wsBaseUrl = svc.api.baseUrl;
|
|
337
|
+
if (svc?.api?.authHeader)
|
|
338
|
+
wsAuthHeader = svc.api.authHeader;
|
|
339
|
+
if (svc?.api?.schemaPath)
|
|
340
|
+
wsSchemaPath = svc.api.schemaPath;
|
|
341
|
+
if (wsAuthHeader) {
|
|
342
|
+
wsAuthMethod = /cookie|session/i.test(wsAuthHeader)
|
|
343
|
+
? "session"
|
|
344
|
+
: "bearer";
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// workspace config not available
|
|
350
|
+
}
|
|
351
|
+
// ── Step 5: Detect project metadata ──
|
|
352
|
+
const projectMeta = detectProjectMetadata(params.repositoryPath);
|
|
353
|
+
// ── Step 6: Trace files ──
|
|
354
|
+
let traceResult;
|
|
355
|
+
const traceFiles = discoverTraceFiles(params.repositoryPath);
|
|
356
|
+
if (traceFiles.length > 0) {
|
|
357
|
+
try {
|
|
358
|
+
traceResult = await parseTraceFile(traceFiles[0]);
|
|
359
|
+
logger.info("Parsed trace file", {
|
|
360
|
+
file: traceFiles[0],
|
|
361
|
+
entries: traceResult.entries.length,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
logger.warning("Trace parsing failed", {
|
|
366
|
+
error: err instanceof Error ? err.message : String(err),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// ── Step 7: Build skeleton endpoints ──
|
|
371
|
+
const skeletonResponse = (method) => method === "POST"
|
|
372
|
+
? { statusCode: 201, description: "Created" }
|
|
373
|
+
: method === "DELETE"
|
|
374
|
+
? { statusCode: 204, description: "No Content" }
|
|
375
|
+
: { statusCode: 200, description: "OK" };
|
|
376
|
+
const skeletonEndpoints = scannedEndpoints.length > 0
|
|
377
|
+
? scannedEndpoints.map((ep) => ({
|
|
378
|
+
path: ep.path,
|
|
379
|
+
resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
|
|
380
|
+
pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
|
|
381
|
+
name: p.slice(1, -1),
|
|
382
|
+
type: "string",
|
|
383
|
+
required: true,
|
|
384
|
+
})),
|
|
385
|
+
methods: ep.methods.map((m) => ({
|
|
386
|
+
method: m,
|
|
387
|
+
description: "",
|
|
388
|
+
queryParams: [],
|
|
389
|
+
authRequired: true,
|
|
390
|
+
sourceFile: ep.sourceFile,
|
|
391
|
+
interactions: [
|
|
392
|
+
{
|
|
393
|
+
description: `${m} ${ep.path}`,
|
|
394
|
+
type: "success",
|
|
395
|
+
request: {},
|
|
396
|
+
response: skeletonResponse(m),
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
})),
|
|
400
|
+
}))
|
|
401
|
+
: parsedDiff
|
|
402
|
+
? (() => {
|
|
403
|
+
const grouped = new Map();
|
|
404
|
+
for (const ep of [
|
|
405
|
+
...parsedDiff.newEndpoints,
|
|
406
|
+
...parsedDiff.modifiedEndpoints,
|
|
407
|
+
]) {
|
|
408
|
+
let entry = grouped.get(ep.path);
|
|
409
|
+
if (!entry) {
|
|
410
|
+
entry = {
|
|
411
|
+
path: ep.path,
|
|
412
|
+
resourceGroup: ep.path.split("/").filter(Boolean).pop() || "unknown",
|
|
413
|
+
pathParams: (ep.path.match(/\{(\w+)\}/g) || []).map((p) => ({
|
|
414
|
+
name: p.slice(1, -1),
|
|
415
|
+
type: "string",
|
|
416
|
+
required: true,
|
|
417
|
+
})),
|
|
418
|
+
methods: [],
|
|
419
|
+
};
|
|
420
|
+
grouped.set(ep.path, entry);
|
|
421
|
+
}
|
|
422
|
+
entry.methods.push({
|
|
423
|
+
method: ep.method,
|
|
424
|
+
description: "",
|
|
425
|
+
queryParams: [],
|
|
426
|
+
authRequired: true,
|
|
427
|
+
sourceFile: ep.sourceFile,
|
|
428
|
+
interactions: [
|
|
429
|
+
{
|
|
430
|
+
description: `${ep.method} ${ep.path}`,
|
|
431
|
+
type: "success",
|
|
432
|
+
request: {},
|
|
433
|
+
response: skeletonResponse(ep.method),
|
|
434
|
+
},
|
|
435
|
+
],
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return Array.from(grouped.values());
|
|
439
|
+
})()
|
|
440
|
+
: [];
|
|
441
|
+
// ── Step 8: Merge trace interactions ──
|
|
442
|
+
if (traceResult && traceResult.entries.length > 0) {
|
|
443
|
+
for (const entry of traceResult.entries) {
|
|
444
|
+
let rawPath = entry.path;
|
|
445
|
+
try {
|
|
446
|
+
rawPath = new URL(rawPath).pathname;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
/* already a path */
|
|
450
|
+
}
|
|
451
|
+
const normalizedPath = rawPath
|
|
452
|
+
.replace(/\/[0-9a-f-]{20,}/gi, "/{id}")
|
|
453
|
+
.replace(/\/\d+/g, "/{id}");
|
|
454
|
+
const existing = skeletonEndpoints.find((ep) => {
|
|
455
|
+
const epNorm = ep.path.replace(/\{\w+\}/g, "/{id}");
|
|
456
|
+
return epNorm === normalizedPath;
|
|
457
|
+
});
|
|
458
|
+
if (existing) {
|
|
459
|
+
const methodObj = existing.methods.find((m) => m.method === entry.method);
|
|
460
|
+
if (methodObj) {
|
|
461
|
+
const alreadyHasStatus = methodObj.interactions.some((i) => i.response.statusCode === entry.statusCode);
|
|
462
|
+
if (!alreadyHasStatus) {
|
|
463
|
+
methodObj.interactions.push({
|
|
464
|
+
description: `${entry.method} ${entry.path} \u2192 ${entry.statusCode} (from trace)`,
|
|
465
|
+
type: "success",
|
|
466
|
+
request: entry.requestBody ? { body: entry.requestBody } : {},
|
|
467
|
+
response: {
|
|
468
|
+
statusCode: entry.statusCode,
|
|
469
|
+
description: `Observed in trace (${traceResult.format})`,
|
|
470
|
+
...(entry.responseBody
|
|
471
|
+
? { body: entry.responseBody }
|
|
472
|
+
: {}),
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// ── Step 9: Draft scenarios ──
|
|
481
|
+
const codeInferredScenarios = draftScenariosFromEndpoints(skeletonEndpoints);
|
|
482
|
+
let allDraftedScenarios = codeInferredScenarios;
|
|
483
|
+
if (traceResult && traceResult.userFlows.length > 0) {
|
|
484
|
+
const traceScenarios = traceResult.userFlows
|
|
485
|
+
.slice(0, 5)
|
|
486
|
+
.map((flow, idx) => ({
|
|
487
|
+
scenarioName: `trace-flow-${idx + 1}`,
|
|
488
|
+
description: `User flow from trace: ${flow.entries.map((e) => `${e.method} ${e.path}`).join(" \u2192 ")}`,
|
|
489
|
+
category: "workflow",
|
|
490
|
+
priority: "high",
|
|
491
|
+
steps: flow.entries.map((e, stepIdx) => ({
|
|
492
|
+
order: stepIdx + 1,
|
|
493
|
+
method: e.method,
|
|
494
|
+
path: e.path,
|
|
495
|
+
description: `${e.method} ${e.path} \u2192 ${e.statusCode}`,
|
|
496
|
+
interactionType: e.statusCode < 400
|
|
497
|
+
? "success"
|
|
498
|
+
: "error",
|
|
499
|
+
requestBody: e.requestBody,
|
|
500
|
+
responseBody: e.responseBody,
|
|
501
|
+
expectedStatusCode: e.statusCode,
|
|
502
|
+
})),
|
|
503
|
+
chainingKeys: [],
|
|
504
|
+
requiresAuth: true,
|
|
505
|
+
estimatedComplexity: flow.entries.length > 3
|
|
506
|
+
? "complex"
|
|
507
|
+
: "moderate",
|
|
508
|
+
source: "trace",
|
|
509
|
+
}));
|
|
510
|
+
allDraftedScenarios = [...traceScenarios, ...codeInferredScenarios];
|
|
511
|
+
}
|
|
512
|
+
await sendProgress(80, 100, "Building unified state...");
|
|
513
|
+
// ── Step 10: Build full RepositoryAnalysis for ranked recommendations ──
|
|
514
|
+
const sessionId = crypto.randomUUID();
|
|
515
|
+
// Build existing test locations map (type → file list) for deduplication in recommendations
|
|
516
|
+
const testLocationsByType = {};
|
|
517
|
+
for (const t of existingTests) {
|
|
518
|
+
const type = t.testType || "unknown";
|
|
519
|
+
testLocationsByType[type] = testLocationsByType[type]
|
|
520
|
+
? `${testLocationsByType[type]}, ${t.testFile}`
|
|
521
|
+
: t.testFile;
|
|
522
|
+
}
|
|
523
|
+
// Build the full RepositoryAnalysis object — same structure as analyzeRepositoryTool
|
|
524
|
+
// so buildRecommendationPrompt can reason over enriched endpoint + scenario data
|
|
525
|
+
const diffContext = parsedDiff ? {
|
|
526
|
+
currentBranch: parsedDiff.currentBranch,
|
|
527
|
+
baseBranch: parsedDiff.baseBranch,
|
|
528
|
+
changedFiles: parsedDiff.changedFiles,
|
|
529
|
+
newEndpoints: (() => {
|
|
530
|
+
const grouped = new Map();
|
|
531
|
+
for (const ep of parsedDiff.newEndpoints) {
|
|
532
|
+
let entry = grouped.get(ep.path);
|
|
533
|
+
if (!entry) {
|
|
534
|
+
entry = { path: ep.path, methods: [] };
|
|
535
|
+
grouped.set(ep.path, entry);
|
|
536
|
+
}
|
|
537
|
+
entry.methods.push({ method: ep.method, sourceFile: ep.sourceFile, interactionCount: 0 });
|
|
538
|
+
}
|
|
539
|
+
return Array.from(grouped.values());
|
|
540
|
+
})(),
|
|
541
|
+
modifiedEndpoints: (() => {
|
|
542
|
+
const grouped = new Map();
|
|
543
|
+
for (const ep of parsedDiff.modifiedEndpoints) {
|
|
544
|
+
let entry = grouped.get(ep.path);
|
|
545
|
+
if (!entry) {
|
|
546
|
+
entry = { path: ep.path, methods: [] };
|
|
547
|
+
grouped.set(ep.path, entry);
|
|
548
|
+
}
|
|
549
|
+
entry.methods.push({ method: ep.method, sourceFile: ep.sourceFile, changeType: "modified" });
|
|
550
|
+
}
|
|
551
|
+
return Array.from(grouped.values());
|
|
552
|
+
})(),
|
|
553
|
+
affectedServices: parsedDiff.affectedServices,
|
|
554
|
+
summary: "",
|
|
555
|
+
} : undefined;
|
|
556
|
+
const fullAnalysis = {
|
|
557
|
+
metadata: {
|
|
558
|
+
repositoryName: path.basename(params.repositoryPath),
|
|
559
|
+
analysisDate: new Date().toISOString(),
|
|
560
|
+
scanDepth: "full",
|
|
561
|
+
analysisScope,
|
|
562
|
+
},
|
|
563
|
+
projectClassification: {
|
|
564
|
+
projectType: projectMeta.projectType,
|
|
565
|
+
primaryLanguage: projectMeta.primaryLanguage,
|
|
566
|
+
primaryFramework: projectMeta.primaryFramework,
|
|
567
|
+
deploymentPattern: projectMeta.deploymentPattern,
|
|
568
|
+
},
|
|
569
|
+
technologyStack: {
|
|
570
|
+
languages: projectMeta.languages,
|
|
571
|
+
frameworks: projectMeta.frameworks,
|
|
572
|
+
runtime: projectMeta.runtime,
|
|
573
|
+
keyDependencies: [],
|
|
574
|
+
},
|
|
575
|
+
businessContext: {
|
|
576
|
+
mainPurpose: "",
|
|
577
|
+
userFlows: [],
|
|
578
|
+
dataFlows: [],
|
|
579
|
+
integrationPatterns: [],
|
|
580
|
+
draftedScenarios: allDraftedScenarios,
|
|
581
|
+
},
|
|
582
|
+
artifacts: {
|
|
583
|
+
openApiSpecs: wsSchemaPath ? [{ path: wsSchemaPath, version: "from-workspace-config", endpointCount: 0, baseUrl: wsBaseUrl, authType: wsAuthMethod }] : [],
|
|
584
|
+
playwrightRecordings: [],
|
|
585
|
+
traceFiles: traceResult ? [traceResult] : [],
|
|
586
|
+
notFound: [],
|
|
587
|
+
},
|
|
588
|
+
apiEndpoints: {
|
|
589
|
+
totalCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
|
|
590
|
+
baseUrl: wsBaseUrl,
|
|
591
|
+
endpoints: skeletonEndpoints,
|
|
592
|
+
},
|
|
593
|
+
authentication: {
|
|
594
|
+
method: wsAuthMethod,
|
|
595
|
+
configLocation: wsAuthHeader ? ".skyramp/workspace.yml" : "",
|
|
596
|
+
envVarsRequired: [],
|
|
597
|
+
setupExample: wsAuthHeader ? `${wsAuthHeader}: <token>` : "",
|
|
598
|
+
},
|
|
599
|
+
infrastructure: {
|
|
600
|
+
isContainerized: projectMeta.isContainerized,
|
|
601
|
+
hasDockerCompose: projectMeta.hasDockerCompose,
|
|
602
|
+
hasKubernetes: false,
|
|
603
|
+
hasCiCd: projectMeta.hasCiCd,
|
|
604
|
+
},
|
|
605
|
+
existingTests: {
|
|
606
|
+
frameworks: [...new Set(existingTests.map((t) => t.framework).filter(Boolean))],
|
|
607
|
+
coverage: { unit: 0, integration: 0, e2e: 0, ui: 0, load: 0, contract: 0, smoke: 0 },
|
|
608
|
+
testLocations: testLocationsByType,
|
|
609
|
+
hasCoverageReports: false,
|
|
610
|
+
},
|
|
611
|
+
...(diffContext ? { branchDiffContext: diffContext } : {}),
|
|
612
|
+
};
|
|
613
|
+
// Store RecommendationState in memory so it's compatible with skyramp_recommend_tests if needed
|
|
614
|
+
const recommendationState = {
|
|
615
|
+
repositoryPath: params.repositoryPath,
|
|
616
|
+
analysisScope: analysisScope,
|
|
617
|
+
analysis: fullAnalysis,
|
|
618
|
+
};
|
|
619
|
+
storeSessionData(sessionId, recommendationState);
|
|
620
|
+
registerSession(sessionId, `memory://${sessionId}`);
|
|
621
|
+
// ── Step 11: Build UnifiedAnalysisState and save ──
|
|
622
|
+
const unifiedState = {
|
|
623
|
+
existingTests,
|
|
624
|
+
newEndpoints,
|
|
625
|
+
analysisScope: scope === "branch_diff" ? "branch_diff" : "full_repo",
|
|
626
|
+
repositoryAnalysis: {
|
|
627
|
+
skeletonEndpoints,
|
|
628
|
+
projectMeta,
|
|
629
|
+
wsBaseUrl,
|
|
630
|
+
wsAuthHeader,
|
|
631
|
+
wsSchemaPath,
|
|
632
|
+
wsAuthMethod,
|
|
633
|
+
scenarios: allDraftedScenarios,
|
|
634
|
+
diff: parsedDiff,
|
|
635
|
+
fullAnalysis, // include full analysis for downstream tools
|
|
636
|
+
sessionId, // expose sessionId for optional skyramp_recommend_tests call
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
const stateManager = new StateManager("analysis", sessionId);
|
|
640
|
+
await stateManager.writeData(unifiedState, {
|
|
641
|
+
repositoryPath: params.repositoryPath,
|
|
642
|
+
step: "analyze_changes",
|
|
643
|
+
});
|
|
644
|
+
const stateFile = stateManager.getStatePath();
|
|
645
|
+
try {
|
|
646
|
+
await server.server.sendResourceListChanged();
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
// Client may not support resource list notifications
|
|
650
|
+
}
|
|
651
|
+
await sendProgress(90, 100, "Generating ranked recommendations...");
|
|
652
|
+
// ── Step 12: Generate ranked recommendations inline ──
|
|
653
|
+
const topN = params.topN ?? MAX_RECOMMENDATIONS;
|
|
654
|
+
// ── Step 13: Fetch PR comment history for deduplication ──
|
|
655
|
+
let prContext;
|
|
656
|
+
if (params.prNumber) {
|
|
657
|
+
try {
|
|
658
|
+
// Derive repo owner/name from git remote
|
|
659
|
+
const { execFileSync } = await import("child_process");
|
|
660
|
+
const remoteUrl = execFileSync("git", ["-C", params.repositoryPath, "remote", "get-url", "origin"], { encoding: "utf-8", timeout: 5_000 }).trim();
|
|
661
|
+
// Parse owner/repo from https or ssh remote URLs
|
|
662
|
+
const match = remoteUrl.match(/[:/]([^/]+)\/([^/.]+?)(\.git)?$/);
|
|
663
|
+
if (match) {
|
|
664
|
+
prContext = await parsePRComments(match[1], match[2], params.prNumber);
|
|
665
|
+
logger.info("Fetched PR comment history", {
|
|
666
|
+
prNumber: params.prNumber,
|
|
667
|
+
previousRecommendations: prContext.previousRecommendations.length,
|
|
668
|
+
implementedFiles: prContext.implementedTestFiles.length,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
logger.warning("Failed to fetch PR comment history — continuing without it", {
|
|
674
|
+
error: err instanceof Error ? err.message : String(err),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
// Fallback: if comment-based implementedTestFiles is empty (names didn't
|
|
678
|
+
// match disk, e.g. after a force push), recover from the actual bot commit.
|
|
679
|
+
if (prContext && prContext.implementedTestFiles.length === 0 && scope === "branch_diff") {
|
|
680
|
+
const botFiles = await getBotCommittedFiles(params.repositoryPath);
|
|
681
|
+
if (botFiles.length > 0) {
|
|
682
|
+
logger.info("Recovered implementedTestFiles from bot commit (comment names did not match disk)", {
|
|
683
|
+
files: botFiles,
|
|
684
|
+
});
|
|
685
|
+
prContext.implementedTestFiles = botFiles;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
const recommendationPrompt = buildRecommendationPrompt(fullAnalysis, analysisScope, topN, prContext, wsAuthHeader || undefined);
|
|
690
|
+
const routerMountContext = grepRouterMountingContext(params.repositoryPath);
|
|
691
|
+
await sendProgress(100, 100, "Analysis complete.");
|
|
692
|
+
const stateSize = await stateManager.getSizeFormatted();
|
|
693
|
+
const structuredSummary = JSON.stringify({
|
|
694
|
+
sessionId,
|
|
695
|
+
stateFile,
|
|
696
|
+
summary: {
|
|
697
|
+
repositoryName: path.basename(params.repositoryPath),
|
|
698
|
+
projectType: projectMeta.projectType,
|
|
699
|
+
primaryFramework: projectMeta.primaryFramework,
|
|
700
|
+
existingTestCount: existingTests.length,
|
|
701
|
+
newEndpointCount: newEndpoints.length,
|
|
702
|
+
endpointCount: skeletonEndpoints.reduce((acc, ep) => acc + ep.methods.length, 0),
|
|
703
|
+
},
|
|
704
|
+
stateFileSize: stateSize,
|
|
705
|
+
nextStep: "Call skyramp_analyze_test_health with stateFile to run drift analysis and health scoring",
|
|
706
|
+
}, null, 2);
|
|
707
|
+
const outputText = buildAnalysisOutputText({
|
|
708
|
+
sessionId,
|
|
709
|
+
stateFile,
|
|
710
|
+
repositoryPath: params.repositoryPath,
|
|
711
|
+
analysisScope,
|
|
712
|
+
parsedDiff,
|
|
713
|
+
scannedEndpoints,
|
|
714
|
+
wsBaseUrl,
|
|
715
|
+
wsAuthHeader,
|
|
716
|
+
wsSchemaPath,
|
|
717
|
+
routerMountContext,
|
|
718
|
+
nextTool: "skyramp_analyze_test_health",
|
|
719
|
+
});
|
|
720
|
+
return {
|
|
721
|
+
content: [
|
|
722
|
+
{
|
|
723
|
+
type: "text",
|
|
724
|
+
text: `\`\`\`json\n${structuredSummary}\n\`\`\`\n\n${outputText}\n\n---\n\n## Ranked Test Recommendations\n\n${recommendationPrompt}`,
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
isError: false,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
catch (error) {
|
|
731
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
732
|
+
logger.error("Analyze changes tool failed", { error: errorMessage });
|
|
733
|
+
errorResult = {
|
|
734
|
+
content: [
|
|
735
|
+
{
|
|
736
|
+
type: "text",
|
|
737
|
+
text: `Error analyzing changes: ${errorMessage}`,
|
|
738
|
+
},
|
|
739
|
+
],
|
|
740
|
+
isError: true,
|
|
741
|
+
};
|
|
742
|
+
return errorResult;
|
|
743
|
+
}
|
|
744
|
+
finally {
|
|
745
|
+
AnalyticsService.pushMCPToolEvent(TOOL_NAME, errorResult, {
|
|
746
|
+
repositoryPath: params.repositoryPath,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
}
|