@skyramp/mcp 0.0.59 → 0.0.60-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 (35) hide show
  1. package/build/index.js +32 -5
  2. package/build/prompts/test-recommendation/analysisOutputPrompt.js +98 -0
  3. package/build/prompts/test-recommendation/recommendationSections.js +226 -0
  4. package/build/prompts/test-recommendation/registerRecommendTestsPrompt.js +71 -0
  5. package/build/prompts/test-recommendation/test-recommendation-prompt.js +166 -104
  6. package/build/prompts/testGenerationPrompt.js +2 -3
  7. package/build/prompts/testbot/testbot-prompts.js +96 -93
  8. package/build/resources/analysisResources.js +254 -0
  9. package/build/services/ScenarioGenerationService.js +70 -26
  10. package/build/tools/generate-tests/generateIntegrationRestTool.js +54 -1
  11. package/build/tools/generate-tests/generateScenarioRestTool.js +8 -5
  12. package/build/tools/submitReportTool.js +28 -0
  13. package/build/tools/test-maintenance/stateCleanupTool.js +8 -0
  14. package/build/tools/test-recommendation/analyzeRepositoryTool.js +349 -217
  15. package/build/tools/test-recommendation/recommendTestsTool.js +163 -159
  16. package/build/tools/workspace/initializeWorkspaceTool.js +1 -1
  17. package/build/types/RepositoryAnalysis.js +99 -12
  18. package/build/utils/AnalysisStateManager.js +40 -23
  19. package/build/utils/branchDiff.js +47 -0
  20. package/build/utils/pr-comment-parser.js +124 -0
  21. package/build/utils/projectMetadata.js +188 -0
  22. package/build/utils/projectMetadata.test.js +81 -0
  23. package/build/utils/repoScanner.js +378 -0
  24. package/build/utils/routeParsers.js +213 -0
  25. package/build/utils/routeParsers.test.js +87 -0
  26. package/build/utils/scenarioDrafting.js +119 -0
  27. package/build/utils/scenarioDrafting.test.js +66 -0
  28. package/build/utils/trace-parser.js +166 -0
  29. package/build/utils/workspaceAuth.js +16 -0
  30. package/package.json +1 -1
  31. package/build/prompts/test-recommendation/repository-analysis-prompt.js +0 -326
  32. package/build/prompts/test-recommendation/test-mapping-prompt.js +0 -266
  33. package/build/tools/test-recommendation/mapTestsTool.js +0 -243
  34. package/build/types/TestMapping.js +0 -173
  35. package/build/utils/scoring-engine.js +0 -380
