@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.
Files changed (28) hide show
  1. package/build/index.js +18 -26
  2. package/build/prompts/test-maintenance/drift-analysis-prompt.js +59 -0
  3. package/build/prompts/test-maintenance/driftAnalysisSections.js +153 -0
  4. package/build/prompts/test-recommendation/analysisOutputPrompt.js +21 -9
  5. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +34 -38
  6. package/build/prompts/test-recommendation/test-recommendation-prompt.js +56 -9
  7. package/build/prompts/testbot/testbot-prompts.js +113 -100
  8. package/build/services/DriftAnalysisService.js +1 -1
  9. package/build/services/ScenarioGenerationService.js +5 -1
  10. package/build/services/TestExecutionService.js +2 -24
  11. package/build/services/TestExecutionService.test.js +167 -0
  12. package/build/services/containerEnv.js +35 -0
  13. package/build/tools/generate-tests/generateScenarioRestTool.js +7 -1
  14. package/build/tools/submitReportTool.js +6 -6
  15. package/build/tools/test-management/actionsTool.js +396 -0
  16. package/build/tools/test-management/analyzeChangesTool.js +750 -0
  17. package/build/tools/test-management/analyzeTestHealthTool.js +132 -0
  18. package/build/tools/test-management/executeTestsTool.js +198 -0
  19. package/build/tools/test-management/index.js +5 -0
  20. package/build/tools/test-management/stateCleanupTool.js +163 -0
  21. package/build/tools/test-recommendation/recommendTestsTool.js +1 -1
  22. package/build/utils/analyze-openapi.js +2 -2
  23. package/build/utils/pr-comment-parser.js +157 -36
  24. package/build/utils/pr-comment-parser.test.js +427 -0
  25. package/package.json +1 -1
  26. package/build/tools/initTestbotTool.js +0 -187
  27. package/build/tools/initTestbotTool.test.js +0 -194
  28. 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
+ }