@skyramp/mcp 0.2.0-rc.3 → 0.2.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/build/prompts/testbot/testbot-prompts.js +1 -1
- package/build/services/AnalyticsService.js +5 -1
- package/build/tools/submitReportTool.js +56 -0
- package/build/tools/submitReportTool.test.js +106 -0
- package/build/utils/telemetry.js +70 -0
- package/build/utils/telemetry.test.js +70 -0
- package/package.json +1 -1
|
@@ -331,7 +331,7 @@ Call \`skyramp_submit_report\` with \`summaryOutputFile\`: "${summaryOutputFile}
|
|
|
331
331
|
- **additionalRecommendations**: AT MOST ${maxRecommendations - maxGenerate} items.
|
|
332
332
|
- For \`testType: "contract"\` entries: **\`primaryEndpoint\` is required** (e.g. \`"GET /api/v1/users/{user_id}"\`). The tool will reject the submission without it — do not omit it or you will be forced to resubmit.
|
|
333
333
|
- For \`testType: "integration"\` or \`"e2e"\` entries: omit \`primaryEndpoint\` — use \`description\` to list the endpoints involved instead.
|
|
334
|
-
- **testMaintenance**: Use \`[]\` **only** if no existing Skyramp tests were found in the repository. If existing tests were found (any score), include one entry per test. For UPDATE/REGENERATE/DELETE tests that were modified and executed, populate all fields from real before/after execution results. For IGNORE
|
|
334
|
+
- **testMaintenance**: Use \`[]\` **only** if no existing Skyramp tests were found in the repository. If existing tests were found (any score), include one entry per test. Set \`action\` to the exact drift action you chose from the Action Decision Matrix (\`UPDATE\`, \`REGENERATE\`, \`DELETE\`, \`VERIFY\`, or \`IGNORE\`). For UPDATE/REGENERATE/DELETE tests that were modified and executed, populate all fields from real before/after execution results. For VERIFY/IGNORE tests (not modified), derive \`beforeStatus\` from the \`skyramp_analyze_test_health\` health score (typically \`"Pass"\` if drift score is 0 and no health issues were flagged), set \`afterStatus\` to \`"Skipped"\`, and use \`afterDetails\` to explain why (e.g. "IGNORE: drift score 0 — endpoint not modified in this PR"). Do **not** add entries for tests that were not returned by the health analysis.
|
|
335
335
|
|
|
336
336
|
---
|
|
337
337
|
|
|
@@ -2,7 +2,7 @@ import { pushToolEvent } from "@skyramp/skyramp";
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
-
import { getEntryPoint, getCIPlatform } from "../utils/telemetry.js";
|
|
5
|
+
import { getEntryPoint, getCIPlatform, getRepositoryInfo, } from "../utils/telemetry.js";
|
|
6
6
|
import { logger } from "../utils/logger.js";
|
|
7
7
|
export class AnalyticsService {
|
|
8
8
|
static async pushTestGenerationToolEvent(toolName, result, params) {
|
|
@@ -29,6 +29,10 @@ export class AnalyticsService {
|
|
|
29
29
|
if (ciPlatform) {
|
|
30
30
|
params.ciPlatform = ciPlatform;
|
|
31
31
|
}
|
|
32
|
+
// Prefer a repo path the tool already supplied — the MCP server's own
|
|
33
|
+
// process.cwd() is set by the IDE and may not be the user's repo.
|
|
34
|
+
const repoPath = params.repositoryPath || params.workspacePath;
|
|
35
|
+
Object.assign(params, await getRepositoryInfo(repoPath));
|
|
32
36
|
await pushToolEvent(getEntryPoint(), toolName, errorMessage, params);
|
|
33
37
|
}
|
|
34
38
|
catch (error) {
|
|
@@ -5,6 +5,15 @@ import * as path from "path";
|
|
|
5
5
|
import { AnalyticsService } from "../services/AnalyticsService.js";
|
|
6
6
|
import { TEST_CATEGORIES, externalCategory } from "../types/TestRecommendation.js";
|
|
7
7
|
import { TestType, HttpMethod } from "../types/TestTypes.js";
|
|
8
|
+
import { DriftAction } from "../types/TestAnalysis.js";
|
|
9
|
+
// Drift actions that actually modify a test file. VERIFY and IGNORE are
|
|
10
|
+
// no-ops (the test was assessed but left unchanged), so they must not count
|
|
11
|
+
// toward "tests maintained" telemetry.
|
|
12
|
+
const MAINTENANCE_CHANGE_ACTIONS = new Set([
|
|
13
|
+
DriftAction.Update,
|
|
14
|
+
DriftAction.Regenerate,
|
|
15
|
+
DriftAction.Delete,
|
|
16
|
+
]);
|
|
8
17
|
const TOOL_NAME = "skyramp_submit_report";
|
|
9
18
|
const DEFAULT_COMMIT_MESSAGE = "Added recommendations by Skyramp Testbot.";
|
|
10
19
|
const testResultSchema = z.object({
|
|
@@ -73,12 +82,58 @@ const testMaintenanceSchema = z.object({
|
|
|
73
82
|
testType: z.nativeEnum(TestType).describe("Type of test."),
|
|
74
83
|
endpoint: z.string().describe("HTTP verb and path, e.g. 'GET /api/v1/products'"),
|
|
75
84
|
fileName: z.string().describe("Test file that was maintained, e.g. 'products_smoke_test.py'"),
|
|
85
|
+
action: z.nativeEnum(DriftAction).optional().describe("The drift action taken for this test, exactly as decided by the Action Decision Matrix: UPDATE, REGENERATE, or DELETE modify the test; VERIFY or IGNORE leave it unchanged (no-op)."),
|
|
76
86
|
description: z.string().describe("What was changed and why"),
|
|
77
87
|
beforeStatus: z.enum(["Pass", "Fail", "Error"]).describe("Test result BEFORE modification"),
|
|
78
88
|
beforeDetails: z.string().describe("Execution output/timing before modification, or 'baseline from CI workflow <name>' if a parallel workflow provided the baseline"),
|
|
79
89
|
afterStatus: z.enum(["Pass", "Fail", "Error", "Skipped"]).describe("Test result AFTER modification"),
|
|
80
90
|
afterDetails: z.string().describe("Execution output/timing after modification"),
|
|
81
91
|
});
|
|
92
|
+
/**
|
|
93
|
+
* Derive per-run analytics counts from a submitted report. These power the
|
|
94
|
+
* alpha-launch dashboards (tests generated/maintained, suite growth, bugs vs
|
|
95
|
+
* test failures, flakiness recovery). All values are stringified for Amplitude.
|
|
96
|
+
*
|
|
97
|
+
* Bug-vs-failure distinction:
|
|
98
|
+
* - issuesFound* = bugs/issues the agent flagged (real defects + severity)
|
|
99
|
+
* - testsFailed = test executions that returned "Fail" (test-level outcome)
|
|
100
|
+
* These are intentionally separate metrics — a failing test is not always a bug.
|
|
101
|
+
*
|
|
102
|
+
* maintenanceRecovered approximates flakiness/regression fixes: tests that were
|
|
103
|
+
* Fail/Error before maintenance and Pass afterward.
|
|
104
|
+
*
|
|
105
|
+
* testsMaintained counts only entries that actually changed a test file
|
|
106
|
+
* (action UPDATE/REGENERATE/DELETE). VERIFY/IGNORE entries are reported for
|
|
107
|
+
* transparency but are no-ops, so they are excluded. When `action` is absent
|
|
108
|
+
* (older reports), we fall back to the status heuristic: an IGNORE no-op sets
|
|
109
|
+
* afterStatus to "Skipped", so anything else is treated as a real change.
|
|
110
|
+
*/
|
|
111
|
+
function isMaintenanceChange(m) {
|
|
112
|
+
if (m.action) {
|
|
113
|
+
return MAINTENANCE_CHANGE_ACTIONS.has(m.action);
|
|
114
|
+
}
|
|
115
|
+
return m.afterStatus !== "Skipped";
|
|
116
|
+
}
|
|
117
|
+
function computeReportMetrics(params) {
|
|
118
|
+
const recommendations = params.additionalRecommendations ?? [];
|
|
119
|
+
const countBy = (items, pred) => items.filter(pred).length;
|
|
120
|
+
const changedMaintenance = params.testMaintenance.filter(isMaintenanceChange);
|
|
121
|
+
const maintenanceRecovered = countBy(changedMaintenance, (m) => m.beforeStatus !== "Pass" && m.afterStatus === "Pass");
|
|
122
|
+
return {
|
|
123
|
+
testsGenerated: String(params.newTestsCreated.length),
|
|
124
|
+
testsMaintained: String(changedMaintenance.length),
|
|
125
|
+
recommendationsCount: String(recommendations.length),
|
|
126
|
+
maintenanceRecovered: String(maintenanceRecovered),
|
|
127
|
+
testsPassed: String(countBy(params.testResults, (t) => t.status === "Pass")),
|
|
128
|
+
testsFailed: String(countBy(params.testResults, (t) => t.status === "Fail")),
|
|
129
|
+
testsSkipped: String(countBy(params.testResults, (t) => t.status === "Skipped")),
|
|
130
|
+
issuesFound: String(params.issuesFound.length),
|
|
131
|
+
issuesCritical: String(countBy(params.issuesFound, (i) => i.severity === "critical")),
|
|
132
|
+
issuesHigh: String(countBy(params.issuesFound, (i) => i.severity === "high")),
|
|
133
|
+
issuesMedium: String(countBy(params.issuesFound, (i) => i.severity === "medium")),
|
|
134
|
+
issuesLow: String(countBy(params.issuesFound, (i) => i.severity === "low")),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
82
137
|
export function registerSubmitReportTool(server) {
|
|
83
138
|
server.registerTool(TOOL_NAME, {
|
|
84
139
|
description: "Submit the final testbot report. Call this tool once after completing all test analysis, generation, and execution. " +
|
|
@@ -201,6 +256,7 @@ export function registerSubmitReportTool(server) {
|
|
|
201
256
|
summary_output_file: params.summaryOutputFile,
|
|
202
257
|
testResultCount: String(params.testResults.length),
|
|
203
258
|
payloadBytes: String(reportJson.length),
|
|
259
|
+
...computeReportMetrics(params),
|
|
204
260
|
}).catch(() => { });
|
|
205
261
|
}
|
|
206
262
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-ignore
|
|
2
2
|
import { registerSubmitReportTool, additionalRecommendationSchema } from "./submitReportTool.js";
|
|
3
3
|
import { TestType } from "../types/TestTypes.js";
|
|
4
|
+
import { AnalyticsService } from "../services/AnalyticsService.js";
|
|
4
5
|
import * as fs from "fs/promises";
|
|
5
6
|
import * as path from "path";
|
|
6
7
|
import * as os from "os";
|
|
@@ -10,6 +11,7 @@ jest.mock("../utils/logger.js", () => ({
|
|
|
10
11
|
jest.mock("../services/AnalyticsService.js", () => ({
|
|
11
12
|
AnalyticsService: { pushMCPToolEvent: jest.fn().mockResolvedValue(undefined) },
|
|
12
13
|
}));
|
|
14
|
+
const pushMCPToolEventMock = AnalyticsService.pushMCPToolEvent;
|
|
13
15
|
function captureToolHandler() {
|
|
14
16
|
let handler;
|
|
15
17
|
const fakeServer = {
|
|
@@ -290,4 +292,108 @@ describe("registerSubmitReportTool", () => {
|
|
|
290
292
|
expect(result.data.primaryEndpoint).toBeUndefined();
|
|
291
293
|
}
|
|
292
294
|
});
|
|
295
|
+
describe("analytics metrics", () => {
|
|
296
|
+
beforeEach(() => {
|
|
297
|
+
pushMCPToolEventMock.mockClear();
|
|
298
|
+
});
|
|
299
|
+
function lastAnalyticsParams() {
|
|
300
|
+
expect(pushMCPToolEventMock).toHaveBeenCalled();
|
|
301
|
+
const calls = pushMCPToolEventMock.mock.calls;
|
|
302
|
+
return calls[calls.length - 1][2];
|
|
303
|
+
}
|
|
304
|
+
it("emits per-run count metrics derived from the report", async () => {
|
|
305
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
306
|
+
tmpDirs.push(tmpDir);
|
|
307
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
308
|
+
// sampleReportParams: 1 new test, 1 maintenance (Fail→Pass), 2 results
|
|
309
|
+
// (1 Pass + 1 Fail), 1 issue (no severity).
|
|
310
|
+
await handler(sampleReportParams(outputFile));
|
|
311
|
+
const params = lastAnalyticsParams();
|
|
312
|
+
expect(params.testsGenerated).toBe("1");
|
|
313
|
+
expect(params.testsMaintained).toBe("1");
|
|
314
|
+
expect(params.recommendationsCount).toBe("0");
|
|
315
|
+
expect(params.maintenanceRecovered).toBe("1");
|
|
316
|
+
expect(params.testsPassed).toBe("1");
|
|
317
|
+
expect(params.testsFailed).toBe("1");
|
|
318
|
+
expect(params.testsSkipped).toBe("0");
|
|
319
|
+
expect(params.issuesFound).toBe("1");
|
|
320
|
+
});
|
|
321
|
+
it("breaks issues down by severity", async () => {
|
|
322
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
323
|
+
tmpDirs.push(tmpDir);
|
|
324
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
325
|
+
await handler({
|
|
326
|
+
...sampleReportParams(outputFile),
|
|
327
|
+
issuesFound: [
|
|
328
|
+
{ description: "Data corruption on checkout", severity: "critical" },
|
|
329
|
+
{ description: "Wrong total returned", severity: "high" },
|
|
330
|
+
{ description: "Another wrong total", severity: "high" },
|
|
331
|
+
{ description: "Minor gap", severity: "medium" },
|
|
332
|
+
{ description: "Unlabeled issue" },
|
|
333
|
+
],
|
|
334
|
+
});
|
|
335
|
+
const params = lastAnalyticsParams();
|
|
336
|
+
expect(params.issuesFound).toBe("5");
|
|
337
|
+
expect(params.issuesCritical).toBe("1");
|
|
338
|
+
expect(params.issuesHigh).toBe("2");
|
|
339
|
+
expect(params.issuesMedium).toBe("1");
|
|
340
|
+
expect(params.issuesLow).toBe("0");
|
|
341
|
+
});
|
|
342
|
+
it("reports zero metrics for an empty report", async () => {
|
|
343
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
344
|
+
tmpDirs.push(tmpDir);
|
|
345
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
346
|
+
await handler({
|
|
347
|
+
summaryOutputFile: outputFile,
|
|
348
|
+
businessCaseAnalysis: "Config-only change.",
|
|
349
|
+
newTestsCreated: [],
|
|
350
|
+
testMaintenance: [],
|
|
351
|
+
testResults: [],
|
|
352
|
+
issuesFound: [],
|
|
353
|
+
});
|
|
354
|
+
const params = lastAnalyticsParams();
|
|
355
|
+
expect(params.testsGenerated).toBe("0");
|
|
356
|
+
expect(params.testsMaintained).toBe("0");
|
|
357
|
+
expect(params.maintenanceRecovered).toBe("0");
|
|
358
|
+
expect(params.testsPassed).toBe("0");
|
|
359
|
+
expect(params.issuesFound).toBe("0");
|
|
360
|
+
});
|
|
361
|
+
it("testsMaintained counts only change actions, not VERIFY/IGNORE no-ops", async () => {
|
|
362
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
363
|
+
tmpDirs.push(tmpDir);
|
|
364
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
365
|
+
await handler({
|
|
366
|
+
...sampleReportParams(outputFile),
|
|
367
|
+
testMaintenance: [
|
|
368
|
+
// Real change
|
|
369
|
+
{ action: "UPDATE", testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", description: "Patched auth", beforeStatus: "Fail", beforeDetails: "401 (1.5s)", afterStatus: "Pass", afterDetails: "passed (2.3s)" },
|
|
370
|
+
{ action: "REGENERATE", testType: TestType.SMOKE, endpoint: "GET /api/v1/orders", fileName: "orders_smoke_test.py", description: "Rewrote for new shape", beforeStatus: "Fail", beforeDetails: "shape mismatch", afterStatus: "Pass", afterDetails: "passed (1.0s)" },
|
|
371
|
+
// No-ops — assessed but not modified
|
|
372
|
+
{ action: "IGNORE", testType: TestType.SMOKE, endpoint: "GET /api/v1/reviews", fileName: "reviews_smoke_test.py", description: "No action required", beforeStatus: "Pass", beforeDetails: "drift score 0", afterStatus: "Skipped", afterDetails: "IGNORE: endpoint not in PR" },
|
|
373
|
+
{ action: "VERIFY", testType: TestType.SMOKE, endpoint: "GET /api/v1/coupons", fileName: "coupons_smoke_test.py", description: "Verified still valid", beforeStatus: "Pass", beforeDetails: "minor drift", afterStatus: "Skipped", afterDetails: "VERIFY: assertions still hold" },
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
const params = lastAnalyticsParams();
|
|
377
|
+
// 2 change actions (UPDATE + REGENERATE), not 4
|
|
378
|
+
expect(params.testsMaintained).toBe("2");
|
|
379
|
+
// Both changes went Fail→Pass
|
|
380
|
+
expect(params.maintenanceRecovered).toBe("2");
|
|
381
|
+
});
|
|
382
|
+
it("falls back to afterStatus heuristic when action is absent", async () => {
|
|
383
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "submit-report-test-"));
|
|
384
|
+
tmpDirs.push(tmpDir);
|
|
385
|
+
const outputFile = path.join(tmpDir, "report.json");
|
|
386
|
+
await handler({
|
|
387
|
+
...sampleReportParams(outputFile),
|
|
388
|
+
testMaintenance: [
|
|
389
|
+
// No action field — afterStatus !== "Skipped" → counts as a change
|
|
390
|
+
{ testType: TestType.CONTRACT, endpoint: "GET /api/v1/products", fileName: "products_contract_test.py", description: "Patched", beforeStatus: "Fail", beforeDetails: "401", afterStatus: "Pass", afterDetails: "passed" },
|
|
391
|
+
// No action field — afterStatus === "Skipped" → treated as no-op
|
|
392
|
+
{ testType: TestType.SMOKE, endpoint: "GET /api/v1/reviews", fileName: "reviews_smoke_test.py", description: "No action required", beforeStatus: "Pass", beforeDetails: "drift 0", afterStatus: "Skipped", afterDetails: "not in PR" },
|
|
393
|
+
],
|
|
394
|
+
});
|
|
395
|
+
const params = lastAnalyticsParams();
|
|
396
|
+
expect(params.testsMaintained).toBe("1");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
293
399
|
});
|
package/build/utils/telemetry.js
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* "testbot" when running as part of the GitHub Action test bot,
|
|
4
4
|
* "mcp" for regular IDE/MCP usage.
|
|
5
5
|
*/
|
|
6
|
+
import { execFile } from "child_process";
|
|
7
|
+
import { promisify } from "util";
|
|
6
8
|
import { isTestbotEnabled } from "./featureFlags.js";
|
|
9
|
+
import { logger } from "./logger.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
7
11
|
export function getEntryPoint() {
|
|
8
12
|
return isTestbotEnabled() ? "testbot" : "mcp";
|
|
9
13
|
}
|
|
@@ -22,3 +26,69 @@ export function getCIPlatform() {
|
|
|
22
26
|
return "circleci";
|
|
23
27
|
return undefined;
|
|
24
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Parses "owner/repo" out of a git remote URL. Handles both HTTPS
|
|
31
|
+
* (https://github.com/owner/repo.git) and SSH (git@github.com:owner/repo.git)
|
|
32
|
+
* forms, with or without the trailing ".git". Returns undefined if no match.
|
|
33
|
+
*/
|
|
34
|
+
export function parseRepositoryFromRemoteUrl(remoteUrl) {
|
|
35
|
+
const match = remoteUrl.trim().match(/[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
36
|
+
if (!match)
|
|
37
|
+
return undefined;
|
|
38
|
+
const [, owner, repo] = match;
|
|
39
|
+
return { repository: `${owner}/${repo}`, repositoryOwner: owner };
|
|
40
|
+
}
|
|
41
|
+
// Memoize the local git lookup per working directory so we shell out at most
|
|
42
|
+
// once per cwd for the lifetime of the MCP server process. The value is the
|
|
43
|
+
// resolved RepositoryInfo (possibly empty when not a git repo / no remote).
|
|
44
|
+
const localRepoInfoCache = new Map();
|
|
45
|
+
async function getLocalRepositoryInfo(cwd) {
|
|
46
|
+
const cached = localRepoInfoCache.get(cwd);
|
|
47
|
+
if (cached)
|
|
48
|
+
return cached;
|
|
49
|
+
const lookup = (async () => {
|
|
50
|
+
try {
|
|
51
|
+
const { stdout } = await execFileAsync("git", ["-C", cwd, "remote", "get-url", "origin"], { timeout: 5_000 });
|
|
52
|
+
return parseRepositoryFromRemoteUrl(stdout) ?? {};
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
// Not a git repo, no "origin" remote, or git not installed — attribute
|
|
56
|
+
// nothing rather than failing the telemetry call.
|
|
57
|
+
logger.debug("Could not resolve local git repository for telemetry", {
|
|
58
|
+
cwd,
|
|
59
|
+
error: error instanceof Error ? error.message : String(error),
|
|
60
|
+
});
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
})();
|
|
64
|
+
localRepoInfoCache.set(cwd, lookup);
|
|
65
|
+
return lookup;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Resolves source-repository attribution for telemetry, segmenting usage per
|
|
69
|
+
* customer/repo.
|
|
70
|
+
*
|
|
71
|
+
* - In GitHub Actions (testbot), reads GITHUB_REPOSITORY / GITHUB_REPOSITORY_OWNER
|
|
72
|
+
* which the MCP subprocess inherits — fast, no shelling out.
|
|
73
|
+
* - In local/IDE usage, derives "owner/repo" from `git remote get-url origin`.
|
|
74
|
+
* The lookup directory is the caller-supplied `cwd` (a tool's repositoryPath
|
|
75
|
+
* or workspacePath) when available, since the MCP server's own process.cwd()
|
|
76
|
+
* is set by the IDE and is not necessarily the user's repo. Falls back to
|
|
77
|
+
* process.cwd(). Cached per directory so git is invoked at most once each.
|
|
78
|
+
*
|
|
79
|
+
* Returns an empty object when attribution can't be determined (e.g. not a git
|
|
80
|
+
* repo, no origin remote).
|
|
81
|
+
*/
|
|
82
|
+
export async function getRepositoryInfo(cwd) {
|
|
83
|
+
if (process.env.GITHUB_ACTIONS === "true") {
|
|
84
|
+
const info = {};
|
|
85
|
+
if (process.env.GITHUB_REPOSITORY) {
|
|
86
|
+
info.repository = process.env.GITHUB_REPOSITORY;
|
|
87
|
+
}
|
|
88
|
+
if (process.env.GITHUB_REPOSITORY_OWNER) {
|
|
89
|
+
info.repositoryOwner = process.env.GITHUB_REPOSITORY_OWNER;
|
|
90
|
+
}
|
|
91
|
+
return info;
|
|
92
|
+
}
|
|
93
|
+
return getLocalRepositoryInfo(cwd || process.cwd());
|
|
94
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { getRepositoryInfo, parseRepositoryFromRemoteUrl } from "./telemetry.js";
|
|
2
|
+
describe("parseRepositoryFromRemoteUrl", () => {
|
|
3
|
+
it("parses an HTTPS remote with .git suffix", () => {
|
|
4
|
+
expect(parseRepositoryFromRemoteUrl("https://github.com/acme-corp/their-api.git")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
|
|
5
|
+
});
|
|
6
|
+
it("parses an HTTPS remote without .git suffix", () => {
|
|
7
|
+
expect(parseRepositoryFromRemoteUrl("https://github.com/acme-corp/their-api")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
|
|
8
|
+
});
|
|
9
|
+
it("parses an SSH remote", () => {
|
|
10
|
+
expect(parseRepositoryFromRemoteUrl("git@github.com:acme-corp/their-api.git")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
|
|
11
|
+
});
|
|
12
|
+
it("trims trailing whitespace/newline from git output", () => {
|
|
13
|
+
expect(parseRepositoryFromRemoteUrl("git@github.com:acme-corp/their-api.git\n")).toEqual({ repository: "acme-corp/their-api", repositoryOwner: "acme-corp" });
|
|
14
|
+
});
|
|
15
|
+
it("returns undefined for an unparseable URL", () => {
|
|
16
|
+
expect(parseRepositoryFromRemoteUrl("not-a-remote")).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe("getRepositoryInfo", () => {
|
|
20
|
+
const originalEnv = { ...process.env };
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
process.env = { ...originalEnv };
|
|
23
|
+
});
|
|
24
|
+
describe("in GitHub Actions", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
process.env.GITHUB_ACTIONS = "true";
|
|
27
|
+
});
|
|
28
|
+
it("returns repository and owner from env vars", async () => {
|
|
29
|
+
process.env.GITHUB_REPOSITORY = "acme-corp/their-api";
|
|
30
|
+
process.env.GITHUB_REPOSITORY_OWNER = "acme-corp";
|
|
31
|
+
await expect(getRepositoryInfo()).resolves.toEqual({
|
|
32
|
+
repository: "acme-corp/their-api",
|
|
33
|
+
repositoryOwner: "acme-corp",
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
it("includes only the fields that are present", async () => {
|
|
37
|
+
process.env.GITHUB_REPOSITORY = "acme-corp/their-api";
|
|
38
|
+
delete process.env.GITHUB_REPOSITORY_OWNER;
|
|
39
|
+
await expect(getRepositoryInfo()).resolves.toEqual({
|
|
40
|
+
repository: "acme-corp/their-api",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("local / IDE", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
delete process.env.GITHUB_ACTIONS;
|
|
47
|
+
});
|
|
48
|
+
it("derives repository info from the local git remote", async () => {
|
|
49
|
+
// The test suite itself runs inside the mcp git repo (origin = letsramp/mcp),
|
|
50
|
+
// so a local lookup should resolve owner=letsramp, repo=mcp.
|
|
51
|
+
const info = await getRepositoryInfo();
|
|
52
|
+
expect(info.repositoryOwner).toBe("letsramp");
|
|
53
|
+
expect(info.repository).toBe("letsramp/mcp");
|
|
54
|
+
});
|
|
55
|
+
it("uses the caller-supplied cwd when provided", async () => {
|
|
56
|
+
// Passing the repo path explicitly (as tools do via repositoryPath)
|
|
57
|
+
// resolves the same remote even if process.cwd() differs.
|
|
58
|
+
const info = await getRepositoryInfo(process.cwd());
|
|
59
|
+
expect(info.repository).toBe("letsramp/mcp");
|
|
60
|
+
});
|
|
61
|
+
it("ignores GITHUB_REPOSITORY env when not in GitHub Actions", async () => {
|
|
62
|
+
// A stray env var must not be trusted outside Actions; the git remote is
|
|
63
|
+
// the source of truth locally.
|
|
64
|
+
process.env.GITHUB_REPOSITORY = "spoofed/repo";
|
|
65
|
+
process.env.GITHUB_REPOSITORY_OWNER = "spoofed";
|
|
66
|
+
const info = await getRepositoryInfo();
|
|
67
|
+
expect(info.repository).not.toBe("spoofed/repo");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|