@@ -0,0 +1,47 @@
1
+ import { simpleGit } from "simple-git";
2
+ import { logger } from "./logger.js";
3
+ const MAX_DIFF_LENGTH = 50_000;
4
+ export async function computeBranchDiff(repositoryPath, providedBaseBranch) {
5
+ const git = simpleGit(repositoryPath);
6
+ const isRepo = await git.checkIsRepo();
7
+ if (!isRepo) {
8
+ throw new Error(`"${repositoryPath}" is not a Git repository.`);
9
+ }
10
+ const branchInfo = await git.branch();
11
+ const currentBranch = branchInfo.current || "HEAD";
12
+ let baseBranch;
13
+ if (providedBaseBranch) {
14
+ baseBranch = `origin/${providedBaseBranch}`;
15
+ }
16
+ else {
17
+ baseBranch = "origin/main";
18
+ try {
19
+ const remoteBranches = await git.branch(["-r"]);
20
+ if (remoteBranches.all.some((b) => b.endsWith("/main"))) {
21
+ baseBranch = "origin/main";
22
+ }
23
+ else if (remoteBranches.all.some((b) => b.endsWith("/master"))) {
24
+ baseBranch = "origin/master";
25
+ }
26
+ }
27
+ catch {
28
+ logger.debug("Could not determine remote default branch, falling back to origin/main");
29
+ }
30
+ }
31
+ const changedFilesRaw = await git.diff([
32
+ `${baseBranch}...HEAD`,
33
+ "--name-only",
34
+ ]);
35
+ const changedFiles = changedFilesRaw
36
+ .split("\n")
37
+ .map((f) => f.trim())
38
+ .filter((f) => f.length > 0);
39
+ const diffStat = await git.diff([`${baseBranch}...HEAD`, "--stat"]);
40
+ let diffContent = await git.diff([`${baseBranch}...HEAD`]);
41
+ if (diffContent.length > MAX_DIFF_LENGTH) {
42
+ diffContent =
43
+ diffContent.substring(0, MAX_DIFF_LENGTH) +
44
+ `\n\n... [diff truncated at ${MAX_DIFF_LENGTH} chars, ${changedFiles.length} files total] ...`;
45
+ }
46
+ return { currentBranch, baseBranch, changedFiles, diffContent, diffStat };
47
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * PR Comment Parser — extracts TestBot history from GitHub PR comments.
3
+ *
4
+ * Parses previous Skyramp TestBot comments on a PR to build context
5
+ * about what has already been recommended, implemented, and tested.
6
+ * This prevents re-recommendations and enables contextual awareness
7
+ * across the PR lifecycle.
8
+ */
9
+ import { execSync } from "child_process";
10
+ import { logger } from "./logger.js";
11
+ const TESTBOT_MARKERS = [
12
+ "Generated by Skyramp",
13
+ "Skyramp Testbot",
14
+ "skyramp-testbot",
15
+ ];
16
+ const TEST_TYPE_PATTERN = /\b(Smoke|Contract|Integration|Fuzz|Load|E2E|UI)\b/gi;
17
+ const ENDPOINT_PATTERN = /\b(GET|POST|PUT|PATCH|DELETE)\s+(\/\S+)/gi;
18
+ const TEST_FILE_PATTERN = /[\w/.-]+(?:_test|_smoke|_contract|_fuzz|_integration|_load|_e2e|_ui)\.\w+/gi;
19
+ const STATUS_PATTERN = /\b(Pass|Fail|Skipped)\b/gi;
20
+ function isTestBotComment(body) {
21
+ return TESTBOT_MARKERS.some((marker) => body.includes(marker));
22
+ }
23
+ function extractRecommendations(comment) {
24
+ const results = [];
25
+ const body = comment.body;
26
+ const endpointMatches = [...body.matchAll(ENDPOINT_PATTERN)];
27
+ const typeMatches = [...body.matchAll(TEST_TYPE_PATTERN)];
28
+ const implementedFiles = [...body.matchAll(TEST_FILE_PATTERN)];
29
+ const hasImplementedSection = body.includes("## New Tests Created") ||
30
+ body.includes("newTestsCreated");
31
+ for (const ep of endpointMatches) {
32
+ const endpoint = `${ep[1].toUpperCase()} ${ep[2]}`;
33
+ const nearbyType = typeMatches.find((t) => Math.abs(t.index - ep.index) < 200);
34
+ const testType = nearbyType
35
+ ? nearbyType[1].toLowerCase()
36
+ : "unknown";
37
+ const isImplemented = hasImplementedSection && implementedFiles.length > 0;
38
+ results.push({
39
+ testType,
40
+ endpoint,
41
+ status: isImplemented ? "implemented" : "recommended",
42
+ commentId: String(comment.id),
43
+ });
44
+ }
45
+ return results;
46
+ }
47
+ function extractExecutionResults(body) {
48
+ const results = [];
49
+ const fileMatches = [...body.matchAll(TEST_FILE_PATTERN)];
50
+ const statusMatches = [...body.matchAll(STATUS_PATTERN)];
51
+ for (let i = 0; i < fileMatches.length; i++) {
52
+ const file = fileMatches[i][0];
53
+ const nearbyStatus = statusMatches.find((s) => Math.abs(s.index - fileMatches[i].index) < 150);
54
+ results.push({
55
+ testFile: file,
56
+ status: nearbyStatus?.[1].toLowerCase() === "pass"
57
+ ? "pass"
58
+ : "fail",
59
+ timestamp: new Date().toISOString(),
60
+ });
61
+ }
62
+ return results;
63
+ }
64
+ function extractImplementedFiles(body) {
65
+ const matches = [...body.matchAll(TEST_FILE_PATTERN)];
66
+ return [...new Set(matches.map((m) => m[0]))];
67
+ }
68
+ /**
69
+ * Fetch and parse PR comments to build TestBot history context.
70
+ *
71
+ * Uses the `gh` CLI (available in CI and local dev environments with
72
+ * GitHub CLI installed). Falls back gracefully if `gh` is unavailable.
73
+ */
74
+ export async function parsePRComments(repoOwner, repoName, prNumber, _token) {
75
+ const empty = {
76
+ prNumber,
77
+ previousRecommendations: [],
78
+ implementedTestFiles: [],
79
+ executionResults: [],
80
+ };
81
+ let rawComments;
82
+ try {
83
+ rawComments = execSync(`gh api repos/${repoOwner}/${repoName}/issues/${prNumber}/comments --paginate`, {
84
+ encoding: "utf-8",
85
+ timeout: 15_000,
86
+ stdio: ["pipe", "pipe", "pipe"],
87
+ });
88
+ }
89
+ catch (err) {
90
+ logger.warning("Failed to fetch PR comments via gh CLI — skipping PR context", {
91
+ error: err instanceof Error ? err.message : String(err),
92
+ });
93
+ return empty;
94
+ }
95
+ let comments;
96
+ try {
97
+ comments = JSON.parse(rawComments);
98
+ if (!Array.isArray(comments)) {
99
+ return empty;
100
+ }
101
+ }
102
+ catch {
103
+ logger.warning("Failed to parse PR comments JSON");
104
+ return empty;
105
+ }
106
+ const testBotComments = comments.filter((c) => isTestBotComment(c.body));
107
+ if (testBotComments.length === 0) {
108
+ return empty;
109
+ }
110
+ const allRecommendations = [];
111
+ const allFiles = [];
112
+ const allExecutionResults = [];
113
+ for (const comment of testBotComments) {
114
+ allRecommendations.push(...extractRecommendations(comment));
115
+ allFiles.push(...extractImplementedFiles(comment.body));
116
+ allExecutionResults.push(...extractExecutionResults(comment.body));
117
+ }
118
+ return {
119
+ prNumber,
120
+ previousRecommendations: allRecommendations,
121
+ implementedTestFiles: [...new Set(allFiles)],
122
+ executionResults: allExecutionResults,
123
+ };
124
+ }
@@ -0,0 +1,188 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ export function detectProjectMetadata(repositoryPath) {
4
+ const langSet = new Set();
5
+ const fwSet = new Set();
6
+ const meta = {
7
+ projectType: "other", primaryLanguage: "unknown", primaryFramework: "unknown",
8
+ deploymentPattern: "unknown", languages: [], frameworks: [], runtime: "unknown",
9
+ isContainerized: false, hasDockerCompose: false, hasCiCd: false,
10
+ };
11
+ const exists = (rel) => fs.existsSync(path.join(repositoryPath, rel));
12
+ const readJson = (rel) => {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(path.join(repositoryPath, rel), "utf-8"));
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ };
20
+ const readText = (rel) => {
21
+ try {
22
+ return fs.readFileSync(path.join(repositoryPath, rel), "utf-8");
23
+ }
24
+ catch {
25
+ return "";
26
+ }
27
+ };
28
+ meta.isContainerized = exists("Dockerfile") || exists("backend/Dockerfile") || exists("frontend/Dockerfile");
29
+ meta.hasDockerCompose = exists("docker-compose.yml") || exists("docker-compose.yaml");
30
+ meta.hasCiCd = exists(".github/workflows") || exists(".gitlab-ci.yml") || exists("Jenkinsfile");
31
+ if (meta.isContainerized)
32
+ meta.deploymentPattern = "containerized-monolith";
33
+ const pkg = readJson("package.json");
34
+ if (pkg) {
35
+ langSet.add("TypeScript/JavaScript");
36
+ meta.runtime = "Node.js";
37
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
38
+ if (allDeps["next"]) {
39
+ fwSet.add("Next.js");
40
+ meta.primaryFramework = "Next.js";
41
+ }
42
+ if (allDeps["express"]) {
43
+ fwSet.add("Express");
44
+ if (meta.primaryFramework === "unknown")
45
+ meta.primaryFramework = "Express";
46
+ }
47
+ if (allDeps["react"])
48
+ fwSet.add("React");
49
+ if (allDeps["vue"])
50
+ fwSet.add("Vue");
51
+ if (allDeps["@nestjs/core"]) {
52
+ fwSet.add("NestJS");
53
+ meta.primaryFramework = "NestJS";
54
+ }
55
+ meta.primaryLanguage = allDeps["typescript"] ? "TypeScript" : "JavaScript";
56
+ }
57
+ const reqs = readText("requirements.txt") + readText("backend/requirements.txt");
58
+ const pyproject = readText("pyproject.toml");
59
+ if (reqs || exists("setup.py") || pyproject) {
60
+ langSet.add("Python");
61
+ if (!meta.runtime || meta.runtime === "unknown")
62
+ meta.runtime = "Python";
63
+ if (/fastapi/i.test(reqs + pyproject)) {
64
+ fwSet.add("FastAPI");
65
+ meta.primaryFramework = "FastAPI";
66
+ meta.primaryLanguage = "Python";
67
+ }
68
+ if (/django/i.test(reqs + pyproject)) {
69
+ fwSet.add("Django");
70
+ if (meta.primaryFramework === "unknown") {
71
+ meta.primaryFramework = "Django";
72
+ meta.primaryLanguage = "Python";
73
+ }
74
+ }
75
+ if (/flask/i.test(reqs + pyproject)) {
76
+ fwSet.add("Flask");
77
+ if (meta.primaryFramework === "unknown") {
78
+ meta.primaryFramework = "Flask";
79
+ meta.primaryLanguage = "Python";
80
+ }
81
+ }
82
+ }
83
+ if (exists("pom.xml") || exists("build.gradle")) {
84
+ langSet.add("Java");
85
+ if (meta.primaryLanguage === "unknown")
86
+ meta.primaryLanguage = "Java";
87
+ fwSet.add("Spring");
88
+ if (meta.primaryFramework === "unknown")
89
+ meta.primaryFramework = "Spring";
90
+ meta.runtime = "JVM";
91
+ }
92
+ // Go
93
+ if (exists("go.mod")) {
94
+ langSet.add("Go");
95
+ if (meta.primaryLanguage === "unknown")
96
+ meta.primaryLanguage = "Go";
97
+ meta.runtime = "Go";
98
+ const gomod = readText("go.mod");
99
+ if (/gin-gonic/i.test(gomod)) {
100
+ fwSet.add("Gin");
101
+ if (meta.primaryFramework === "unknown")
102
+ meta.primaryFramework = "Gin";
103
+ }
104
+ else if (/labstack\/echo/i.test(gomod)) {
105
+ fwSet.add("Echo");
106
+ if (meta.primaryFramework === "unknown")
107
+ meta.primaryFramework = "Echo";
108
+ }
109
+ else if (/go-chi/i.test(gomod)) {
110
+ fwSet.add("Chi");
111
+ if (meta.primaryFramework === "unknown")
112
+ meta.primaryFramework = "Chi";
113
+ }
114
+ }
115
+ // Ruby
116
+ if (exists("Gemfile")) {
117
+ langSet.add("Ruby");
118
+ if (meta.primaryLanguage === "unknown")
119
+ meta.primaryLanguage = "Ruby";
120
+ meta.runtime = "Ruby";
121
+ const gemfile = readText("Gemfile");
122
+ if (/rails/i.test(gemfile)) {
123
+ fwSet.add("Rails");
124
+ meta.primaryFramework = "Rails";
125
+ }
126
+ else if (/sinatra/i.test(gemfile)) {
127
+ fwSet.add("Sinatra");
128
+ if (meta.primaryFramework === "unknown")
129
+ meta.primaryFramework = "Sinatra";
130
+ }
131
+ }
132
+ // PHP
133
+ const composerJson = readJson("composer.json");
134
+ if (composerJson) {
135
+ langSet.add("PHP");
136
+ if (meta.primaryLanguage === "unknown")
137
+ meta.primaryLanguage = "PHP";
138
+ meta.runtime = "PHP";
139
+ const allPhpDeps = { ...(composerJson.require || {}), ...(composerJson["require-dev"] || {}) };
140
+ if (allPhpDeps["laravel/framework"]) {
141
+ fwSet.add("Laravel");
142
+ meta.primaryFramework = "Laravel";
143
+ }
144
+ else if (allPhpDeps["symfony/framework-bundle"]) {
145
+ fwSet.add("Symfony");
146
+ if (meta.primaryFramework === "unknown")
147
+ meta.primaryFramework = "Symfony";
148
+ }
149
+ }
150
+ // Rust
151
+ if (exists("Cargo.toml")) {
152
+ langSet.add("Rust");
153
+ if (meta.primaryLanguage === "unknown")
154
+ meta.primaryLanguage = "Rust";
155
+ meta.runtime = "Rust";
156
+ const cargo = readText("Cargo.toml");
157
+ if (/actix-web/i.test(cargo)) {
158
+ fwSet.add("Actix");
159
+ if (meta.primaryFramework === "unknown")
160
+ meta.primaryFramework = "Actix";
161
+ }
162
+ else if (/axum/i.test(cargo)) {
163
+ fwSet.add("Axum");
164
+ if (meta.primaryFramework === "unknown")
165
+ meta.primaryFramework = "Axum";
166
+ }
167
+ else if (/rocket/i.test(cargo)) {
168
+ fwSet.add("Rocket");
169
+ if (meta.primaryFramework === "unknown")
170
+ meta.primaryFramework = "Rocket";
171
+ }
172
+ }
173
+ meta.languages = Array.from(langSet);
174
+ meta.frameworks = Array.from(fwSet);
175
+ const hasBackend = meta.frameworks.some(f => /express|fastapi|django|flask|nestjs|spring|gin|echo|chi|rails|sinatra|laravel|symfony|actix|axum|rocket/i.test(f));
176
+ const hasFrontend = meta.frameworks.some(f => /react|vue|next/i.test(f));
177
+ if (hasBackend && hasFrontend)
178
+ meta.projectType = "full-stack";
179
+ else if (hasBackend)
180
+ meta.projectType = "rest-api";
181
+ else if (hasFrontend)
182
+ meta.projectType = "frontend";
183
+ if (exists("backend") && exists("frontend") && meta.projectType !== "full-stack") {
184
+ meta.projectType = "full-stack";
185
+ meta.deploymentPattern = "full-stack";
186
+ }
187
+ return meta;
188
+ }
@@ -0,0 +1,81 @@
1
+ // @ts-ignore
2
+ import { detectProjectMetadata } from "./projectMetadata.js";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import * as os from "os";
6
+ function makeTempDir() {
7
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pm-test-"));
8
+ }
9
+ describe("detectProjectMetadata", () => {
10
+ it("detects Node.js + Express project", () => {
11
+ const dir = makeTempDir();
12
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ dependencies: { express: "^4.18.0" } }));
13
+ const meta = detectProjectMetadata(dir);
14
+ expect(meta.primaryLanguage).toBe("JavaScript");
15
+ expect(meta.frameworks).toContain("Express");
16
+ expect(meta.projectType).toBe("rest-api");
17
+ });
18
+ it("detects Node.js + Next.js as fullstack", () => {
19
+ const dir = makeTempDir();
20
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ dependencies: { next: "^14.0.0", react: "^18.0.0" } }));
21
+ const meta = detectProjectMetadata(dir);
22
+ expect(meta.frameworks).toContain("Next.js");
23
+ expect(meta.frameworks).toContain("React");
24
+ expect(["frontend", "full-stack"]).toContain(meta.projectType);
25
+ });
26
+ it("detects Python + FastAPI project", () => {
27
+ const dir = makeTempDir();
28
+ fs.writeFileSync(path.join(dir, "requirements.txt"), "fastapi==0.100.0\nuvicorn\n");
29
+ const meta = detectProjectMetadata(dir);
30
+ expect(meta.primaryLanguage).toBe("Python");
31
+ expect(meta.frameworks).toContain("FastAPI");
32
+ });
33
+ it("detects Python + Django project", () => {
34
+ const dir = makeTempDir();
35
+ fs.writeFileSync(path.join(dir, "requirements.txt"), "django==4.2\n");
36
+ const meta = detectProjectMetadata(dir);
37
+ expect(meta.primaryLanguage).toBe("Python");
38
+ expect(meta.frameworks).toContain("Django");
39
+ });
40
+ it("detects Java + Spring project", () => {
41
+ const dir = makeTempDir();
42
+ fs.writeFileSync(path.join(dir, "pom.xml"), "<project></project>");
43
+ const meta = detectProjectMetadata(dir);
44
+ expect(meta.primaryLanguage).toBe("Java");
45
+ expect(meta.frameworks).toContain("Spring");
46
+ });
47
+ it("detects Go + Gin project", () => {
48
+ const dir = makeTempDir();
49
+ fs.writeFileSync(path.join(dir, "go.mod"), "module example.com/app\nrequire github.com/gin-gonic/gin v1.9.0\n");
50
+ const meta = detectProjectMetadata(dir);
51
+ expect(meta.primaryLanguage).toBe("Go");
52
+ expect(meta.frameworks).toContain("Gin");
53
+ });
54
+ it("detects Ruby + Rails project", () => {
55
+ const dir = makeTempDir();
56
+ fs.writeFileSync(path.join(dir, "Gemfile"), 'source "https://rubygems.org"\ngem "rails", "~> 7.0"\n');
57
+ const meta = detectProjectMetadata(dir);
58
+ expect(meta.primaryLanguage).toBe("Ruby");
59
+ expect(meta.frameworks).toContain("Rails");
60
+ });
61
+ it("detects PHP + Laravel project", () => {
62
+ const dir = makeTempDir();
63
+ fs.writeFileSync(path.join(dir, "composer.json"), JSON.stringify({ require: { "laravel/framework": "^10.0" } }));
64
+ const meta = detectProjectMetadata(dir);
65
+ expect(meta.primaryLanguage).toBe("PHP");
66
+ expect(meta.frameworks).toContain("Laravel");
67
+ });
68
+ it("detects Rust + Actix project", () => {
69
+ const dir = makeTempDir();
70
+ fs.writeFileSync(path.join(dir, "Cargo.toml"), '[dependencies]\nactix-web = "4"\n');
71
+ const meta = detectProjectMetadata(dir);
72
+ expect(meta.primaryLanguage).toBe("Rust");
73
+ expect(meta.frameworks).toContain("Actix");
74
+ });
75
+ it("returns unknown for empty directory", () => {
76
+ const dir = makeTempDir();
77
+ const meta = detectProjectMetadata(dir);
78
+ expect(meta.primaryLanguage).toBe("unknown");
79
+ expect(meta.primaryFramework).toBe("unknown");
80
+ });
81
+ });