This is page 3 of 4. Use http://codebase.md/circleci-public/mcp-server-circleci?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .circleci
│ └── config.yml
├── .dockerignore
├── .github
│ ├── CODEOWNERS
│ ├── ISSUE_TEMPLATE
│ │ ├── BUG.yml
│ │ └── FEATURE_REQUEST.yml
│ └── PULL_REQUEST_TEMPLATE
│ └── PULL_REQUEST.md
├── .gitignore
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile
├── eslint.config.js
├── LICENSE
├── package.json
├── pnpm-lock.yaml
├── README.md
├── renovate.json
├── scripts
│ └── create-tool.js
├── smithery.yaml
├── src
│ ├── circleci-tools.ts
│ ├── clients
│ │ ├── circleci
│ │ │ ├── configValidate.ts
│ │ │ ├── deploys.ts
│ │ │ ├── httpClient.test.ts
│ │ │ ├── httpClient.ts
│ │ │ ├── index.ts
│ │ │ ├── insights.ts
│ │ │ ├── jobs.ts
│ │ │ ├── jobsV1.ts
│ │ │ ├── pipelines.ts
│ │ │ ├── projects.ts
│ │ │ ├── tests.ts
│ │ │ ├── usage.ts
│ │ │ └── workflows.ts
│ │ ├── circleci-private
│ │ │ ├── index.ts
│ │ │ ├── jobsPrivate.ts
│ │ │ └── me.ts
│ │ ├── circlet
│ │ │ ├── circlet.ts
│ │ │ └── index.ts
│ │ ├── client.ts
│ │ └── schemas.ts
│ ├── index.ts
│ ├── lib
│ │ ├── flaky-tests
│ │ │ └── getFlakyTests.ts
│ │ ├── getWorkflowIdFromURL.test.ts
│ │ ├── getWorkflowIdFromURL.ts
│ │ ├── latest-pipeline
│ │ │ ├── formatLatestPipelineStatus.ts
│ │ │ └── getLatestPipelineWorkflows.ts
│ │ ├── mcpErrorOutput.test.ts
│ │ ├── mcpErrorOutput.ts
│ │ ├── mcpResponse.test.ts
│ │ ├── mcpResponse.ts
│ │ ├── outputTextTruncated.test.ts
│ │ ├── outputTextTruncated.ts
│ │ ├── pipeline-job-logs
│ │ │ ├── getJobLogs.ts
│ │ │ └── getPipelineJobLogs.ts
│ │ ├── pipeline-job-tests
│ │ │ ├── formatJobTests.ts
│ │ │ └── getJobTests.ts
│ │ ├── project-detection
│ │ │ ├── index.test.ts
│ │ │ ├── index.ts
│ │ │ └── vcsTool.ts
│ │ ├── rateLimitedRequests
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ └── usage-api
│ │ ├── findUnderusedResourceClasses.test.ts
│ │ ├── findUnderusedResourceClasses.ts
│ │ ├── getUsageApiData.test.ts
│ │ ├── getUsageApiData.ts
│ │ └── parseDateTimeString.ts
│ ├── tools
│ │ ├── analyzeDiff
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── configHelper
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── createPromptTemplate
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── downloadUsageApiData
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── findUnderusedResourceClasses
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── getBuildFailureLogs
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── getFlakyTests
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── getJobTestResults
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── getLatestPipelineStatus
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── listComponentVersions
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── listFollowedProjects
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── recommendPromptTemplateTests
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── rerunWorkflow
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── runEvaluationTests
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── runPipeline
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ ├── runRollbackPipeline
│ │ │ ├── handler.test.ts
│ │ │ ├── handler.ts
│ │ │ ├── inputSchema.ts
│ │ │ └── tool.ts
│ │ └── shared
│ │ └── constants.ts
│ └── transports
│ ├── stdio.ts
│ └── unified.ts
├── tsconfig.json
├── tsconfig.test.json
└── vitest.config.js
```
# Files
--------------------------------------------------------------------------------
/src/lib/usage-api/findUnderusedResourceClasses.ts:
--------------------------------------------------------------------------------
```typescript
1 | import fs from 'fs';
2 | import path from 'path';
3 | import { parse } from 'csv-parse/sync';
4 |
5 | function normalizeHeader(header: string): string {
6 | return header.trim().toLowerCase().replace(/\s+/g, '_');
7 | }
8 |
9 | export function readAndParseCSV(csvFilePath: string): any[] {
10 | if (!csvFilePath) {
11 | throw new Error('csvFilePath is required');
12 | }
13 | let csvContent: string;
14 | try {
15 | csvContent = fs.readFileSync(path.resolve(csvFilePath), 'utf8');
16 | } catch (e: any) {
17 | throw new Error(`Could not read CSV file at ${csvFilePath}.\n${e?.stack || e}`);
18 | }
19 | try {
20 | return parse(csvContent, {
21 | columns: (headers: string[]) => headers.map(normalizeHeader),
22 | skip_empty_lines: true,
23 | relax_column_count: true,
24 | skip_records_with_error: true,
25 | });
26 | } catch (e: any) {
27 | throw new Error(`Failed to parse CSV.\n${e?.stack || e}`);
28 | }
29 | }
30 |
31 | export function validateCSVColumns(records: any[]): void {
32 | const requiredCols = [
33 | 'project_name',
34 | 'workflow_name',
35 | 'job_name',
36 | 'resource_class',
37 | 'median_cpu_utilization_pct',
38 | 'max_cpu_utilization_pct',
39 | 'median_ram_utilization_pct',
40 | 'max_ram_utilization_pct',
41 | ];
42 | const first = records[0];
43 | if (!first || !requiredCols.every((col) => col in first)) {
44 | throw new Error('CSV is missing required columns. Required: project_name, workflow_name, job_name, resource_class, median_cpu_utilization_pct, max_cpu_utilization_pct, median_ram_utilization_pct, max_ram_utilization_pct');
45 | }
46 | }
47 |
48 | export function groupRecordsByJob(records: any[]): Map<string, any[]> {
49 | const groupMap = new Map<string, any[]>();
50 | for (const row of records) {
51 | const key = [row.project_name, row.workflow_name, row.job_name, row.resource_class].join('|||');
52 | if (!groupMap.has(key)) {
53 | groupMap.set(key, []);
54 | }
55 | groupMap.get(key)?.push(row);
56 | }
57 | return groupMap;
58 | }
59 |
60 | const avg = (arr: number[]) => arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
61 | const sum = (arr: number[]) => arr.reduce((a, b) => a + b, 0);
62 |
63 | function calculateAverages(group: any[]): { avgCpu: number; maxCpu: number; avgRam: number; maxRam: number; totalComputeCredits: number, hasData: boolean } {
64 |
65 | const medianCpuArr = group.map((r: any) => parseFloat(r.median_cpu_utilization_pct)).filter(isFinite);
66 | const maxCpuArr = group.map((r: any) => parseFloat(r.max_cpu_utilization_pct)).filter(isFinite);
67 | const medianRamArr = group.map((r: any) => parseFloat(r.median_ram_utilization_pct)).filter(isFinite);
68 | const maxRamArr = group.map((r: any) => parseFloat(r.max_ram_utilization_pct)).filter(isFinite);
69 | const computeCreditsArr = group.map((r: any) => parseFloat(r.compute_credits)).filter(isFinite);
70 |
71 | if (!medianCpuArr.length || !maxCpuArr.length || !medianRamArr.length || !maxRamArr.length) {
72 | return { avgCpu: 0, maxCpu: 0, avgRam: 0, maxRam: 0, totalComputeCredits: 0, hasData: false };
73 | }
74 |
75 | return {
76 | avgCpu: avg(medianCpuArr),
77 | maxCpu: avg(maxCpuArr),
78 | avgRam: avg(medianRamArr),
79 | maxRam: avg(maxRamArr),
80 | totalComputeCredits: sum(computeCreditsArr),
81 | hasData: true
82 | };
83 | }
84 |
85 | export function analyzeJobGroups(groupedRecords: Map<string, any[]>, threshold: number): any[] {
86 | const underused: any[] = [];
87 | for (const [key, group] of groupedRecords.entries()) {
88 | const [projectName, workflowName, jobName, resourceClass] = key.split('|||');
89 |
90 | const { avgCpu, maxCpu, avgRam, maxRam, totalComputeCredits, hasData } = calculateAverages(group);
91 |
92 | if(!hasData) continue;
93 |
94 | if (
95 | avgCpu < threshold &&
96 | maxCpu < threshold &&
97 | avgRam < threshold &&
98 | maxRam < threshold
99 | ) {
100 | underused.push({
101 | projectName,
102 | workflowName,
103 | job: jobName,
104 | resourceClass,
105 | avgCpu: +avgCpu.toFixed(2),
106 | maxCpu: +maxCpu.toFixed(2),
107 | avgRam: +avgRam.toFixed(2),
108 | maxRam: +maxRam.toFixed(2),
109 | count: group.length,
110 | totalComputeCredits: +totalComputeCredits.toFixed(2),
111 | });
112 | }
113 | }
114 | return underused;
115 | }
116 |
117 | export function generateReport(underusedJobs: any[], threshold: number): string {
118 | if (underusedJobs.length === 0) {
119 | return `No underused resource classes found (threshold: ${threshold}%).`;
120 | }
121 |
122 | let report = `Underused resource classes (threshold: ${threshold}%):\n\n`;
123 | const grouped: Record<string, Record<string, any[]>> = {};
124 | for (const u of underusedJobs) {
125 | if (!grouped[u.projectName]) grouped[u.projectName] = {};
126 | if (!grouped[u.projectName][u.workflowName]) grouped[u.projectName][u.workflowName] = [];
127 | grouped[u.projectName][u.workflowName].push(u);
128 | }
129 |
130 | for (const project of Object.keys(grouped).sort()) {
131 | report += `## Project: ${project}\n`;
132 | for (const workflow of Object.keys(grouped[project]).sort()) {
133 | report += `### Workflow: ${workflow}\n`;
134 | report += 'Job Name | Resource Class | #Runs | Total Compute Credits | Avg CPU% | Max CPU% | Avg RAM% | Max RAM%\n';
135 | report += '|--------|---------------|-------|----------------------|----------|----------|----------|----------|\n';
136 | const sortedJobs = grouped[project][workflow].sort((a,b) => a.job.localeCompare(b.job));
137 | for (const u of sortedJobs) {
138 | report += `${u.job} | ${u.resourceClass} | ${u.count} | ${u.totalComputeCredits} | ${u.avgCpu} | ${u.maxCpu} | ${u.avgRam} | ${u.maxRam}\n`;
139 | }
140 | report += '\n';
141 | }
142 | report += '\n';
143 | }
144 | return report;
145 | }
146 |
147 | export async function findUnderusedResourceClassesFromCSV({ csvFilePath, threshold = 40 }: { csvFilePath: string, threshold?: number }) {
148 | const records = readAndParseCSV(csvFilePath);
149 | validateCSVColumns(records);
150 | const groupedRecords = groupRecordsByJob(records);
151 | const underusedJobs = analyzeJobGroups(groupedRecords, threshold);
152 | const report = generateReport(underusedJobs, threshold);
153 |
154 | return { report, underused: underusedJobs };
155 | }
156 |
```
--------------------------------------------------------------------------------
/src/lib/usage-api/getUsageApiData.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import fs from 'fs';
3 | import { gzipSync } from 'zlib';
4 | import * as clientModule from '../../clients/client.js';
5 | import {
6 | downloadAndSaveUsageData,
7 | handleExistingJob,
8 | startNewUsageExportJob,
9 | getUsageApiData,
10 | } from './getUsageApiData.js';
11 |
12 | vi.mock('fs');
13 | vi.mock('../../clients/client.js');
14 |
15 | globalThis.fetch = vi.fn();
16 |
17 | describe('Usage API Data Fetching', () => {
18 | const ORG_ID = 'test-org-id';
19 | const START_DATE = '2024-08-01T00:00:00Z';
20 | const END_DATE = '2024-08-31T23:59:59Z';
21 | const JOB_ID = 'test-job-id';
22 | const DOWNLOAD_URL = 'https://fake-url.com/usage.csv.gz';
23 | const OUTPUT_DIR = '/tmp/usage-data';
24 | const MOCK_CSV_CONTENT = 'col1,col2\nval1,val2';
25 | const MOCK_GZIPPED_CSV = gzipSync(Buffer.from(MOCK_CSV_CONTENT));
26 |
27 | let mockCircleCIClient: any;
28 | let startUsageExportJobMock: any;
29 | let getUsageExportJobStatusMock: any;
30 |
31 | beforeEach(() => {
32 | vi.clearAllMocks();
33 |
34 | startUsageExportJobMock = vi.fn().mockResolvedValue({ usage_export_job_id: JOB_ID });
35 | getUsageExportJobStatusMock = vi.fn();
36 |
37 | mockCircleCIClient = {
38 | usage: {
39 | startUsageExportJob: startUsageExportJobMock,
40 | getUsageExportJobStatus: getUsageExportJobStatusMock,
41 | },
42 | };
43 |
44 | (clientModule.getCircleCIClient as any).mockReturnValue(mockCircleCIClient);
45 | (fetch as any).mockReset();
46 | (fs.existsSync as any).mockReturnValue(true);
47 | });
48 |
49 | describe('downloadAndSaveUsageData', () => {
50 | it('should download, decompress, and save the CSV file correctly', async () => {
51 | (fetch as any).mockResolvedValue({
52 | ok: true,
53 | arrayBuffer: () => Promise.resolve(MOCK_GZIPPED_CSV),
54 | });
55 |
56 | const result = await downloadAndSaveUsageData(DOWNLOAD_URL, OUTPUT_DIR, { startDate: START_DATE, endDate: END_DATE });
57 |
58 | expect(fetch).toHaveBeenCalledWith(DOWNLOAD_URL);
59 | expect(fs.writeFileSync).toHaveBeenCalledWith(
60 | `${OUTPUT_DIR}/usage-data-2024-08-01_2024-08-31.csv`,
61 | Buffer.from(MOCK_CSV_CONTENT)
62 | );
63 | expect(result.content[0].text).toContain('Usage data CSV downloaded and saved to');
64 | });
65 |
66 | it('should create output directory if it does not exist', async () => {
67 | (fs.existsSync as any).mockReturnValue(false);
68 | (fetch as any).mockResolvedValue({
69 | ok: true,
70 | arrayBuffer: () => Promise.resolve(MOCK_GZIPPED_CSV),
71 | });
72 |
73 | await downloadAndSaveUsageData(DOWNLOAD_URL, OUTPUT_DIR, { startDate: START_DATE, endDate: END_DATE });
74 |
75 | expect(fs.mkdirSync).toHaveBeenCalledWith(OUTPUT_DIR, { recursive: true });
76 | });
77 |
78 | it('should handle fetch failure gracefully', async () => {
79 | (fetch as any).mockResolvedValue({
80 | ok: false,
81 | status: 500,
82 | statusText: 'Server Error',
83 | text: async () => 'Internal Server Error'
84 | });
85 |
86 | const result = await downloadAndSaveUsageData(DOWNLOAD_URL, OUTPUT_DIR, { startDate: START_DATE, endDate: END_DATE });
87 | expect(result.content[0].text).toContain('ERROR: Failed to download CSV');
88 | });
89 | });
90 |
91 | describe('handleExistingJob', () => {
92 | it('should return a "processing" message for pending jobs', async () => {
93 | getUsageExportJobStatusMock.mockResolvedValue({ state: 'processing' });
94 | const result = await handleExistingJob({ client: mockCircleCIClient, orgId: ORG_ID, jobId: JOB_ID, outputDir: OUTPUT_DIR, startDate: START_DATE, endDate: END_DATE });
95 | expect(result.content[0].text).toContain('still processing');
96 | });
97 |
98 | it('should return an error for a failed job status fetch', async () => {
99 | getUsageExportJobStatusMock.mockRejectedValue(new Error('API Error'));
100 | const result = await handleExistingJob({ client: mockCircleCIClient, orgId: ORG_ID, jobId: JOB_ID, outputDir: OUTPUT_DIR, startDate: START_DATE, endDate: END_DATE });
101 | expect(result.content[0].text).toContain('ERROR: Could not fetch job status');
102 | });
103 |
104 | it('should return an error for an unknown job state', async () => {
105 | getUsageExportJobStatusMock.mockResolvedValue({ state: 'exploded' });
106 | const result = await handleExistingJob({ client: mockCircleCIClient, orgId: ORG_ID, jobId: JOB_ID, outputDir: OUTPUT_DIR, startDate: START_DATE, endDate: END_DATE });
107 | expect(result.content[0].text).toContain('ERROR: Unknown job state: exploded');
108 | });
109 | });
110 |
111 | describe('startNewUsageExportJob', () => {
112 | it('should return a "started new job" message on success', async () => {
113 | const result = await startNewUsageExportJob({ client: mockCircleCIClient, orgId: ORG_ID, startDate: START_DATE, endDate: END_DATE });
114 | expect(startUsageExportJobMock).toHaveBeenCalledWith(ORG_ID, START_DATE, END_DATE);
115 | expect(result.content[0].text).toContain('Started a new usage export job');
116 | expect((result as any).jobId).toBe(JOB_ID);
117 | });
118 |
119 | it('should return an error if job creation fails', async () => {
120 | startUsageExportJobMock.mockRejectedValue(new Error('Creation Failed'));
121 | const result = await startNewUsageExportJob({ client: mockCircleCIClient, orgId: ORG_ID, startDate: START_DATE, endDate: END_DATE });
122 | expect(result.content[0].text).toContain('ERROR: Failed to start usage export job');
123 | });
124 | });
125 |
126 | describe('getUsageApiData (main dispatcher)', () => {
127 | it('should call handleExistingJob if a jobId is provided', async () => {
128 | getUsageExportJobStatusMock.mockResolvedValue({ state: 'pending' });
129 | await getUsageApiData({ orgId: ORG_ID, startDate: START_DATE, endDate: END_DATE, jobId: JOB_ID, outputDir: OUTPUT_DIR });
130 | expect(getUsageExportJobStatusMock).toHaveBeenCalledWith(ORG_ID, JOB_ID);
131 | expect(startUsageExportJobMock).not.toHaveBeenCalled();
132 | });
133 |
134 | it('should call startNewUsageExportJob if no jobId is provided', async () => {
135 | await getUsageApiData({ orgId: ORG_ID, startDate: START_DATE, endDate: END_DATE, outputDir: OUTPUT_DIR });
136 | expect(startUsageExportJobMock).toHaveBeenCalledWith(ORG_ID, START_DATE, END_DATE);
137 | expect(getUsageExportJobStatusMock).not.toHaveBeenCalled();
138 | });
139 | });
140 | });
```
--------------------------------------------------------------------------------
/src/lib/project-detection/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { getCircleCIPrivateClient } from '../../clients/client.js';
2 | import { getVCSFromHost, vcses } from './vcsTool.js';
3 | import gitUrlParse from 'parse-github-url';
4 |
5 | /**
6 | * Identify the project slug from the git remote URL
7 | * @param {string} gitRemoteURL - eg: https://github.com/organization/project.git
8 | * @returns {string} project slug - eg: gh/organization/project
9 | */
10 | export const identifyProjectSlug = async ({
11 | gitRemoteURL,
12 | }: {
13 | gitRemoteURL: string;
14 | }) => {
15 | const cciPrivateClients = getCircleCIPrivateClient();
16 |
17 | const parsedGitURL = gitUrlParse(gitRemoteURL);
18 | if (!parsedGitURL?.host) {
19 | return undefined;
20 | }
21 |
22 | const vcs = getVCSFromHost(parsedGitURL.host);
23 | if (!vcs) {
24 | throw new Error(`VCS with host ${parsedGitURL.host} is not handled`);
25 | }
26 |
27 | const { projects: followedProjects } =
28 | await cciPrivateClients.me.getFollowedProjects();
29 | if (!followedProjects) {
30 | throw new Error('Failed to get followed projects');
31 | }
32 |
33 | const project = followedProjects.find(
34 | (followedProject) =>
35 | followedProject.name === parsedGitURL.name &&
36 | followedProject.vcs_type === vcs.name,
37 | );
38 |
39 | return project?.slug;
40 | };
41 |
42 | /**
43 | * Get the pipeline number from the URL
44 | * @param {string} url - CircleCI pipeline URL
45 | * @returns {number} The pipeline number
46 | * @example
47 | * // Standard pipeline URL
48 | * getPipelineNumberFromURL('https://app.circleci.com/pipelines/gh/organization/project/2/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv')
49 | * // returns 2
50 | *
51 | * @example
52 | * // Pipeline URL with complex project path
53 | * getPipelineNumberFromURL('https://app.circleci.com/pipelines/circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ/123/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv')
54 | * // returns 123
55 | *
56 | * @example
57 | * // URL without pipelines segment. This is a legacy job URL format.
58 | * getPipelineNumberFromURL('https://circleci.com/gh/organization/project/2')
59 | * // returns undefined
60 | */
61 | export const getPipelineNumberFromURL = (url: string): number | undefined => {
62 | const parts = url.split('/');
63 | const pipelineIndex = parts.indexOf('pipelines');
64 | if (pipelineIndex === -1) {
65 | return undefined;
66 | }
67 | const pipelineNumber = parts[pipelineIndex + 4];
68 |
69 | if (!pipelineNumber) {
70 | return undefined;
71 | }
72 | const parsedNumber = Number(pipelineNumber);
73 | if (isNaN(parsedNumber)) {
74 | throw new Error('Pipeline number in URL is not a valid number');
75 | }
76 | return parsedNumber;
77 | };
78 |
79 | /**
80 | * Get the job number from the URL
81 | * @param {string} url - CircleCI job URL
82 | * @returns {number | undefined} The job number if present in the URL
83 | * @example
84 | * // Job URL
85 | * getJobNumberFromURL('https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv/jobs/456')
86 | * // returns 456
87 | *
88 | * @example
89 | * // Legacy job URL format
90 | * getJobNumberFromURL('https://circleci.com/gh/organization/project/123')
91 | * // returns 123
92 | *
93 | * @example
94 | * // URL without job number
95 | * getJobNumberFromURL('https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv')
96 | * // returns undefined
97 | */
98 | export const getJobNumberFromURL = (url: string): number | undefined => {
99 | const parts = url.split('/');
100 | const jobsIndex = parts.indexOf('jobs');
101 | const pipelineIndex = parts.indexOf('pipelines');
102 |
103 | // Handle legacy URL format (e.g. https://circleci.com/gh/organization/project/123)
104 | if (jobsIndex === -1 && pipelineIndex === -1) {
105 | const jobNumber = parts[parts.length - 1];
106 | if (!jobNumber) {
107 | return undefined;
108 | }
109 | const parsedNumber = Number(jobNumber);
110 | if (isNaN(parsedNumber)) {
111 | throw new Error('Job number in URL is not a valid number');
112 | }
113 | return parsedNumber;
114 | }
115 |
116 | if (jobsIndex === -1) {
117 | return undefined;
118 | }
119 |
120 | // Handle modern URL format with /jobs/ segment
121 | if (jobsIndex + 1 >= parts.length) {
122 | return undefined;
123 | }
124 |
125 | const jobNumber = parts[jobsIndex + 1];
126 | if (!jobNumber) {
127 | return undefined;
128 | }
129 |
130 | const parsedNumber = Number(jobNumber);
131 | if (isNaN(parsedNumber)) {
132 | throw new Error('Job number in URL is not a valid number');
133 | }
134 |
135 | return parsedNumber;
136 | };
137 |
138 | /**
139 | * Get the project slug from the URL
140 | * @param {string} url - CircleCI pipeline or project URL
141 | * @returns {string} project slug - eg: gh/organization/project
142 | * @example
143 | * // Pipeline URL with workflow
144 | * getProjectSlugFromURL('https://app.circleci.com/pipelines/gh/organization/project/2/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv')
145 | * // returns 'gh/organization/project'
146 | *
147 | * @example
148 | * // Simple project URL with query parameters
149 | * getProjectSlugFromURL('https://app.circleci.com/pipelines/gh/organization/project?branch=main')
150 | * // returns 'gh/organization/project'
151 | */
152 | export const getProjectSlugFromURL = (url: string) => {
153 | const urlWithoutQuery = url.split('?')[0];
154 | const parts = urlWithoutQuery.split('/');
155 |
156 | let startIndex = -1;
157 | const pipelineIndex = parts.indexOf('pipelines');
158 | if (pipelineIndex !== -1) {
159 | startIndex = pipelineIndex + 1;
160 | } else {
161 | for (const vcs of vcses) {
162 | const shortIndex = parts.indexOf(vcs.short);
163 | const nameIndex = parts.indexOf(vcs.name);
164 | if (shortIndex !== -1) {
165 | startIndex = shortIndex;
166 | break;
167 | }
168 | if (nameIndex !== -1) {
169 | startIndex = nameIndex;
170 | break;
171 | }
172 | }
173 | }
174 |
175 | if (startIndex === -1) {
176 | throw new Error(
177 | 'Error getting project slug from URL: Invalid CircleCI URL format',
178 | );
179 | }
180 |
181 | const [vcs, org, project] = parts.slice(
182 | startIndex,
183 | startIndex + 3, // vcs/org/project
184 | );
185 | if (!vcs || !org || !project) {
186 | throw new Error('Unable to extract project information from URL');
187 | }
188 |
189 | return `${vcs}/${org}/${project}`;
190 | };
191 |
192 | /**
193 | * Get the branch name from the URL's query parameters
194 | * @param {string} url - CircleCI pipeline URL
195 | * @returns {string | undefined} The branch name if present in the URL
196 | * @example
197 | * // URL with branch parameter
198 | * getBranchFromURL('https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch')
199 | * // returns 'feature-branch'
200 | *
201 | * @example
202 | * // URL without branch parameter
203 | * getBranchFromURL('https://app.circleci.com/pipelines/gh/organization/project')
204 | * // returns undefined
205 | */
206 | export const getBranchFromURL = (url: string): string | undefined => {
207 | try {
208 | const urlObj = new URL(url);
209 | return urlObj.searchParams.get('branch') || undefined;
210 | } catch {
211 | throw new Error(
212 | 'Error getting branch from URL: Invalid CircleCI URL format',
213 | );
214 | }
215 | };
216 |
```
--------------------------------------------------------------------------------
/src/tools/runEvaluationTests/handler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js';
2 | import { gzipSync } from 'zlib';
3 | import {
4 | getBranchFromURL,
5 | getProjectSlugFromURL,
6 | identifyProjectSlug,
7 | } from '../../lib/project-detection/index.js';
8 | import { runEvaluationTestsInputSchema } from './inputSchema.js';
9 | import mcpErrorOutput from '../../lib/mcpErrorOutput.js';
10 | import { getCircleCIClient } from '../../clients/client.js';
11 |
12 | export const runEvaluationTests: ToolCallback<{
13 | params: typeof runEvaluationTestsInputSchema;
14 | }> = async (args) => {
15 | const {
16 | workspaceRoot,
17 | gitRemoteURL,
18 | branch,
19 | projectURL,
20 | pipelineChoiceName,
21 | projectSlug: inputProjectSlug,
22 | promptFiles,
23 | } = args.params ?? {};
24 |
25 | let projectSlug: string | undefined;
26 | let branchFromURL: string | undefined;
27 |
28 | if (inputProjectSlug) {
29 | if (!branch) {
30 | return mcpErrorOutput(
31 | 'Branch not provided. When using projectSlug, a branch must also be specified.',
32 | );
33 | }
34 | projectSlug = inputProjectSlug;
35 | } else if (projectURL) {
36 | projectSlug = getProjectSlugFromURL(projectURL);
37 | branchFromURL = getBranchFromURL(projectURL);
38 | } else if (workspaceRoot && gitRemoteURL && branch) {
39 | projectSlug = await identifyProjectSlug({
40 | gitRemoteURL,
41 | });
42 | } else {
43 | return mcpErrorOutput(
44 | 'Missing required inputs. Please provide either: 1) projectSlug with branch, 2) projectURL, or 3) workspaceRoot with gitRemoteURL and branch.',
45 | );
46 | }
47 |
48 | if (!projectSlug) {
49 | return mcpErrorOutput(`
50 | Project not found. Ask the user to provide the inputs user can provide based on the tool description.
51 |
52 | Project slug: ${projectSlug}
53 | Git remote URL: ${gitRemoteURL}
54 | Branch: ${branch}
55 | `);
56 | }
57 | const foundBranch = branchFromURL || branch;
58 | if (!foundBranch) {
59 | return mcpErrorOutput(
60 | 'No branch provided. Try using the current git branch.',
61 | );
62 | }
63 |
64 | if (!promptFiles || promptFiles.length === 0) {
65 | return mcpErrorOutput(
66 | 'No prompt template files provided. Please ensure you have prompt template files in the ./prompts directory (e.g. <relevant-name>.prompt.yml) and include them in the promptFiles parameter.',
67 | );
68 | }
69 |
70 | const circleci = getCircleCIClient();
71 | const { id: projectId } = await circleci.projects.getProject({
72 | projectSlug,
73 | });
74 | const pipelineDefinitions = await circleci.pipelines.getPipelineDefinitions({
75 | projectId,
76 | });
77 |
78 | const pipelineChoices = [
79 | ...pipelineDefinitions.map((definition) => ({
80 | name: definition.name,
81 | definitionId: definition.id,
82 | })),
83 | ];
84 |
85 | if (pipelineChoices.length === 0) {
86 | return mcpErrorOutput(
87 | 'No pipeline definitions found. Please make sure your project is set up on CircleCI to run pipelines.',
88 | );
89 | }
90 |
91 | const formattedPipelineChoices = pipelineChoices
92 | .map(
93 | (pipeline, index) =>
94 | `${index + 1}. ${pipeline.name} (definitionId: ${pipeline.definitionId})`,
95 | )
96 | .join('\n');
97 |
98 | if (pipelineChoices.length > 1 && !pipelineChoiceName) {
99 | return {
100 | content: [
101 | {
102 | type: 'text',
103 | text: `Multiple pipeline definitions found. Please choose one of the following:\n${formattedPipelineChoices}`,
104 | },
105 | ],
106 | };
107 | }
108 |
109 | const chosenPipeline = pipelineChoiceName
110 | ? pipelineChoices.find((pipeline) => pipeline.name === pipelineChoiceName)
111 | : undefined;
112 |
113 | if (pipelineChoiceName && !chosenPipeline) {
114 | return mcpErrorOutput(
115 | `Pipeline definition with name ${pipelineChoiceName} not found. Please choose one of the following:\n${formattedPipelineChoices}`,
116 | );
117 | }
118 |
119 | const runPipelineDefinitionId =
120 | chosenPipeline?.definitionId || pipelineChoices[0].definitionId;
121 |
122 | // Process each file for compression and encoding
123 | const processedFiles = promptFiles.map((promptFile) => {
124 | const fileExtension = promptFile.fileName.toLowerCase();
125 | let processedPromptFileContent: string;
126 |
127 | if (fileExtension.endsWith('.json')) {
128 | // For JSON files, parse and re-stringify to ensure proper formatting
129 | const json = JSON.parse(promptFile.fileContent);
130 | processedPromptFileContent = JSON.stringify(json, null);
131 | } else if (
132 | fileExtension.endsWith('.yml') ||
133 | fileExtension.endsWith('.yaml')
134 | ) {
135 | // For YAML files, keep as-is
136 | processedPromptFileContent = promptFile.fileContent;
137 | } else {
138 | // Default to treating as text content
139 | processedPromptFileContent = promptFile.fileContent;
140 | }
141 |
142 | // Gzip compress the content and then base64 encode for compact transport
143 | const gzippedContent = gzipSync(processedPromptFileContent);
144 | const base64GzippedContent = gzippedContent.toString('base64');
145 |
146 | return {
147 | fileName: promptFile.fileName,
148 | base64GzippedContent,
149 | };
150 | });
151 |
152 | // Generate file creation commands with conditional logic for parallelism
153 | const fileCreationCommands = processedFiles
154 | .map(
155 | (file, index) =>
156 | ` if [ "$CIRCLE_NODE_INDEX" = "${index}" ]; then
157 | sudo mkdir -p /prompts
158 | echo "${file.base64GzippedContent}" | base64 -d | gzip -d | sudo tee /prompts/${file.fileName} > /dev/null
159 | fi`,
160 | )
161 | .join('\n');
162 |
163 | // Generate individual evaluation commands with conditional logic for parallelism
164 | const evaluationCommands = processedFiles
165 | .map(
166 | (file, index) =>
167 | ` if [ "$CIRCLE_NODE_INDEX" = "${index}" ]; then
168 | python eval.py ${file.fileName}
169 | fi`,
170 | )
171 | .join('\n');
172 |
173 | const configContent = `
174 | version: 2.1
175 |
176 | jobs:
177 | evaluate-prompt-template-tests:
178 | parallelism: ${processedFiles.length}
179 | docker:
180 | - image: cimg/python:3.12.0
181 | steps:
182 | - run: |
183 | curl https://gist.githubusercontent.com/jvincent42/10bf3d2d2899033ae1530cf429ed03f8/raw/acf07002d6bfcfb649c913b01a203af086c1f98d/eval.py > eval.py
184 | echo "deepeval>=3.0.3
185 | openai>=1.84.0
186 | anthropic>=0.54.0
187 | PyYAML>=6.0.2
188 | " > requirements.txt
189 | pip install -r requirements.txt
190 | - run: |
191 | ${fileCreationCommands}
192 | - run: |
193 | ${evaluationCommands}
194 |
195 | workflows:
196 | mcp-run-evaluation-tests:
197 | jobs:
198 | - evaluate-prompt-template-tests
199 | `;
200 |
201 | const runPipelineResponse = await circleci.pipelines.runPipeline({
202 | projectSlug,
203 | branch: foundBranch,
204 | definitionId: runPipelineDefinitionId,
205 | configContent,
206 | });
207 |
208 | return {
209 | content: [
210 | {
211 | type: 'text',
212 | text: `Pipeline run successfully. View it at: https://app.circleci.com/pipelines/${projectSlug}/${runPipelineResponse.number}`,
213 | },
214 | ],
215 | };
216 | };
217 |
```
--------------------------------------------------------------------------------
/src/tools/getFlakyTests/handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { getFlakyTestLogs, getFlakyTestsOutputDirectory } from './handler.js';
3 | import * as projectDetection from '../../lib/project-detection/index.js';
4 | import * as getFlakyTestsModule from '../../lib/flaky-tests/getFlakyTests.js';
5 | import * as formatFlakyTestsModule from '../../lib/flaky-tests/getFlakyTests.js';
6 |
7 | // Mock dependencies
8 | vi.mock('../../lib/project-detection/index.js');
9 | vi.mock('../../lib/flaky-tests/getFlakyTests.js');
10 |
11 | // Define mock functions using vi.hoisted() to make them available everywhere
12 | const { mockWriteFileSync, mockMkdirSync, mockRmSync, mockJoin } = vi.hoisted(
13 | () => ({
14 | mockWriteFileSync: vi.fn(),
15 | mockMkdirSync: vi.fn(),
16 | mockRmSync: vi.fn(),
17 | mockJoin: vi.fn(),
18 | }),
19 | );
20 |
21 | vi.mock('fs', () => ({
22 | writeFileSync: mockWriteFileSync,
23 | mkdirSync: mockMkdirSync,
24 | rmSync: mockRmSync,
25 | }));
26 |
27 | vi.mock('path', () => ({
28 | join: mockJoin,
29 | }));
30 |
31 | describe('getFlakyTestLogs handler', () => {
32 | beforeEach(() => {
33 | vi.resetAllMocks();
34 | delete process.env.FILE_OUTPUT_DIRECTORY;
35 | });
36 |
37 | it('should return a valid MCP error response when no inputs are provided', async () => {
38 | const args = {
39 | params: {},
40 | };
41 |
42 | const controller = new AbortController();
43 | const response = await getFlakyTestLogs(args, {
44 | signal: controller.signal,
45 | });
46 |
47 | expect(response).toHaveProperty('content');
48 | expect(response).toHaveProperty('isError', true);
49 | expect(Array.isArray(response.content)).toBe(true);
50 | expect(response.content[0]).toHaveProperty('type', 'text');
51 | expect(typeof response.content[0].text).toBe('string');
52 | });
53 |
54 | it('should return a valid MCP error response when project is not found', async () => {
55 | vi.spyOn(projectDetection, 'identifyProjectSlug').mockResolvedValue(
56 | undefined,
57 | );
58 |
59 | const args = {
60 | params: {
61 | workspaceRoot: '/workspace',
62 | gitRemoteURL: 'https://github.com/org/repo.git',
63 | },
64 | };
65 |
66 | const controller = new AbortController();
67 | const response = await getFlakyTestLogs(args, {
68 | signal: controller.signal,
69 | });
70 |
71 | expect(response).toHaveProperty('content');
72 | expect(response).toHaveProperty('isError', true);
73 | expect(Array.isArray(response.content)).toBe(true);
74 | expect(response.content[0]).toHaveProperty('type', 'text');
75 | expect(typeof response.content[0].text).toBe('string');
76 | });
77 |
78 | it('should use projectSlug directly when provided', async () => {
79 | vi.spyOn(getFlakyTestsModule, 'default').mockResolvedValue([
80 | {
81 | name: 'flakyTest',
82 | message: 'Test failure message',
83 | run_time: '1.5',
84 | result: 'failure',
85 | classname: 'TestClass',
86 | file: 'path/to/file.js',
87 | },
88 | ]);
89 |
90 | vi.spyOn(formatFlakyTestsModule, 'formatFlakyTests').mockReturnValue({
91 | content: [
92 | {
93 | type: 'text',
94 | text: 'Flaky test output',
95 | },
96 | ],
97 | });
98 |
99 | const args = {
100 | params: {
101 | projectSlug: 'gh/org/repo',
102 | },
103 | };
104 |
105 | const controller = new AbortController();
106 | await getFlakyTestLogs(args, {
107 | signal: controller.signal,
108 | });
109 |
110 | expect(getFlakyTestsModule.default).toHaveBeenCalledWith({
111 | projectSlug: 'gh/org/repo',
112 | });
113 | // Verify that no project detection methods were called
114 | expect(projectDetection.getProjectSlugFromURL).not.toHaveBeenCalled();
115 | expect(projectDetection.identifyProjectSlug).not.toHaveBeenCalled();
116 | });
117 |
118 | it('should return a valid MCP success response with flaky tests', async () => {
119 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
120 | 'gh/org/repo',
121 | );
122 |
123 | vi.spyOn(getFlakyTestsModule, 'default').mockResolvedValue([
124 | {
125 | name: 'flakyTest',
126 | message: 'Test failure message',
127 | run_time: '1.5',
128 | result: 'failure',
129 | classname: 'TestClass',
130 | file: 'path/to/file.js',
131 | },
132 | ]);
133 |
134 | vi.spyOn(formatFlakyTestsModule, 'formatFlakyTests').mockReturnValue({
135 | content: [
136 | {
137 | type: 'text',
138 | text: 'Flaky test output',
139 | },
140 | ],
141 | });
142 |
143 | const args = {
144 | params: {
145 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
146 | },
147 | };
148 |
149 | const controller = new AbortController();
150 | const response = await getFlakyTestLogs(args, {
151 | signal: controller.signal,
152 | });
153 |
154 | expect(response).toHaveProperty('content');
155 | expect(Array.isArray(response.content)).toBe(true);
156 | expect(response.content[0]).toHaveProperty('type', 'text');
157 | expect(typeof response.content[0].text).toBe('string');
158 | });
159 |
160 | it('should write flaky tests to files when FILE_OUTPUT_DIRECTORY is set', async () => {
161 | process.env.FILE_OUTPUT_DIRECTORY = '/tmp/test-output';
162 |
163 | // Mock path.join to return predictable file paths for cross-platform test consistency
164 | // This ensures the same path format regardless of OS (Windows uses \, Unix uses /)
165 | mockJoin.mockImplementation((dir, filename) => `${dir}/${filename}`);
166 |
167 | vi.spyOn(getFlakyTestsModule, 'default').mockResolvedValue([
168 | {
169 | name: 'flakyTest',
170 | message: 'Test failure message',
171 | run_time: '1.5',
172 | result: 'failure',
173 | classname: 'TestClass',
174 | file: 'path/to/file.js',
175 | },
176 | {
177 | name: 'anotherFlakyTest',
178 | message: 'Another test failure',
179 | run_time: '2.1',
180 | result: 'failure',
181 | classname: 'AnotherClass',
182 | file: 'path/to/another.js',
183 | },
184 | ]);
185 |
186 | const args = {
187 | params: {
188 | projectSlug: 'gh/org/repo',
189 | },
190 | };
191 |
192 | const controller = new AbortController();
193 | const response = await getFlakyTestLogs(args, {
194 | signal: controller.signal,
195 | });
196 |
197 | expect(mockMkdirSync).toHaveBeenCalledWith(getFlakyTestsOutputDirectory(), {
198 | recursive: true,
199 | });
200 | expect(mockWriteFileSync).toHaveBeenCalledTimes(3);
201 |
202 | expect(response).toHaveProperty('content');
203 | expect(Array.isArray(response.content)).toBe(true);
204 | expect(response.content[0]).toHaveProperty('type', 'text');
205 | expect(response.content[0].text).toContain(
206 | 'Found 2 flaky tests that need stabilization',
207 | );
208 | expect(response.content[0].text).toContain(getFlakyTestsOutputDirectory());
209 | });
210 |
211 | it('should handle no flaky tests found in file output mode', async () => {
212 | process.env.FILE_OUTPUT_DIRECTORY = '/tmp/test-output';
213 |
214 | vi.spyOn(getFlakyTestsModule, 'default').mockResolvedValue([]);
215 |
216 | const args = {
217 | params: {
218 | projectSlug: 'gh/org/repo',
219 | },
220 | };
221 |
222 | const controller = new AbortController();
223 | const response = await getFlakyTestLogs(args, {
224 | signal: controller.signal,
225 | });
226 |
227 | expect(response).toHaveProperty('content');
228 | expect(response.content[0].text).toBe(
229 | 'No flaky tests found - no files created',
230 | );
231 | });
232 | });
233 |
```
--------------------------------------------------------------------------------
/src/tools/getLatestPipelineStatus/handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { getLatestPipelineStatus } from './handler.js';
3 | import * as projectDetection from '../../lib/project-detection/index.js';
4 | import * as getLatestPipelineWorkflowsModule from '../../lib/latest-pipeline/getLatestPipelineWorkflows.js';
5 | import * as formatLatestPipelineStatusModule from '../../lib/latest-pipeline/formatLatestPipelineStatus.js';
6 | import { McpSuccessResponse } from '../../lib/mcpResponse.js';
7 |
8 | // Mock dependencies
9 | vi.mock('../../lib/project-detection/index.js');
10 | vi.mock('../../lib/latest-pipeline/getLatestPipelineWorkflows.js');
11 | vi.mock('../../lib/latest-pipeline/formatLatestPipelineStatus.js');
12 |
13 | describe('getLatestPipelineStatus handler', () => {
14 | const mockWorkflows = [
15 | {
16 | id: 'workflow-id-1',
17 | name: 'build-and-test',
18 | status: 'success',
19 | created_at: '2023-01-01T12:00:00Z',
20 | stopped_at: '2023-01-01T12:05:00Z',
21 | pipeline_number: 123,
22 | project_slug: 'gh/circleci/project',
23 | },
24 | ];
25 |
26 | const mockFormattedResponse: McpSuccessResponse = {
27 | content: [
28 | {
29 | type: 'text' as const,
30 | text: 'Formatted pipeline status',
31 | },
32 | ],
33 | };
34 |
35 | beforeEach(() => {
36 | vi.resetAllMocks();
37 |
38 | // Setup default mocks
39 | vi.mocked(projectDetection.getProjectSlugFromURL).mockReturnValue(
40 | 'gh/circleci/project',
41 | );
42 | vi.mocked(projectDetection.getBranchFromURL).mockReturnValue('main');
43 | vi.mocked(projectDetection.identifyProjectSlug).mockResolvedValue(
44 | 'gh/circleci/project',
45 | );
46 | vi.mocked(
47 | getLatestPipelineWorkflowsModule.getLatestPipelineWorkflows,
48 | ).mockResolvedValue(mockWorkflows);
49 | vi.mocked(
50 | formatLatestPipelineStatusModule.formatLatestPipelineStatus,
51 | ).mockReturnValue(mockFormattedResponse);
52 | });
53 |
54 | it('should get latest pipeline status using projectURL', async () => {
55 | const args = {
56 | params: {
57 | projectURL:
58 | 'https://app.circleci.com/pipelines/github/circleci/project',
59 | },
60 | };
61 |
62 | const controller = new AbortController();
63 | const response = await getLatestPipelineStatus(args as any, {
64 | signal: controller.signal,
65 | });
66 |
67 | expect(projectDetection.getProjectSlugFromURL).toHaveBeenCalledWith(
68 | args.params.projectURL,
69 | );
70 | expect(projectDetection.getBranchFromURL).toHaveBeenCalledWith(
71 | args.params.projectURL,
72 | );
73 | expect(
74 | getLatestPipelineWorkflowsModule.getLatestPipelineWorkflows,
75 | ).toHaveBeenCalledWith({
76 | projectSlug: 'gh/circleci/project',
77 | branch: 'main',
78 | });
79 | expect(
80 | formatLatestPipelineStatusModule.formatLatestPipelineStatus,
81 | ).toHaveBeenCalledWith(mockWorkflows);
82 | expect(response).toEqual(mockFormattedResponse);
83 | });
84 |
85 | it('should return a valid MCP error response when projectSlug is provided without branch', async () => {
86 | const args = {
87 | params: {
88 | projectSlug: 'gh/circleci/project',
89 | },
90 | };
91 |
92 | const controller = new AbortController();
93 | const response = await getLatestPipelineStatus(args as any, {
94 | signal: controller.signal,
95 | });
96 |
97 | expect(response).toHaveProperty('content');
98 | expect(response).toHaveProperty('isError', true);
99 | expect(Array.isArray(response.content)).toBe(true);
100 | expect(response.content[0]).toHaveProperty('type', 'text');
101 | expect(typeof response.content[0].text).toBe('string');
102 | expect(response.content[0].text).toContain('Branch not provided');
103 | });
104 |
105 | it('should get latest pipeline status using workspace and git info', async () => {
106 | const args = {
107 | params: {
108 | workspaceRoot: '/path/to/workspace',
109 | gitRemoteURL: 'https://github.com/circleci/project.git',
110 | branch: 'feature/branch',
111 | },
112 | };
113 |
114 | const controller = new AbortController();
115 | const response = await getLatestPipelineStatus(args as any, {
116 | signal: controller.signal,
117 | });
118 |
119 | expect(projectDetection.identifyProjectSlug).toHaveBeenCalledWith({
120 | gitRemoteURL: args.params.gitRemoteURL,
121 | });
122 | expect(
123 | getLatestPipelineWorkflowsModule.getLatestPipelineWorkflows,
124 | ).toHaveBeenCalledWith({
125 | projectSlug: 'gh/circleci/project',
126 | branch: 'feature/branch',
127 | });
128 | expect(
129 | formatLatestPipelineStatusModule.formatLatestPipelineStatus,
130 | ).toHaveBeenCalledWith(mockWorkflows);
131 | expect(response).toEqual(mockFormattedResponse);
132 | });
133 |
134 | it('should get latest pipeline status using projectSlug and branch', async () => {
135 | const args = {
136 | params: {
137 | projectSlug: 'gh/circleci/project',
138 | branch: 'feature/branch',
139 | },
140 | };
141 |
142 | const controller = new AbortController();
143 | const response = await getLatestPipelineStatus(args as any, {
144 | signal: controller.signal,
145 | });
146 |
147 | // Verify that project detection functions were not called
148 | expect(projectDetection.getProjectSlugFromURL).not.toHaveBeenCalled();
149 | expect(projectDetection.identifyProjectSlug).not.toHaveBeenCalled();
150 |
151 | expect(
152 | getLatestPipelineWorkflowsModule.getLatestPipelineWorkflows,
153 | ).toHaveBeenCalledWith({
154 | projectSlug: 'gh/circleci/project',
155 | branch: 'feature/branch',
156 | });
157 | expect(
158 | formatLatestPipelineStatusModule.formatLatestPipelineStatus,
159 | ).toHaveBeenCalledWith(mockWorkflows);
160 | expect(response).toEqual(mockFormattedResponse);
161 | });
162 |
163 | it('should return error when no valid inputs are provided', async () => {
164 | const args = {
165 | params: {},
166 | };
167 |
168 | const controller = new AbortController();
169 | const response = await getLatestPipelineStatus(args as any, {
170 | signal: controller.signal,
171 | });
172 |
173 | expect(response).toHaveProperty('content');
174 | expect(response.content[0]).toHaveProperty('type', 'text');
175 | expect(response.content[0].text).toContain('Missing required inputs');
176 | });
177 |
178 | it('should return error when project slug cannot be identified', async () => {
179 | // Return null to simulate project not found
180 | vi.mocked(projectDetection.identifyProjectSlug).mockResolvedValue(
181 | null as unknown as string,
182 | );
183 |
184 | const args = {
185 | params: {
186 | workspaceRoot: '/path/to/workspace',
187 | gitRemoteURL: 'https://github.com/circleci/project.git',
188 | branch: 'feature/branch',
189 | },
190 | };
191 |
192 | const controller = new AbortController();
193 | const response = await getLatestPipelineStatus(args as any, {
194 | signal: controller.signal,
195 | });
196 |
197 | expect(response).toHaveProperty('content');
198 | expect(response.content[0]).toHaveProperty('type', 'text');
199 | expect(response.content[0].text).toContain('Project not found');
200 | });
201 |
202 | it('should get pipeline status when branch is provided from URL but not in params', async () => {
203 | const args = {
204 | params: {
205 | projectURL:
206 | 'https://app.circleci.com/pipelines/github/circleci/project?branch=develop',
207 | },
208 | };
209 |
210 | const controller = new AbortController();
211 | const response = await getLatestPipelineStatus(args as any, {
212 | signal: controller.signal,
213 | });
214 |
215 | expect(
216 | getLatestPipelineWorkflowsModule.getLatestPipelineWorkflows,
217 | ).toHaveBeenCalledWith({
218 | projectSlug: 'gh/circleci/project',
219 | branch: 'main', // This is what our mock returns
220 | });
221 | expect(response).toEqual(mockFormattedResponse);
222 | });
223 |
224 | it('should handle errors from getLatestPipelineWorkflows', async () => {
225 | vi.mocked(
226 | getLatestPipelineWorkflowsModule.getLatestPipelineWorkflows,
227 | ).mockRejectedValue(new Error('Failed to fetch workflows'));
228 |
229 | const args = {
230 | params: {
231 | projectURL:
232 | 'https://app.circleci.com/pipelines/github/circleci/project',
233 | },
234 | };
235 |
236 | // We expect the handler to throw the error so we can catch it
237 | const controller = new AbortController();
238 | await expect(
239 | getLatestPipelineStatus(args as any, { signal: controller.signal }),
240 | ).rejects.toThrow('Failed to fetch workflows');
241 | });
242 | });
243 |
```
--------------------------------------------------------------------------------
/src/lib/project-detection/index.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | getPipelineNumberFromURL,
3 | getProjectSlugFromURL,
4 | getBranchFromURL,
5 | getJobNumberFromURL,
6 | } from './index.js';
7 | import { describe, it, expect } from 'vitest';
8 |
9 | describe('getPipelineNumberFromURL', () => {
10 | it.each([
11 | // Workflow URL
12 | {
13 | url: 'https://app.circleci.com/pipelines/gh/organization/project/2/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv',
14 | expected: 2,
15 | },
16 | // Workflow URL
17 | {
18 | url: 'https://app.circleci.com/pipelines/circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ/123/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv',
19 | expected: 123,
20 | },
21 | // Project URL (no pipeline number)
22 | {
23 | url: 'https://app.circleci.com/pipelines/gh/organization/project',
24 | expected: undefined,
25 | },
26 | // Project URL (missing all info)
27 | {
28 | url: 'https://app.circleci.com/gh/organization/project',
29 | expected: undefined,
30 | },
31 | // Project URL (Legacy job URL format with job number returns undefined for pipeline number)
32 | {
33 | url: 'https://circleci.com/gh/organization/project/123',
34 | expected: undefined,
35 | },
36 | // Project URL (Legacy job URL format with job number returns undefined for pipeline number)
37 | {
38 | url: 'https://circleci.server.customdomain.com/gh/organization/project/123',
39 | expected: undefined,
40 | },
41 | ])('extracts pipeline number $expected from URL', ({ url, expected }) => {
42 | expect(getPipelineNumberFromURL(url)).toBe(expected);
43 | });
44 |
45 | it('should not throw error for invalid CircleCI URL format. Returns undefined for pipeline number', () => {
46 | expect(() =>
47 | getPipelineNumberFromURL('https://app.circleci.com/invalid/url'),
48 | ).not.toThrow();
49 | });
50 |
51 | it('throws error when pipeline number is not a valid number', () => {
52 | expect(() =>
53 | getPipelineNumberFromURL(
54 | 'https://app.circleci.com/pipelines/gh/organization/project/abc/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv',
55 | ),
56 | ).toThrow('Pipeline number in URL is not a valid number');
57 | });
58 | });
59 |
60 | describe('getProjectSlugFromURL', () => {
61 | it.each([
62 | // Workflow URL
63 | {
64 | url: 'https://app.circleci.com/pipelines/gh/organization/project/2/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv',
65 | expected: 'gh/organization/project',
66 | },
67 | // Workflow URL
68 | {
69 | url: 'https://app.circleci.com/pipelines/circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ/123/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv',
70 | expected: 'circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ',
71 | },
72 | // Pipeline URL
73 | {
74 | url: 'https://app.circleci.com/pipelines/gh/organization/project/123',
75 | expected: 'gh/organization/project',
76 | },
77 | // Legacy Pipeline URL for gh
78 | {
79 | url: 'https://circleci.com/gh/organization/project/123',
80 | expected: 'gh/organization/project',
81 | },
82 | // Legacy Pipeline URL for Github
83 | {
84 | url: 'https://circleci.com/github/organization/project/123',
85 | expected: 'github/organization/project',
86 | },
87 | // Legacy Pipeline URL for bb
88 | {
89 | url: 'https://circleci.com/bb/organization/project/123',
90 | expected: 'bb/organization/project',
91 | },
92 | // Legacy Pipeline URL for Bitbucket
93 | {
94 | url: 'https://circleci.com/bitbucket/organization/project/123',
95 | expected: 'bitbucket/organization/project',
96 | },
97 | // Legacy Pipeline URL for CircleCI
98 | {
99 | url: 'https://circleci.com/circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ/456',
100 | expected: 'circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ',
101 | },
102 | // Pipeline URL
103 | {
104 | url: 'https://app.circleci.com/pipelines/circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ/456',
105 | expected: 'circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ',
106 | },
107 | // Job URL
108 | {
109 | url: 'https://app.circleci.com/pipelines/gh/organization/project/2/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv/jobs/xyz789',
110 | expected: 'gh/organization/project',
111 | },
112 | // Job URL
113 | {
114 | url: 'https://app.circleci.com/pipelines/circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ/123/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv/jobs/def456',
115 | expected: 'circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ',
116 | },
117 | // Project URL
118 | {
119 | url: 'https://app.circleci.com/pipelines/gh/organization/project',
120 | expected: 'gh/organization/project',
121 | },
122 | // Project URL
123 | {
124 | url: 'https://app.circleci.com/pipelines/circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ',
125 | expected: 'circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ',
126 | },
127 | // Project URL with query parameters
128 | {
129 | url: 'https://app.circleci.com/pipelines/github/CircleCI-Public/hungry-panda?branch=splitting',
130 | expected: 'github/CircleCI-Public/hungry-panda',
131 | },
132 | ])('extracts project slug $expected from URL', ({ url, expected }) => {
133 | expect(getProjectSlugFromURL(url)).toBe(expected);
134 | });
135 |
136 | it('throws error for invalid CircleCI URL format', () => {
137 | expect(() =>
138 | getProjectSlugFromURL('https://app.circleci.com/invalid/url'),
139 | ).toThrow(
140 | 'Error getting project slug from URL: Invalid CircleCI URL format',
141 | );
142 | });
143 |
144 | it('throws error when project information is incomplete', () => {
145 | expect(() =>
146 | getProjectSlugFromURL('https://app.circleci.com/pipelines/gh'),
147 | ).toThrow('Unable to extract project information from URL');
148 | });
149 | });
150 |
151 | describe('getBranchFromURL', () => {
152 | it.each([
153 | // URL with branch parameter
154 | {
155 | url: 'https://app.circleci.com/pipelines/gh/organization/project?branch=feature-branch',
156 | expected: 'feature-branch',
157 | },
158 | // URL with branch parameter and other params
159 | {
160 | url: 'https://app.circleci.com/pipelines/gh/organization/project?branch=fix%2Fbug-123&filter=mine',
161 | expected: 'fix/bug-123',
162 | },
163 | // URL without branch parameter
164 | {
165 | url: 'https://app.circleci.com/pipelines/gh/organization/project',
166 | expected: undefined,
167 | },
168 | // URL with other parameters but no branch
169 | {
170 | url: 'https://app.circleci.com/pipelines/gh/organization/project?filter=mine',
171 | expected: undefined,
172 | },
173 | ])('extracts branch $expected from URL', ({ url, expected }) => {
174 | expect(getBranchFromURL(url)).toBe(expected);
175 | });
176 |
177 | it('throws error for invalid CircleCI URL format', () => {
178 | expect(() => getBranchFromURL('not-a-url')).toThrow(
179 | 'Error getting branch from URL: Invalid CircleCI URL format',
180 | );
181 | });
182 | });
183 |
184 | describe('getJobNumberFromURL', () => {
185 | it.each([
186 | // Job URL with numeric job number
187 | {
188 | url: 'https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv/jobs/456',
189 | expected: 456,
190 | },
191 | // Job URL with complex project path
192 | {
193 | url: 'https://app.circleci.com/pipelines/circleci/GM1mbrQEWnNbzLKEnotDo4/5gh9pgQgohHwicwomY5nYQ/123/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv/jobs/789',
194 | expected: 789,
195 | },
196 | // Job URL with legacy format
197 | {
198 | url: 'https://circleci.com/gh/organization/project/123',
199 | expected: 123,
200 | },
201 | // Job URL with legacy format with custom domain
202 | {
203 | url: 'https://circleci.server.customdomain.com/gh/organization/project/123',
204 | expected: 123,
205 | },
206 | // Workflow URL (no job number)
207 | {
208 | url: 'https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv',
209 | expected: undefined,
210 | },
211 | // Pipeline URL (no job number)
212 | {
213 | url: 'https://app.circleci.com/pipelines/gh/organization/project/123',
214 | expected: undefined,
215 | },
216 | // Project URL (no job number)
217 | {
218 | url: 'https://app.circleci.com/pipelines/gh/organization/project',
219 | expected: undefined,
220 | },
221 | ])('extracts job number $expected from URL', ({ url, expected }) => {
222 | expect(getJobNumberFromURL(url)).toBe(expected);
223 | });
224 |
225 | it('throws error when job number is not a valid number', () => {
226 | expect(() =>
227 | getJobNumberFromURL(
228 | 'https://app.circleci.com/pipelines/gh/organization/project/123/workflows/abc123de-f456-78gh-90ij-klmnopqrstuv/jobs/abc',
229 | ),
230 | ).toThrow('Job number in URL is not a valid number');
231 | });
232 | });
233 |
```
--------------------------------------------------------------------------------
/src/lib/rateLimitedRequests/index.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, test, expect, vi, beforeAll } from 'vitest';
2 | import { rateLimitedRequests } from '.';
3 |
4 | type MockResponse = {
5 | ok: boolean;
6 | json: () => Promise<{ args: Record<string, string | string[]> }>;
7 | };
8 |
9 | const mockFetch = (url: string): Promise<MockResponse> => {
10 | return Promise.resolve({
11 | ok: true,
12 | json: () => {
13 | const params = url.split('?')[1].split('&');
14 | const paramsMap = params.reduce<Record<string, string | string[]>>(
15 | (map, paramPair) => {
16 | const values = paramPair.split('=');
17 | if (map[values[0]] && Array.isArray(map[values[0]])) {
18 | (map[values[0]] as string[]).push(values[1]);
19 | } else if (map[values[0]] && !Array.isArray(map[values[0]])) {
20 | map[values[0]] = [map[values[0]] as string, values[1]];
21 | } else {
22 | map[values[0]] = values[1];
23 | }
24 | return map;
25 | },
26 | {},
27 | );
28 | return Promise.resolve({ args: paramsMap });
29 | },
30 | });
31 | };
32 |
33 | // Test configuration
34 | beforeAll(() => {
35 | vi.setConfig({ testTimeout: 60000 });
36 | });
37 |
38 | const maxRetries = 2;
39 | const retryDelayInMillis = 500;
40 | const requestURL = `https://httpbin.org/get`;
41 |
42 | // Helper functions
43 | function generateRequests(numberOfRequests: number): (() => Promise<any>)[] {
44 | return Array.from(
45 | { length: numberOfRequests },
46 | (_, i) => () => makeRequest(i),
47 | );
48 | }
49 |
50 | async function makeRequest(
51 | requestId: number,
52 | attempt = 1,
53 | ): Promise<{ args: any } | { error: any }> {
54 | try {
55 | const response = await mockFetch(requestURL + '?id=' + requestId);
56 | if (!response.ok) {
57 | throw new Error(`HTTP error occurred`);
58 | }
59 | return await response.json();
60 | } catch (error) {
61 | if (attempt <= maxRetries) {
62 | await new Promise((resolve) => setTimeout(resolve, retryDelayInMillis));
63 | return makeRequest(requestId, attempt + 1);
64 | } else {
65 | return {
66 | error: error instanceof Error ? error.toString() : String(error),
67 | };
68 | }
69 | }
70 | }
71 |
72 | function isResponseContainData(
73 | result: any[],
74 | startIndex: number,
75 | endIndex: number,
76 | ): boolean {
77 | for (let i = startIndex; i <= endIndex; i++) {
78 | const resultItem = JSON.stringify(result[i - startIndex]);
79 | if (!resultItem || !resultItem.includes(`{"args":{"id":"${i}"}`)) {
80 | return false;
81 | }
82 | }
83 | return true;
84 | }
85 |
86 | function isBatchResponseContainData(
87 | batchItems: any[],
88 | startIndex: number,
89 | endIndex: number,
90 | ): boolean {
91 | return batchItems.some(
92 | (batchItem) =>
93 | batchItem.startIndex === startIndex &&
94 | batchItem.stopIndex === endIndex &&
95 | isResponseContainData(batchItem.results, startIndex, endIndex),
96 | );
97 | }
98 |
99 | function checkProgressItems(
100 | progressItems: any[],
101 | maxRequests: number,
102 | totalRequests: number,
103 | expectedProgressItemsCount: number,
104 | ): void {
105 | expect(progressItems.length).toBe(expectedProgressItemsCount);
106 | for (let i = 0; i < expectedProgressItemsCount; i++) {
107 | expect(progressItems[i].totalRequests).toBe(totalRequests);
108 | expect(progressItems[i].completedRequests).toBe((1 + i) * maxRequests);
109 | }
110 | }
111 |
112 | function checkBatchItems(
113 | batchItems: any[],
114 | batchSize: number,
115 | totalRequests: number,
116 | ): void {
117 | expect(batchItems.length).toBe(Math.ceil(totalRequests / batchSize));
118 | for (const batch of batchItems) {
119 | const expectedSize = Math.min(batchSize, totalRequests - batch.startIndex);
120 | expect(batch.results.length).toBe(expectedSize);
121 | const result = isResponseContainData(
122 | batch.results,
123 | batch.startIndex,
124 | batch.stopIndex,
125 | );
126 | if (!result) {
127 | console.log('items ' + JSON.stringify(batchItems));
128 | console.log(
129 | `startIndex ${batch.startIndex}, endIndex ${batch.stopIndex}`,
130 | );
131 | }
132 | expect(result).toBe(true);
133 | }
134 | }
135 |
136 | // Options creator
137 | function createOptions(
138 | batchSize?: number,
139 | onProgress?: (progress: {
140 | totalRequests: number;
141 | completedRequests: number;
142 | }) => void,
143 | onBatchComplete?: (batch: {
144 | startIndex: number;
145 | stopIndex: number;
146 | results: any[];
147 | }) => void,
148 | ) {
149 | return { batchSize, onProgress, onBatchComplete };
150 | }
151 |
152 | describe('rateLimitedRequests', () => {
153 | test('execute 50 requests', async () => {
154 | const requests = generateRequests(50);
155 | const result = await rateLimitedRequests(
156 | requests,
157 | 25,
158 | 1000,
159 | createOptions(),
160 | );
161 |
162 | expect(result.length).toBe(50);
163 | expect(isResponseContainData(result, 0, 49)).toBe(true);
164 | });
165 |
166 | test('execute 1000 requests', async () => {
167 | const requests = generateRequests(1000);
168 | const batchItems: any[] = [];
169 | const progressItems: any[] = [];
170 |
171 | const result = await rateLimitedRequests(
172 | requests,
173 | 100,
174 | 100,
175 | createOptions(
176 | 50,
177 | (progress) => progressItems.push(progress),
178 | (batch) => batchItems.push(batch),
179 | ),
180 | );
181 |
182 | expect(result.length).toBe(1000);
183 | expect(isResponseContainData(result, 0, 999)).toBe(true);
184 | expect(batchItems.length).toBe(20);
185 |
186 | for (const batch of batchItems) {
187 | expect(batch.results.length).toBe(50);
188 | expect(
189 | isResponseContainData(batch.results, batch.startIndex, batch.stopIndex),
190 | ).toBe(true);
191 | }
192 |
193 | checkProgressItems(progressItems, 100, 1000, 10);
194 | }, 30000);
195 |
196 | describe('with batch', () => {
197 | test('execute 50 requests with batch', async () => {
198 | const requests = generateRequests(50);
199 | const batchItems: any[] = [];
200 |
201 | const result = await rateLimitedRequests(
202 | requests,
203 | 25,
204 | 1000,
205 | createOptions(10, undefined, (batch) => batchItems.push(batch)),
206 | );
207 |
208 | expect(result.length).toBe(50);
209 | expect(batchItems.length).toBe(5);
210 | checkBatchItems(batchItems, 10, 50);
211 | });
212 |
213 | test('batchSize bigger than total requests', async () => {
214 | const requests = generateRequests(50);
215 | const batchItems: any[] = [];
216 |
217 | const result = await rateLimitedRequests(
218 | requests,
219 | 25,
220 | 1000,
221 | createOptions(60, undefined, (batch) => batchItems.push(batch)),
222 | );
223 |
224 | expect(result.length).toBe(50);
225 | expect(batchItems.length).toBe(1);
226 | expect(batchItems[0].results.length).toBe(50);
227 | });
228 |
229 | test('batchSize equals to total requests', async () => {
230 | const requests = generateRequests(50);
231 | const batchItems: any[] = [];
232 |
233 | const result = await rateLimitedRequests(
234 | requests,
235 | 25,
236 | 1000,
237 | createOptions(50, undefined, (batch) => batchItems.push(batch)),
238 | );
239 |
240 | expect(result.length).toBe(50);
241 | expect(batchItems.length).toBe(1);
242 | isBatchResponseContainData(batchItems, 0, 49);
243 | });
244 |
245 | test('with onProgress callback', async () => {
246 | const requests = generateRequests(50);
247 | const progressItems: any[] = [];
248 |
249 | const result = await rateLimitedRequests(
250 | requests,
251 | 25,
252 | 1000,
253 | createOptions(50, (progress) => progressItems.push(progress)),
254 | );
255 |
256 | expect(result.length).toBe(50);
257 | checkProgressItems(progressItems, 25, 50, 2);
258 | });
259 | });
260 |
261 | describe('batch processing', () => {
262 | test('should process empty batch items correctly', async () => {
263 | const requests = generateRequests(30);
264 | const batchItems: any[] = [];
265 |
266 | const result = await rateLimitedRequests(
267 | requests,
268 | 10,
269 | 1000,
270 | createOptions(10, undefined, (batch) => batchItems.push(batch)),
271 | );
272 |
273 | expect(result.length).toBe(30);
274 | expect(batchItems.length).toBe(3);
275 | for (const batchItem of batchItems) {
276 | expect(batchItem.results.length).toBe(10);
277 | }
278 | });
279 |
280 | test('should handle partial batch correctly', async () => {
281 | const requests = generateRequests(25);
282 | const batchItems: any[] = [];
283 |
284 | const result = await rateLimitedRequests(
285 | requests,
286 | 10,
287 | 1000,
288 | createOptions(10, undefined, (batch) => batchItems.push(batch)),
289 | );
290 |
291 | expect(result.length).toBe(25);
292 | expect(batchItems.length).toBe(3);
293 | expect(batchItems[2].results.length).toBe(5); // Last batch should have 5 items
294 | });
295 | });
296 |
297 | describe('progress tracking', () => {
298 | test('should track progress correctly with uneven batches', async () => {
299 | const requests = generateRequests(25);
300 | const progressItems: any[] = [];
301 |
302 | const result = await rateLimitedRequests(
303 | requests,
304 | 10,
305 | 1000,
306 | createOptions(undefined, (progress) => progressItems.push(progress)),
307 | );
308 |
309 | expect(result.length).toBe(25);
310 | expect(progressItems.length).toBe(3);
311 | expect(progressItems[0].completedRequests).toBe(10);
312 | expect(progressItems[1].completedRequests).toBe(20);
313 | expect(progressItems[2].completedRequests).toBe(25);
314 | expect(progressItems[2].totalRequests).toBe(25);
315 | });
316 | });
317 |
318 | describe('error cases', () => {
319 | test('options not passed', async () => {
320 | const requests = generateRequests(5);
321 | const result = await rateLimitedRequests(requests, 25, 1000);
322 | expect(result.length).toBe(5);
323 | });
324 |
325 | test('should handle undefined options gracefully', async () => {
326 | const requests = generateRequests(5);
327 | const result = await rateLimitedRequests(requests, 25, 1000, undefined);
328 | expect(result.length).toBe(5);
329 | });
330 |
331 | test('should handle empty batch size gracefully', async () => {
332 | const requests = generateRequests(5);
333 | const result = await rateLimitedRequests(
334 | requests,
335 | 25,
336 | 1000,
337 | createOptions(0),
338 | );
339 | expect(result.length).toBe(5);
340 | });
341 | });
342 | });
343 |
```
--------------------------------------------------------------------------------
/src/clients/schemas.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 |
3 | type ContextSchema = {
4 | [k: string]: 'string' | 'number' | 'boolean' | 'date' | ContextSchema;
5 | };
6 |
7 | const contextSchemaSchema: z.ZodSchema<ContextSchema> = z.lazy(() =>
8 | z
9 | .record(
10 | z.union([
11 | contextSchemaSchema,
12 | z
13 | .enum(['string', 'number', 'boolean', 'date'])
14 | .describe('a primitive data type: string, number, boolean, or date'),
15 | ]),
16 | )
17 | .describe(
18 | 'a schema structure, mapping keys to a primitive type (string, number, boolean, or date) or recursively to a nested schema',
19 | ),
20 | );
21 |
22 | const promptObjectSchema = z
23 | .object({
24 | template: z.string().describe('a mustache template string'),
25 | contextSchema: contextSchemaSchema.describe(
26 | 'an arbitrarily nested map of variable names from the mustache template to primitive types (string, number, or boolean)',
27 | ),
28 | })
29 | .describe(
30 | 'a complete prompt template with a template string and a context schema',
31 | );
32 |
33 | const RuleReviewSchema = z.object({
34 | isRuleCompliant: z.boolean(),
35 | relatedRules: z.object({
36 | compliant: z.array(
37 | z.object({
38 | rule: z.string(),
39 | reason: z.string(),
40 | confidenceScore: z.number(),
41 | }),
42 | ),
43 | violations: z.array(
44 | z.object({
45 | rule: z.string(),
46 | reason: z.string(),
47 | confidenceScore: z.number(),
48 | violationInstances: z.array(
49 | z.object({
50 | file: z.string(),
51 | lineNumbersInDiff: z.array(z.string()),
52 | violatingCodeSnippet: z.string(),
53 | explanationOfViolation: z.string(),
54 | }),
55 | ),
56 | }),
57 | ),
58 | requiresHumanReview: z.array(
59 | z.object({
60 | rule: z.string(),
61 | reason: z.string(),
62 | confidenceScore: z.number(),
63 | humanReviewRequired: z.object({
64 | pointsOfAmbiguity: z.array(z.string()),
65 | questionsForManualReviewer: z.array(z.string()),
66 | }),
67 | }),
68 | ),
69 | }),
70 | unrelatedRules: z.array(z.string()).optional(),
71 | });
72 |
73 | const FollowedProjectSchema = z.object({
74 | name: z.string(),
75 | slug: z.string(),
76 | vcs_type: z.string(),
77 | });
78 |
79 | const PipelineSchema = z.object({
80 | id: z.string(),
81 | project_slug: z.string(),
82 | number: z.number(),
83 | });
84 |
85 | const WorkflowSchema = z.object({
86 | id: z.string(),
87 | name: z.string(),
88 | status: z.string().nullable(),
89 | created_at: z.string(),
90 | stopped_at: z.string().nullable().optional(),
91 | pipeline_number: z.number(),
92 | project_slug: z.string(),
93 | pipeline_id: z.string(),
94 | });
95 |
96 | const RerunWorkflowSchema = z.object({
97 | workflow_id: z.string(),
98 | });
99 |
100 | const JobSchema = z.object({
101 | job_number: z.number().optional(),
102 | id: z.string(),
103 | });
104 |
105 | const JobDetailsSchema = z.object({
106 | build_num: z.number(),
107 | steps: z.array(
108 | z.object({
109 | name: z.string(),
110 | actions: z.array(
111 | z.object({
112 | index: z.number(),
113 | step: z.number(),
114 | failed: z.boolean().nullable(),
115 | }),
116 | ),
117 | }),
118 | ),
119 | workflows: z.object({
120 | job_name: z.string(),
121 | }),
122 | });
123 |
124 | const FlakyTestSchema = z.object({
125 | flaky_tests: z.array(
126 | z.object({
127 | job_number: z.number(),
128 | test_name: z.string(),
129 | }),
130 | ),
131 | total_flaky_tests: z.number(),
132 | });
133 |
134 | const TestSchema = z.object({
135 | message: z.string(),
136 | run_time: z.union([z.string(), z.number()]),
137 | file: z.string().optional(),
138 | result: z.string(),
139 | name: z.string(),
140 | classname: z.string(),
141 | });
142 |
143 | const PaginatedTestResponseSchema = z.object({
144 | items: z.array(TestSchema),
145 | next_page_token: z.string().nullable(),
146 | });
147 |
148 | const ConfigValidateSchema = z.object({
149 | valid: z.boolean(),
150 | errors: z
151 | .array(
152 | z.object({
153 | message: z.string(),
154 | }),
155 | )
156 | .nullable(),
157 | 'output-yaml': z.string(),
158 | 'source-yaml': z.string(),
159 | });
160 |
161 | const RunPipelineResponseSchema = z.object({
162 | number: z.number(),
163 | });
164 |
165 | const RollbackProjectRequestSchema = z.object({
166 | component_name: z.string().describe('The component name'),
167 | current_version: z.string().describe('The current version'),
168 | environment_name: z.string().describe('The environment name'),
169 | namespace: z.string().describe('The namespace').optional(),
170 | parameters: z.record(z.any()).describe('The extra parameters for the rollback pipeline').optional(),
171 | reason: z.string().describe('The reason for the rollback').optional(),
172 | target_version: z.string().describe('The target version'),
173 | });
174 |
175 | const RollbackProjectResponseSchema = z.object({
176 | id: z.string().describe('The ID of the rollback pipeline or the command created to handle the rollback'),
177 | rollback_type: z.string().describe('The type of the rollback'),
178 | });
179 |
180 | const DeploySettingsResponseSchema = z.object({
181 | create_autogenerated_releases: z.boolean().optional().describe('Whether to create autogenerated releases'),
182 | rollback_pipeline_definition_id: z.string().optional().describe('The rollback pipeline definition ID, if configured for this project'),
183 | }).passthrough(); // Allow additional properties we might not know about
184 |
185 | const DeployComponentsResponseSchema = z.object({
186 | items: z.array(z.object({
187 | id: z.string(),
188 | project_id: z.string(),
189 | name: z.string(),
190 | release_count: z.number(),
191 | labels: z.array(z.string()),
192 | created_at: z.string(),
193 | updated_at: z.string(),
194 | })),
195 | next_page_token: z.string().nullable(),
196 | });
197 |
198 | const DeployComponentVersionsResponseSchema = z.object({
199 | items: z.array(z.object({
200 | name: z.string(),
201 | namespace: z.string(),
202 | environment_id: z.string(),
203 | is_live: z.boolean(),
204 | pipeline_id: z.string(),
205 | workflow_id: z.string(),
206 | job_id: z.string(),
207 | job_number: z.number(),
208 | last_deployed_at: z.string(),
209 | })),
210 | next_page_token: z.string().nullable(),
211 | });
212 |
213 | export const DeployEnvironmentResponseSchema = z.object({
214 | items: z.array(z.object({
215 | id: z.string(),
216 | name: z.string(),
217 | created_at: z.string(),
218 | updated_at: z.string(),
219 | labels: z.array(z.string()),
220 | })),
221 | next_page_token: z.string().nullable(),
222 | });
223 |
224 | const ProjectSchema = z.object({
225 | id: z.string(),
226 | organization_id: z.string(),
227 | });
228 |
229 | const PipelineDefinitionSchema = z.object({
230 | id: z.string(),
231 | name: z.string(),
232 | });
233 |
234 | const PipelineDefinitionsResponseSchema = z.object({
235 | items: z.array(PipelineDefinitionSchema),
236 | });
237 |
238 | export const PipelineDefinition = PipelineDefinitionSchema;
239 | export type PipelineDefinition = z.infer<typeof PipelineDefinitionSchema>;
240 |
241 | export const PipelineDefinitionsResponse = PipelineDefinitionsResponseSchema;
242 | export type PipelineDefinitionsResponse = z.infer<
243 | typeof PipelineDefinitionsResponseSchema
244 | >;
245 |
246 | export const Test = TestSchema;
247 | export type Test = z.infer<typeof TestSchema>;
248 |
249 | export const PaginatedTestResponse = PaginatedTestResponseSchema;
250 | export type PaginatedTestResponse = z.infer<typeof PaginatedTestResponseSchema>;
251 |
252 | export const FlakyTest = FlakyTestSchema;
253 | export type FlakyTest = z.infer<typeof FlakyTestSchema>;
254 |
255 | export const ConfigValidate = ConfigValidateSchema;
256 | export type ConfigValidate = z.infer<typeof ConfigValidateSchema>;
257 |
258 | // Export the schemas and inferred types with the same names as the original types
259 | export const Pipeline = PipelineSchema;
260 | export type Pipeline = z.infer<typeof PipelineSchema>;
261 |
262 | export const RunPipelineResponse = RunPipelineResponseSchema;
263 | export type RunPipelineResponse = z.infer<typeof RunPipelineResponseSchema>;
264 |
265 | export const Project = ProjectSchema;
266 | export type Project = z.infer<typeof ProjectSchema>;
267 |
268 | export const PaginatedPipelineResponseSchema = z.object({
269 | items: z.array(Pipeline),
270 | next_page_token: z.string().nullable(),
271 | });
272 | export type PaginatedPipelineResponse = z.infer<
273 | typeof PaginatedPipelineResponseSchema
274 | >;
275 |
276 | export const Workflow = WorkflowSchema;
277 | export type Workflow = z.infer<typeof WorkflowSchema>;
278 |
279 | export const Job = JobSchema;
280 | export type Job = z.infer<typeof JobSchema>;
281 |
282 | export const JobDetails = JobDetailsSchema;
283 | export type JobDetails = z.infer<typeof JobDetailsSchema>;
284 |
285 | export const FollowedProject = FollowedProjectSchema;
286 | export type FollowedProject = z.infer<typeof FollowedProjectSchema>;
287 |
288 | export const PromptObject = promptObjectSchema;
289 | export type PromptObject = z.infer<typeof PromptObject>;
290 |
291 | export const RerunWorkflow = RerunWorkflowSchema;
292 | export type RerunWorkflow = z.infer<typeof RerunWorkflowSchema>;
293 |
294 | export const RuleReview = RuleReviewSchema;
295 | export type RuleReview = z.infer<typeof RuleReviewSchema>;
296 |
297 | export const RollbackProjectRequest = RollbackProjectRequestSchema;
298 | export type RollbackProjectRequest = z.infer<typeof RollbackProjectRequestSchema>;
299 |
300 | export const RollbackProjectResponse = RollbackProjectResponseSchema;
301 | export type RollbackProjectResponse = z.infer<typeof RollbackProjectResponseSchema>;
302 |
303 | export const DeploySettingsResponse = DeploySettingsResponseSchema;
304 | export type DeploySettingsResponse = z.infer<typeof DeploySettingsResponseSchema>;
305 |
306 | export const DeployComponentsResponse = DeployComponentsResponseSchema;
307 | export type DeployComponentsResponse = z.infer<typeof DeployComponentsResponseSchema>;
308 |
309 | export const DeployEnvironmentResponse = DeployEnvironmentResponseSchema;
310 | export type DeployEnvironmentResponse = z.infer<typeof DeployEnvironmentResponseSchema>;
311 |
312 | export const DeployComponentVersionsResponse = DeployComponentVersionsResponseSchema;
313 | export type DeployComponentVersionsResponse = z.infer<typeof DeployComponentVersionsResponseSchema>;
314 |
315 | const UsageExportJobStartSchema = z.object({
316 | usage_export_job_id: z.string().uuid(),
317 | });
318 |
319 | const UsageExportJobStatusSchema = z.object({
320 | state: z.string(),
321 | download_urls: z.array(z.string().url()).optional().nullable(),
322 | });
323 |
324 | export const UsageExportJobStart = UsageExportJobStartSchema;
325 | export type UsageExportJobStart = z.infer<typeof UsageExportJobStartSchema>;
326 |
327 | export const UsageExportJobStatus = UsageExportJobStatusSchema;
328 | export type UsageExportJobStatus = z.infer<typeof UsageExportJobStatusSchema>;
329 |
```
--------------------------------------------------------------------------------
/src/tools/getJobTestResults/handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { getJobTestResults } from './handler.js';
3 | import * as projectDetection from '../../lib/project-detection/index.js';
4 | import * as getJobTestsModule from '../../lib/pipeline-job-tests/getJobTests.js';
5 | import * as formatJobTestsModule from '../../lib/pipeline-job-tests/formatJobTests.js';
6 |
7 | // Mock dependencies
8 | vi.mock('../../lib/project-detection/index.js');
9 | vi.mock('../../lib/pipeline-job-tests/getJobTests.js');
10 | vi.mock('../../lib/pipeline-job-tests/formatJobTests.js');
11 |
12 | describe('getJobTestResults handler', () => {
13 | beforeEach(() => {
14 | vi.resetAllMocks();
15 | });
16 |
17 | it('should return a valid MCP error response when no inputs are provided', async () => {
18 | const args = {
19 | params: {},
20 | };
21 |
22 | const controller = new AbortController();
23 | const response = await getJobTestResults(args, {
24 | signal: controller.signal,
25 | });
26 |
27 | expect(response).toHaveProperty('content');
28 | expect(response).toHaveProperty('isError', true);
29 | expect(Array.isArray(response.content)).toBe(true);
30 | expect(response.content[0]).toHaveProperty('type', 'text');
31 | expect(typeof response.content[0].text).toBe('string');
32 | });
33 |
34 | it('should return a valid MCP error response when project is not found', async () => {
35 | vi.spyOn(projectDetection, 'identifyProjectSlug').mockResolvedValue(
36 | undefined,
37 | );
38 |
39 | const args = {
40 | params: {
41 | workspaceRoot: '/workspace',
42 | gitRemoteURL: 'https://github.com/org/repo.git',
43 | branch: 'main',
44 | },
45 | } as any;
46 |
47 | const controller = new AbortController();
48 | const response = await getJobTestResults(args, {
49 | signal: controller.signal,
50 | });
51 |
52 | expect(response).toHaveProperty('content');
53 | expect(response).toHaveProperty('isError', true);
54 | expect(Array.isArray(response.content)).toBe(true);
55 | expect(response.content[0]).toHaveProperty('type', 'text');
56 | expect(typeof response.content[0].text).toBe('string');
57 | });
58 |
59 | it('should return a valid MCP error response when projectSlug is provided without branch', async () => {
60 | const args = {
61 | params: {
62 | projectSlug: 'gh/org/repo',
63 | },
64 | } as any;
65 |
66 | const controller = new AbortController();
67 | const response = await getJobTestResults(args, {
68 | signal: controller.signal,
69 | });
70 |
71 | expect(response).toHaveProperty('content');
72 | expect(response).toHaveProperty('isError', true);
73 | expect(Array.isArray(response.content)).toBe(true);
74 | expect(response.content[0]).toHaveProperty('type', 'text');
75 | expect(typeof response.content[0].text).toBe('string');
76 | expect(response.content[0].text).toContain('Branch not provided');
77 | });
78 |
79 | it('should return a valid MCP success response with test results for a specific job', async () => {
80 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
81 | 'gh/org/repo',
82 | );
83 | vi.spyOn(projectDetection, 'getJobNumberFromURL').mockReturnValue(123);
84 |
85 | const mockTests = [
86 | {
87 | message: 'No failures',
88 | run_time: 0.5,
89 | file: 'src/test.js',
90 | result: 'success',
91 | name: 'should pass the test',
92 | classname: 'TestClass',
93 | },
94 | ];
95 |
96 | vi.spyOn(getJobTestsModule, 'getJobTests').mockResolvedValue(mockTests);
97 |
98 | vi.spyOn(formatJobTestsModule, 'formatJobTests').mockReturnValue({
99 | content: [
100 | {
101 | type: 'text',
102 | text: 'Test results output',
103 | },
104 | ],
105 | });
106 |
107 | const args = {
108 | params: {
109 | projectURL:
110 | 'https://app.circleci.com/pipelines/gh/org/repo/123/workflows/abc-def/jobs/123',
111 | },
112 | } as any;
113 |
114 | const controller = new AbortController();
115 | const response = await getJobTestResults(args, {
116 | signal: controller.signal,
117 | });
118 |
119 | expect(response).toHaveProperty('content');
120 | expect(Array.isArray(response.content)).toBe(true);
121 | expect(response.content[0]).toHaveProperty('type', 'text');
122 | expect(typeof response.content[0].text).toBe('string');
123 |
124 | expect(getJobTestsModule.getJobTests).toHaveBeenCalledWith({
125 | projectSlug: 'gh/org/repo',
126 | branch: undefined,
127 | jobNumber: 123,
128 | });
129 | });
130 |
131 | it('should return a valid MCP success response with test results for projectSlug and branch', async () => {
132 | const mockTests = [
133 | {
134 | message: 'No failures',
135 | run_time: 0.5,
136 | file: 'src/test.js',
137 | result: 'success',
138 | name: 'should pass the test',
139 | classname: 'TestClass',
140 | },
141 | ];
142 |
143 | vi.spyOn(getJobTestsModule, 'getJobTests').mockResolvedValue(mockTests);
144 |
145 | vi.spyOn(formatJobTestsModule, 'formatJobTests').mockReturnValue({
146 | content: [
147 | {
148 | type: 'text',
149 | text: 'Test results output',
150 | },
151 | ],
152 | });
153 |
154 | const args = {
155 | params: {
156 | projectSlug: 'gh/org/repo',
157 | branch: 'feature/new-feature',
158 | },
159 | } as any;
160 |
161 | const controller = new AbortController();
162 | const response = await getJobTestResults(args, {
163 | signal: controller.signal,
164 | });
165 |
166 | expect(response).toHaveProperty('content');
167 | expect(Array.isArray(response.content)).toBe(true);
168 | expect(response.content[0]).toHaveProperty('type', 'text');
169 | expect(typeof response.content[0].text).toBe('string');
170 |
171 | expect(getJobTestsModule.getJobTests).toHaveBeenCalledWith({
172 | projectSlug: 'gh/org/repo',
173 | branch: 'feature/new-feature',
174 | jobNumber: undefined,
175 | });
176 | });
177 |
178 | it('should return a valid MCP success response with test results for a branch', async () => {
179 | vi.spyOn(projectDetection, 'identifyProjectSlug').mockResolvedValue(
180 | 'gh/org/repo',
181 | );
182 |
183 | const mockTests = [
184 | {
185 | message: 'No failures',
186 | run_time: 0.5,
187 | file: 'src/test.js',
188 | result: 'success',
189 | name: 'should pass the test',
190 | classname: 'TestClass',
191 | },
192 | ];
193 |
194 | vi.spyOn(getJobTestsModule, 'getJobTests').mockResolvedValue(mockTests);
195 |
196 | vi.spyOn(formatJobTestsModule, 'formatJobTests').mockReturnValue({
197 | content: [
198 | {
199 | type: 'text',
200 | text: 'Test results output',
201 | },
202 | ],
203 | });
204 |
205 | const args = {
206 | params: {
207 | workspaceRoot: '/workspace',
208 | gitRemoteURL: 'https://github.com/org/repo.git',
209 | branch: 'main',
210 | },
211 | } as any;
212 |
213 | const controller = new AbortController();
214 | const response = await getJobTestResults(args, {
215 | signal: controller.signal,
216 | });
217 |
218 | expect(response).toHaveProperty('content');
219 | expect(Array.isArray(response.content)).toBe(true);
220 | expect(response.content[0]).toHaveProperty('type', 'text');
221 | expect(typeof response.content[0].text).toBe('string');
222 |
223 | expect(getJobTestsModule.getJobTests).toHaveBeenCalledWith({
224 | projectSlug: 'gh/org/repo',
225 | branch: 'main',
226 | jobNumber: undefined,
227 | });
228 | });
229 |
230 | it('should filter test results by success when filterByTestsResult is success', async () => {
231 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
232 | 'gh/org/repo',
233 | );
234 | vi.spyOn(projectDetection, 'getJobNumberFromURL').mockReturnValue(123);
235 |
236 | const mockTests = [
237 | {
238 | message: 'No failures',
239 | run_time: 0.5,
240 | file: 'src/test1.js',
241 | result: 'success',
242 | name: 'should pass test 1',
243 | classname: 'TestClass1',
244 | },
245 | {
246 | message: 'Test failed',
247 | run_time: 0.3,
248 | file: 'src/test2.js',
249 | result: 'failure',
250 | name: 'should fail test 2',
251 | classname: 'TestClass2',
252 | },
253 | {
254 | message: 'No failures',
255 | run_time: 0.4,
256 | file: 'src/test3.js',
257 | result: 'success',
258 | name: 'should pass test 3',
259 | classname: 'TestClass3',
260 | },
261 | ];
262 |
263 | vi.spyOn(getJobTestsModule, 'getJobTests').mockResolvedValue(mockTests);
264 |
265 | vi.spyOn(formatJobTestsModule, 'formatJobTests').mockReturnValue({
266 | content: [
267 | {
268 | type: 'text',
269 | text: 'Test results output',
270 | },
271 | ],
272 | });
273 |
274 | const args = {
275 | params: {
276 | projectURL:
277 | 'https://app.circleci.com/pipelines/gh/org/repo/123/workflows/abc-def/jobs/123',
278 | filterByTestsResult: 'success',
279 | },
280 | } as any;
281 |
282 | const controller = new AbortController();
283 | const response = await getJobTestResults(args, {
284 | signal: controller.signal,
285 | });
286 |
287 | expect(response).toHaveProperty('content');
288 | expect(Array.isArray(response.content)).toBe(true);
289 | expect(response.content[0]).toHaveProperty('type', 'text');
290 | expect(typeof response.content[0].text).toBe('string');
291 |
292 | expect(getJobTestsModule.getJobTests).toHaveBeenCalledWith({
293 | projectSlug: 'gh/org/repo',
294 | branch: undefined,
295 | jobNumber: 123,
296 | filterByTestsResult: 'success',
297 | });
298 | });
299 |
300 | it('should filter test results by failure when filterByTestsResult is failure', async () => {
301 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
302 | 'gh/org/repo',
303 | );
304 | vi.spyOn(projectDetection, 'getJobNumberFromURL').mockReturnValue(123);
305 |
306 | const mockTests = [
307 | {
308 | message: 'No failures',
309 | run_time: 0.5,
310 | file: 'src/test1.js',
311 | result: 'success',
312 | name: 'should pass test 1',
313 | classname: 'TestClass1',
314 | },
315 | {
316 | message: 'Test failed',
317 | run_time: 0.3,
318 | file: 'src/test2.js',
319 | result: 'failure',
320 | name: 'should fail test 2',
321 | classname: 'TestClass2',
322 | },
323 | {
324 | message: 'Test failed',
325 | run_time: 0.4,
326 | file: 'src/test3.js',
327 | result: 'failure',
328 | name: 'should fail test 3',
329 | classname: 'TestClass3',
330 | },
331 | ];
332 |
333 | vi.spyOn(getJobTestsModule, 'getJobTests').mockResolvedValue(mockTests);
334 |
335 | vi.spyOn(formatJobTestsModule, 'formatJobTests').mockReturnValue({
336 | content: [
337 | {
338 | type: 'text',
339 | text: 'Test results output',
340 | },
341 | ],
342 | });
343 |
344 | const args = {
345 | params: {
346 | projectURL:
347 | 'https://app.circleci.com/pipelines/gh/org/repo/123/workflows/abc-def/jobs/123',
348 | filterByTestsResult: 'failure',
349 | },
350 | } as any;
351 |
352 | const controller = new AbortController();
353 | const response = await getJobTestResults(args, {
354 | signal: controller.signal,
355 | });
356 |
357 | expect(response).toHaveProperty('content');
358 | expect(Array.isArray(response.content)).toBe(true);
359 | expect(response.content[0]).toHaveProperty('type', 'text');
360 | expect(typeof response.content[0].text).toBe('string');
361 |
362 | expect(getJobTestsModule.getJobTests).toHaveBeenCalledWith({
363 | projectSlug: 'gh/org/repo',
364 | branch: undefined,
365 | jobNumber: 123,
366 | filterByTestsResult: 'failure',
367 | });
368 | });
369 | });
370 |
```
--------------------------------------------------------------------------------
/src/tools/rerunWorkflow/handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { rerunWorkflow } from './handler.js';
3 | import * as client from '../../clients/client.js';
4 |
5 | vi.mock('../../clients/client.js');
6 |
7 | const failedWorkflowId = '00000000-0000-0000-0000-000000000000';
8 | const successfulWorkflowId = '11111111-1111-1111-1111-111111111111';
9 | const newWorkflowId = '11111111-1111-1111-1111-111111111111';
10 |
11 | function setupMockClient(
12 | workflowStatus,
13 | rerunResult = { workflow_id: newWorkflowId },
14 | ) {
15 | const mockCircleCIClient = {
16 | workflows: {
17 | getWorkflow: vi
18 | .fn()
19 | .mockResolvedValue(
20 | workflowStatus !== undefined ? { status: workflowStatus } : undefined,
21 | ),
22 | rerunWorkflow: vi.fn().mockResolvedValue(rerunResult),
23 | },
24 | };
25 | vi.spyOn(client, 'getCircleCIClient').mockReturnValue(
26 | mockCircleCIClient as any,
27 | );
28 | return mockCircleCIClient;
29 | }
30 |
31 | describe('rerunWorkflow', () => {
32 | describe('when rerunning a failed workflow', () => {
33 | beforeEach(() => {
34 | vi.resetAllMocks();
35 | });
36 | it('should return the new workflowId and url to the user if requested to be rerun with a given workflowId', async () => {
37 | const mockCircleCIClient = setupMockClient('failed');
38 | const controller = new AbortController();
39 | const result = await rerunWorkflow(
40 | {
41 | params: {
42 | workflowId: failedWorkflowId,
43 | },
44 | },
45 | {
46 | signal: controller.signal,
47 | },
48 | );
49 | expect(mockCircleCIClient.workflows.rerunWorkflow).toHaveBeenCalledWith({
50 | workflowId: failedWorkflowId,
51 | fromFailed: true,
52 | });
53 | expect(result).toEqual({
54 | content: [
55 | {
56 | type: 'text',
57 | text: `New workflowId is ${newWorkflowId} and [View Workflow in CircleCI](https://app.circleci.com/pipelines/workflows/11111111-1111-1111-1111-111111111111)`,
58 | },
59 | ],
60 | });
61 | });
62 |
63 | it('should return the new workflowId and url to the user if requested to be rerun with a given workflowURL', async () => {
64 | const mockCircleCIClient = setupMockClient('failed');
65 | const controller = new AbortController();
66 | const result = await rerunWorkflow(
67 | {
68 | params: {
69 | workflowURL: `https://app.circleci.com/pipelines/workflows/${failedWorkflowId}`,
70 | },
71 | },
72 | {
73 | signal: controller.signal,
74 | },
75 | );
76 | expect(mockCircleCIClient.workflows.rerunWorkflow).toHaveBeenCalledWith({
77 | workflowId: failedWorkflowId,
78 | fromFailed: true,
79 | });
80 | expect(result).toEqual({
81 | content: [
82 | {
83 | type: 'text',
84 | text: `New workflowId is ${newWorkflowId} and [View Workflow in CircleCI](https://app.circleci.com/pipelines/workflows/11111111-1111-1111-1111-111111111111)`,
85 | },
86 | ],
87 | });
88 | });
89 |
90 | it('should return the new workflowId and url to the user if requested to be rerun from start with a given workflowId', async () => {
91 | const mockCircleCIClient = setupMockClient('failed');
92 | const controller = new AbortController();
93 | const result = await rerunWorkflow(
94 | {
95 | params: {
96 | workflowId: failedWorkflowId,
97 | fromFailed: false,
98 | },
99 | },
100 | {
101 | signal: controller.signal,
102 | },
103 | );
104 | expect(mockCircleCIClient.workflows.rerunWorkflow).toHaveBeenCalledWith({
105 | workflowId: failedWorkflowId,
106 | fromFailed: false,
107 | });
108 | expect(result).toEqual({
109 | content: [
110 | {
111 | type: 'text',
112 | text: `New workflowId is ${newWorkflowId} and [View Workflow in CircleCI](https://app.circleci.com/pipelines/workflows/11111111-1111-1111-1111-111111111111)`,
113 | },
114 | ],
115 | });
116 | });
117 | });
118 |
119 | describe('when rerunning a successful workflow', () => {
120 | beforeEach(() => {
121 | vi.resetAllMocks();
122 | });
123 | it('should return an error if requested to be rerun from failed with a given workflowId', async () => {
124 | const mockCircleCIClient = setupMockClient('success');
125 | mockCircleCIClient.workflows.rerunWorkflow.mockResolvedValue(undefined);
126 | const controller = new AbortController();
127 | const response = await rerunWorkflow(
128 | {
129 | params: {
130 | workflowId: successfulWorkflowId,
131 | fromFailed: true,
132 | },
133 | },
134 | {
135 | signal: controller.signal,
136 | },
137 | );
138 | expect(mockCircleCIClient.workflows.rerunWorkflow).not.toHaveBeenCalled();
139 | expect(response).toEqual({
140 | isError: true,
141 | content: [
142 | {
143 | type: 'text',
144 | text: 'Workflow is not failed, cannot rerun from failed',
145 | },
146 | ],
147 | });
148 | });
149 | it('should return the new workflowId and url to the user if requested to be rerun from start with a given workflowId', async () => {
150 | const mockCircleCIClient = setupMockClient('success');
151 | const controller = new AbortController();
152 | const response = await rerunWorkflow(
153 | {
154 | params: {
155 | workflowId: successfulWorkflowId,
156 | fromFailed: false,
157 | },
158 | },
159 | {
160 | signal: controller.signal,
161 | },
162 | );
163 | expect(mockCircleCIClient.workflows.rerunWorkflow).toHaveBeenCalledWith({
164 | workflowId: successfulWorkflowId,
165 | fromFailed: false,
166 | });
167 | expect(response).toEqual({
168 | content: [
169 | {
170 | type: 'text',
171 | text: `New workflowId is ${newWorkflowId} and [View Workflow in CircleCI](https://app.circleci.com/pipelines/workflows/11111111-1111-1111-1111-111111111111)`,
172 | },
173 | ],
174 | });
175 | });
176 | it('should return the new workflowId and url to the user if requested to be rerun from start with a given workflowURL', async () => {
177 | const mockCircleCIClient = setupMockClient('success');
178 | const controller = new AbortController();
179 | const response = await rerunWorkflow(
180 | {
181 | params: {
182 | workflowURL: `https://app.circleci.com/pipelines/workflows/${successfulWorkflowId}`,
183 | fromFailed: false,
184 | },
185 | },
186 | {
187 | signal: controller.signal,
188 | },
189 | );
190 | expect(mockCircleCIClient.workflows.rerunWorkflow).toHaveBeenCalledWith({
191 | workflowId: successfulWorkflowId,
192 | fromFailed: false,
193 | });
194 | expect(response).toEqual({
195 | content: [
196 | {
197 | type: 'text',
198 | text: `New workflowId is ${newWorkflowId} and [View Workflow in CircleCI](https://app.circleci.com/pipelines/workflows/11111111-1111-1111-1111-111111111111)`,
199 | },
200 | ],
201 | });
202 | });
203 | it('should return the new workflowId and url to the user if requested to be rerun with a given workflowId and no explicit fromFailed', async () => {
204 | const mockCircleCIClient = setupMockClient('success');
205 | const controller = new AbortController();
206 | const response = await rerunWorkflow(
207 | {
208 | params: {
209 | workflowId: successfulWorkflowId,
210 | },
211 | },
212 | {
213 | signal: controller.signal,
214 | },
215 | );
216 | expect(mockCircleCIClient.workflows.rerunWorkflow).toHaveBeenCalledWith({
217 | workflowId: successfulWorkflowId,
218 | fromFailed: false,
219 | });
220 | expect(response).toEqual({
221 | content: [
222 | {
223 | type: 'text',
224 | text: `New workflowId is ${newWorkflowId} and [View Workflow in CircleCI](https://app.circleci.com/pipelines/workflows/11111111-1111-1111-1111-111111111111)`,
225 | },
226 | ],
227 | });
228 | });
229 | it('should return the new workflowId and url to the user if requested to be rerun from start with a given workflowURL and no explicit fromFailed', async () => {
230 | const mockCircleCIClient = setupMockClient('success');
231 | const controller = new AbortController();
232 | const response = await rerunWorkflow(
233 | {
234 | params: {
235 | workflowURL: `https://app.circleci.com/pipelines/workflows/${successfulWorkflowId}`,
236 | },
237 | },
238 | {
239 | signal: controller.signal,
240 | },
241 | );
242 | expect(mockCircleCIClient.workflows.rerunWorkflow).toHaveBeenCalledWith({
243 | workflowId: successfulWorkflowId,
244 | fromFailed: false,
245 | });
246 | expect(response).toEqual({
247 | content: [
248 | {
249 | type: 'text',
250 | text: `New workflowId is ${newWorkflowId} and [View Workflow in CircleCI](https://app.circleci.com/pipelines/workflows/11111111-1111-1111-1111-111111111111)`,
251 | },
252 | ],
253 | });
254 | });
255 | it('should return the new workflowId and url to the user if requested to be rerun with fromFailed: undefined with a given workflowId', async () => {
256 | const mockCircleCIClient = setupMockClient('success');
257 | const controller = new AbortController();
258 | const response = await rerunWorkflow(
259 | {
260 | params: {
261 | workflowId: successfulWorkflowId,
262 | fromFailed: undefined,
263 | },
264 | },
265 | {
266 | signal: controller.signal,
267 | },
268 | );
269 | expect(mockCircleCIClient.workflows.rerunWorkflow).toHaveBeenCalledWith({
270 | workflowId: successfulWorkflowId,
271 | fromFailed: false,
272 | });
273 | expect(response).toEqual({
274 | content: [
275 | {
276 | type: 'text',
277 | text: `New workflowId is ${newWorkflowId} and [View Workflow in CircleCI](https://app.circleci.com/pipelines/workflows/11111111-1111-1111-1111-111111111111)`,
278 | },
279 | ],
280 | });
281 | });
282 | });
283 |
284 | describe('edge cases and errors', () => {
285 | it('should return an error if both workflowId and workflowURL are missing', async () => {
286 | setupMockClient(undefined);
287 | const controller = new AbortController();
288 | const response = await rerunWorkflow(
289 | { params: {} },
290 | { signal: controller.signal },
291 | );
292 | expect(response).toEqual({
293 | isError: true,
294 | content: [
295 | {
296 | type: 'text',
297 | text: 'workflowId is required and could not be determined from workflowURL.',
298 | },
299 | ],
300 | });
301 | });
302 |
303 | it('should return an error if workflow is not found', async () => {
304 | setupMockClient(undefined);
305 | const controller = new AbortController();
306 | const response = await rerunWorkflow(
307 | { params: { workflowId: 'nonexistent-id' } },
308 | { signal: controller.signal },
309 | );
310 | expect(response).toEqual({
311 | isError: true,
312 | content: [
313 | {
314 | type: 'text',
315 | text: 'Workflow not found',
316 | },
317 | ],
318 | });
319 | });
320 |
321 | it('should return an error if workflowURL is invalid and cannot extract workflowId', async () => {
322 | const getWorkflowIdFromURL = await import(
323 | '../../lib/getWorkflowIdFromURL.js'
324 | );
325 | const spy = vi
326 | .spyOn(getWorkflowIdFromURL, 'getWorkflowIdFromURL')
327 | .mockReturnValue(undefined);
328 | setupMockClient(undefined);
329 | const controller = new AbortController();
330 | const response = await rerunWorkflow(
331 | { params: { workflowURL: 'invalid-url' } },
332 | { signal: controller.signal },
333 | );
334 | expect(response).toEqual({
335 | isError: true,
336 | content: [
337 | {
338 | type: 'text',
339 | text: 'workflowId is required and could not be determined from workflowURL.',
340 | },
341 | ],
342 | });
343 | spy.mockRestore();
344 | });
345 | });
346 | });
347 |
```
--------------------------------------------------------------------------------
/src/tools/runRollbackPipeline/handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { runRollbackPipeline } from './handler.js';
3 | import * as clientModule from '../../clients/client.js';
4 |
5 | vi.mock('../../clients/client.js');
6 |
7 | describe('runRollbackPipeline handler', () => {
8 | const mockCircleCIClient = {
9 | deploys: {
10 | runRollbackPipeline: vi.fn(),
11 | fetchProjectDeploySettings: vi.fn(),
12 | },
13 | projects: {
14 | getProject: vi.fn(),
15 | },
16 | };
17 |
18 | const mockExtra = {
19 | signal: new AbortController().signal,
20 | requestId: 'test-id',
21 | sendNotification: vi.fn(),
22 | sendRequest: vi.fn(),
23 | };
24 |
25 | beforeEach(() => {
26 | vi.resetAllMocks();
27 | vi.spyOn(clientModule, 'getCircleCIClient').mockReturnValue(
28 | mockCircleCIClient as any,
29 | );
30 | });
31 |
32 | describe('successful rollback pipeline execution', () => {
33 | it('should initiate rollback pipeline with all required parameters', async () => {
34 | const mockRollbackResponse = {
35 | id: 'rollback-123',
36 | rollback_type: 'PIPELINE',
37 | };
38 |
39 | mockCircleCIClient.deploys.fetchProjectDeploySettings.mockResolvedValue({
40 | rollback_pipeline_definition_id: 'rollback-def-123',
41 | });
42 | mockCircleCIClient.deploys.runRollbackPipeline.mockResolvedValue(mockRollbackResponse);
43 |
44 | const args = {
45 | params: {
46 | projectID: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
47 | environmentName: 'production',
48 | componentName: 'frontend',
49 | currentVersion: 'v1.2.0',
50 | targetVersion: 'v1.1.0',
51 | namespace: 'web-app',
52 | },
53 | } as any;
54 |
55 | const response = await runRollbackPipeline(args, mockExtra);
56 |
57 | expect(response).toHaveProperty('content');
58 | expect(Array.isArray(response.content)).toBe(true);
59 | expect(response.content[0]).toHaveProperty('type', 'text');
60 | expect(response.content[0].text).toBe('Rollback initiated successfully. ID: rollback-123, Type: PIPELINE');
61 |
62 | expect(mockCircleCIClient.deploys.runRollbackPipeline).toHaveBeenCalledTimes(1);
63 | expect(mockCircleCIClient.deploys.runRollbackPipeline).toHaveBeenCalledWith({
64 | projectID: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
65 | rollbackRequest: {
66 | environment_name: 'production',
67 | component_name: 'frontend',
68 | current_version: 'v1.2.0',
69 | target_version: 'v1.1.0',
70 | namespace: 'web-app',
71 | },
72 | });
73 | });
74 |
75 | it('should initiate rollback pipeline with optional reason parameter', async () => {
76 | const mockRollbackResponse = {
77 | id: 'rollback-456',
78 | rollback_type: 'PIPELINE',
79 | };
80 |
81 | mockCircleCIClient.deploys.fetchProjectDeploySettings.mockResolvedValue({
82 | rollback_pipeline_definition_id: 'rollback-def-456',
83 | });
84 | mockCircleCIClient.deploys.runRollbackPipeline.mockResolvedValue(mockRollbackResponse);
85 |
86 | const args = {
87 | params: {
88 | projectID: 'b2c3d4e5-f6g7-8901-bcde-fg2345678901',
89 | environmentName: 'staging',
90 | componentName: 'backend',
91 | currentVersion: 'v2.1.0',
92 | targetVersion: 'v2.0.0',
93 | namespace: 'api-service',
94 | reason: 'Critical bug fix required',
95 | },
96 | } as any;
97 |
98 | const response = await runRollbackPipeline(args, mockExtra);
99 |
100 | expect(response).toHaveProperty('content');
101 | expect(response.content[0].text).toBe('Rollback initiated successfully. ID: rollback-456, Type: PIPELINE');
102 |
103 | expect(mockCircleCIClient.deploys.runRollbackPipeline).toHaveBeenCalledWith({
104 | projectID: 'b2c3d4e5-f6g7-8901-bcde-fg2345678901',
105 | rollbackRequest: {
106 | environment_name: 'staging',
107 | component_name: 'backend',
108 | current_version: 'v2.1.0',
109 | target_version: 'v2.0.0',
110 | namespace: 'api-service',
111 | reason: 'Critical bug fix required',
112 | },
113 | });
114 | });
115 |
116 | it('should initiate rollback pipeline with optional parameters object', async () => {
117 | const mockRollbackResponse = {
118 | id: 'rollback-789',
119 | rollback_type: 'PIPELINE',
120 | };
121 |
122 | mockCircleCIClient.deploys.fetchProjectDeploySettings.mockResolvedValue({
123 | rollback_pipeline_definition_id: 'rollback-def-789',
124 | });
125 | mockCircleCIClient.deploys.runRollbackPipeline.mockResolvedValue(mockRollbackResponse);
126 |
127 | const args = {
128 | params: {
129 | projectID: 'c3d4e5f6-g7h8-9012-cdef-gh3456789012',
130 | environmentName: 'production',
131 | componentName: 'database',
132 | currentVersion: 'v3.2.0',
133 | targetVersion: 'v3.1.0',
134 | namespace: 'db-cluster',
135 | reason: 'Performance regression',
136 | parameters: {
137 | skip_migration: true,
138 | notify_team: 'devops',
139 | },
140 | },
141 | } as any;
142 |
143 | const response = await runRollbackPipeline(args, mockExtra);
144 |
145 | expect(response).toHaveProperty('content');
146 | expect(response.content[0].text).toBe('Rollback initiated successfully. ID: rollback-789, Type: PIPELINE');
147 |
148 | expect(mockCircleCIClient.deploys.runRollbackPipeline).toHaveBeenCalledWith({
149 | projectID: 'c3d4e5f6-g7h8-9012-cdef-gh3456789012',
150 | rollbackRequest: {
151 | environment_name: 'production',
152 | component_name: 'database',
153 | current_version: 'v3.2.0',
154 | target_version: 'v3.1.0',
155 | namespace: 'db-cluster',
156 | reason: 'Performance regression',
157 | parameters: {
158 | skip_migration: true,
159 | notify_team: 'devops',
160 | },
161 | },
162 | });
163 | });
164 |
165 | it('should initiate rollback pipeline using projectSlug', async () => {
166 | const mockRollbackResponse = {
167 | id: 'rollback-slug-123',
168 | rollback_type: 'PIPELINE',
169 | };
170 |
171 | mockCircleCIClient.projects.getProject.mockResolvedValue({
172 | id: 'resolved-project-id-123',
173 | organization_id: 'org-id-123',
174 | });
175 | mockCircleCIClient.deploys.fetchProjectDeploySettings.mockResolvedValue({
176 | rollback_pipeline_definition_id: 'rollback-def-slug-123',
177 | });
178 | mockCircleCIClient.deploys.runRollbackPipeline.mockResolvedValue(mockRollbackResponse);
179 |
180 | const args = {
181 | params: {
182 | projectSlug: 'gh/organization/project',
183 | environmentName: 'production',
184 | componentName: 'frontend',
185 | currentVersion: 'v1.2.0',
186 | targetVersion: 'v1.1.0',
187 | namespace: 'web-app',
188 | },
189 | } as any;
190 |
191 | const response = await runRollbackPipeline(args, mockExtra);
192 |
193 | expect(response).toHaveProperty('content');
194 | expect(Array.isArray(response.content)).toBe(true);
195 | expect(response.content[0]).toHaveProperty('type', 'text');
196 | expect(response.content[0].text).toBe('Rollback initiated successfully. ID: rollback-slug-123, Type: PIPELINE');
197 |
198 | expect(mockCircleCIClient.projects.getProject).toHaveBeenCalledWith({
199 | projectSlug: 'gh/organization/project',
200 | });
201 | expect(mockCircleCIClient.deploys.runRollbackPipeline).toHaveBeenCalledWith({
202 | projectID: 'resolved-project-id-123',
203 | rollbackRequest: {
204 | environment_name: 'production',
205 | component_name: 'frontend',
206 | current_version: 'v1.2.0',
207 | target_version: 'v1.1.0',
208 | namespace: 'web-app',
209 | },
210 | });
211 | });
212 | });
213 |
214 | describe('error handling', () => {
215 | it('should return error when API call fails with Error object', async () => {
216 | const errorMessage = 'Rollback pipeline not configured for this project';
217 | mockCircleCIClient.deploys.fetchProjectDeploySettings.mockResolvedValue({
218 | rollback_pipeline_definition_id: 'rollback-def-error',
219 | });
220 | mockCircleCIClient.deploys.runRollbackPipeline.mockRejectedValue(new Error(errorMessage));
221 |
222 | const args = {
223 | params: {
224 | projectID: 'e5f6g7h8-i9j0-1234-efgh-ij5678901234',
225 | environment_name: 'production',
226 | componentName: 'frontend',
227 | currentVersion: 'v2.0.0',
228 | targetVersion: 'v1.9.0',
229 | namespace: 'app',
230 | },
231 | } as any;
232 |
233 | const response = await runRollbackPipeline(args, mockExtra);
234 |
235 | expect(response).toHaveProperty('content');
236 | expect(Array.isArray(response.content)).toBe(true);
237 | expect(response.content[0]).toHaveProperty('type', 'text');
238 | expect(response.content[0].text).toContain('Failed to initiate rollback:');
239 | expect(response.content[0].text).toContain('Rollback pipeline not configured for this project');
240 | });
241 |
242 | it('should return error when API call fails with non-Error object', async () => {
243 | mockCircleCIClient.deploys.fetchProjectDeploySettings.mockResolvedValue({
244 | rollback_pipeline_definition_id: 'rollback-def-error2',
245 | });
246 | mockCircleCIClient.deploys.runRollbackPipeline.mockRejectedValue('String error');
247 |
248 | const args = {
249 | params: {
250 | projectID: 'f6g7h8i9-j0k1-2345-fghi-jk6789012345',
251 | environment_name: 'staging',
252 | component_name: 'backend',
253 | current_version: 'v3.0.0',
254 | target_version: 'v2.9.0',
255 | namespace: 'api',
256 | },
257 | } as any;
258 |
259 | const response = await runRollbackPipeline(args, mockExtra);
260 |
261 | expect(response).toHaveProperty('content');
262 | expect(response.content[0].text).toContain('Failed to initiate rollback:');
263 | expect(response.content[0].text).toContain('Unknown error');
264 | });
265 |
266 | it('should return error when projectSlug resolution fails', async () => {
267 | const errorMessage = 'Project not found';
268 | mockCircleCIClient.projects.getProject.mockRejectedValue(new Error(errorMessage));
269 |
270 | const args = {
271 | params: {
272 | projectSlug: 'gh/invalid/project',
273 | environmentName: 'production',
274 | componentName: 'frontend',
275 | currentVersion: 'v1.2.0',
276 | targetVersion: 'v1.1.0',
277 | namespace: 'web-app',
278 | },
279 | } as any;
280 |
281 | const response = await runRollbackPipeline(args, mockExtra);
282 |
283 | expect(response).toHaveProperty('content');
284 | expect(response.content[0].text).toContain('Failed to resolve project information for gh/invalid/project');
285 | expect(response.content[0].text).toContain('Project not found');
286 | });
287 |
288 | it('should return error when neither projectSlug nor projectID provided', async () => {
289 | const args = {
290 | params: {
291 | environmentName: 'production',
292 | componentName: 'frontend',
293 | currentVersion: 'v1.2.0',
294 | targetVersion: 'v1.1.0',
295 | namespace: 'web-app',
296 | },
297 | } as any;
298 |
299 | const response = await runRollbackPipeline(args, mockExtra);
300 |
301 | expect(response).toHaveProperty('content');
302 | expect(response.content[0].text).toContain('Either projectSlug or projectID must be provided');
303 | });
304 |
305 | it('should return the appropriate message when no rollback pipeline definition is configured', async () => {
306 | mockCircleCIClient.deploys.fetchProjectDeploySettings.mockResolvedValue({
307 | rollback_pipeline_definition_id: null,
308 | });
309 |
310 | const args = {
311 | params: {
312 | projectID: 'test-project-id',
313 | environmentName: 'production',
314 | componentName: 'frontend',
315 | currentVersion: 'v1.2.0',
316 | targetVersion: 'v1.1.0',
317 | namespace: 'web-app',
318 | },
319 | } as any;
320 |
321 | const response = await runRollbackPipeline(args, mockExtra);
322 |
323 | expect(response).toHaveProperty('content');
324 | expect(response.content[0].text).toContain('No rollback pipeline definition found for this project');
325 | expect(response.content[0].text).toContain('https://circleci.com/docs/deploy/rollback-a-project-using-the-rollback-pipeline/');
326 | });
327 | });
328 | });
329 |
```
--------------------------------------------------------------------------------
/src/tools/analyzeDiff/handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { FilterBy } from '../shared/constants.js';
3 | import { analyzeDiff } from './handler.js';
4 | import { analyzeDiffInputSchema } from './inputSchema.js';
5 | import { CircletClient } from '../../clients/circlet/index.js';
6 | import { RuleReview } from '../../clients/schemas.js';
7 |
8 | // Mock the CircletClient
9 | vi.mock('../../clients/circlet/index.js');
10 |
11 | describe('analyzeDiff', () => {
12 | beforeEach(() => {
13 | vi.clearAllMocks();
14 | });
15 |
16 | it('should return no rules message when rules is an empty string', async () => {
17 | const mockCircletInstance = {
18 | circlet: {
19 | ruleReview: vi.fn(),
20 | },
21 | };
22 |
23 | vi.mocked(CircletClient).mockImplementation(
24 | () => mockCircletInstance as any,
25 | );
26 |
27 | const mockArgs = {
28 | params: {
29 | speedMode: false,
30 | filterBy: FilterBy.none,
31 | diff: 'diff --git a/test.ts b/test.ts\n+console.log("test");',
32 | rules: '',
33 | },
34 | };
35 |
36 | const controller = new AbortController();
37 | const result = await analyzeDiff(mockArgs, { signal: controller.signal });
38 |
39 | expect(result).toEqual({
40 | content: [
41 | {
42 | type: 'text',
43 | text: 'No rules found. Please add rules to your repository.',
44 | },
45 | ],
46 | });
47 | });
48 |
49 | it('should return no diff message when diff is an empty string', async () => {
50 | const mockCircletInstance = {
51 | circlet: {
52 | ruleReview: vi.fn(),
53 | },
54 | };
55 |
56 | vi.mocked(CircletClient).mockImplementation(
57 | () => mockCircletInstance as any,
58 | );
59 |
60 | const mockArgs = {
61 | params: {
62 | speedMode: false,
63 | filterBy: FilterBy.none,
64 | diff: '',
65 | rules: '',
66 | },
67 | };
68 |
69 | const controller = new AbortController();
70 | const result = await analyzeDiff(mockArgs, { signal: controller.signal });
71 |
72 | expect(result).toEqual({
73 | content: [
74 | {
75 | type: 'text',
76 | text: 'No diff found. Please provide a diff to analyze.',
77 | },
78 | ],
79 | });
80 | });
81 |
82 | it('should handle complex diff content with multiple rules', async () => {
83 | const mockRuleReview: RuleReview = {
84 | isRuleCompliant: true,
85 | relatedRules: {
86 | compliant: [
87 | {
88 | rule: 'Rule 1: No console.log statements',
89 | reason: 'No console.log statements found',
90 | confidenceScore: 0.95,
91 | },
92 | ],
93 | violations: [],
94 | requiresHumanReview: [],
95 | },
96 | unrelatedRules: [],
97 | };
98 |
99 | const mockCircletInstance = {
100 | circlet: {
101 | ruleReview: vi.fn().mockResolvedValue(mockRuleReview),
102 | },
103 | };
104 |
105 | vi.mocked(CircletClient).mockImplementation(
106 | () => mockCircletInstance as any,
107 | );
108 |
109 | const mockArgs = {
110 | params: {
111 | speedMode: false,
112 | filterBy: FilterBy.none,
113 | diff: `diff --git a/src/component.ts b/src/component.ts
114 | index 1234567..abcdefg 100644
115 | --- a/src/component.ts
116 | +++ b/src/component.ts
117 | @@ -1,5 +1,8 @@
118 | export class Component {
119 | + private data: any = {};
120 | +
121 | constructor() {
122 | + console.log("Component created");
123 | }
124 | }`,
125 | rules: `Rule 1: No console.log statements
126 | Rule 2: Avoid using 'any' type
127 | Rule 3: Use proper TypeScript types
128 | ---
129 | Rule 4: All functions must have JSDoc comments`,
130 | },
131 | };
132 |
133 | const controller = new AbortController();
134 | const result = await analyzeDiff(mockArgs, { signal: controller.signal });
135 |
136 | expect(mockCircletInstance.circlet.ruleReview).toHaveBeenCalledWith({
137 | speedMode: false,
138 | filterBy: FilterBy.none,
139 | diff: mockArgs.params.diff,
140 | rules: mockArgs.params.rules,
141 | });
142 |
143 | expect(result).toEqual({
144 | content: [
145 | {
146 | type: 'text',
147 | text: 'All rules are compliant.',
148 | },
149 | ],
150 | });
151 | });
152 |
153 | it('should handle multiline rules and preserve formatting', async () => {
154 | const mockRuleReview: RuleReview = {
155 | isRuleCompliant: false,
156 | relatedRules: {
157 | compliant: [],
158 | violations: [
159 | {
160 | rule: 'No Console Logs',
161 | reason: 'Console.log statements found in code',
162 | confidenceScore: 0.98,
163 | violationInstances: [
164 | {
165 | file: 'src/component.ts',
166 | lineNumbersInDiff: ['2'],
167 | violatingCodeSnippet: 'console.log(x);',
168 | explanationOfViolation: 'Direct console.log usage',
169 | },
170 | ],
171 | },
172 | ],
173 | requiresHumanReview: [],
174 | },
175 | unrelatedRules: [],
176 | };
177 |
178 | const mockCircletInstance = {
179 | circlet: {
180 | ruleReview: vi.fn().mockResolvedValue(mockRuleReview),
181 | },
182 | };
183 |
184 | vi.mocked(CircletClient).mockImplementation(
185 | () => mockCircletInstance as any,
186 | );
187 |
188 | const mockArgs = {
189 | params: {
190 | speedMode: false,
191 | filterBy: FilterBy.none,
192 | diff: '+const x = 5;\n+console.log(x);',
193 | rules: `# IDE Rules Example
194 |
195 | ## Rule: No Console Logs
196 | Description: Remove all console.log statements before committing code.
197 |
198 | ## Rule: TypeScript Safety
199 | Description: Avoid using 'any' type.`,
200 | },
201 | };
202 |
203 | const controller = new AbortController();
204 | const result = await analyzeDiff(mockArgs, { signal: controller.signal });
205 |
206 | expect(mockCircletInstance.circlet.ruleReview).toHaveBeenCalledWith({
207 | speedMode: false,
208 | filterBy: FilterBy.none,
209 | diff: mockArgs.params.diff,
210 | rules: mockArgs.params.rules,
211 | });
212 |
213 | expect(result.content[0].type).toBe('text');
214 | expect(result.content[0].text).toContain('Rule: No Console Logs');
215 | expect(result.content[0].text).toContain(
216 | 'Reason: Console.log statements found in code',
217 | );
218 | expect(result.content[0].text).toContain('Confidence Score: 0.98');
219 | });
220 |
221 | it('should return compliant message when all rules are followed', async () => {
222 | const mockRuleReview: RuleReview = {
223 | isRuleCompliant: true,
224 | relatedRules: {
225 | compliant: [
226 | {
227 | rule: 'No console.log statements',
228 | reason: 'Code follows proper logging practices',
229 | confidenceScore: 0.95,
230 | },
231 | ],
232 | violations: [],
233 | requiresHumanReview: [],
234 | },
235 | unrelatedRules: [],
236 | };
237 |
238 | const mockCircletInstance = {
239 | circlet: {
240 | ruleReview: vi.fn().mockResolvedValue(mockRuleReview),
241 | },
242 | };
243 |
244 | vi.mocked(CircletClient).mockImplementation(
245 | () => mockCircletInstance as any,
246 | );
247 |
248 | const mockArgs = {
249 | params: {
250 | speedMode: false,
251 | filterBy: FilterBy.none,
252 | diff: 'diff --git a/test.ts b/test.ts\n+const logger = new Logger();',
253 | rules: 'Rule 1: No console.log statements\nRule 2: Use proper logging',
254 | },
255 | };
256 |
257 | const controller = new AbortController();
258 | const result = await analyzeDiff(mockArgs, { signal: controller.signal });
259 |
260 | expect(mockCircletInstance.circlet.ruleReview).toHaveBeenCalledWith({
261 | speedMode: false,
262 | filterBy: FilterBy.none,
263 | diff: mockArgs.params.diff,
264 | rules: mockArgs.params.rules,
265 | });
266 |
267 | expect(result).toEqual({
268 | content: [
269 | {
270 | type: 'text',
271 | text: 'All rules are compliant.',
272 | },
273 | ],
274 | });
275 | });
276 |
277 | it('should return formatted violations when rules are violated', async () => {
278 | const mockRuleReview: RuleReview = {
279 | isRuleCompliant: false,
280 | relatedRules: {
281 | compliant: [],
282 | violations: [
283 | {
284 | rule: 'No console.log statements',
285 | reason: 'Console.log statements found in the code',
286 | confidenceScore: 0.98,
287 | violationInstances: [
288 | {
289 | file: 'src/component.ts',
290 | lineNumbersInDiff: ['5'],
291 | violatingCodeSnippet: 'console.log("test");',
292 | explanationOfViolation: 'Direct console.log usage',
293 | },
294 | ],
295 | },
296 | {
297 | rule: 'Avoid using any type',
298 | reason: 'Any type usage reduces type safety',
299 | confidenceScore: 0.92,
300 | violationInstances: [
301 | {
302 | file: 'src/component.ts',
303 | lineNumbersInDiff: ['3'],
304 | violatingCodeSnippet: 'private data: any = {};',
305 | explanationOfViolation: 'Variable declared with any type',
306 | },
307 | ],
308 | },
309 | ],
310 | requiresHumanReview: [],
311 | },
312 | unrelatedRules: [],
313 | };
314 |
315 | const mockCircletInstance = {
316 | circlet: {
317 | ruleReview: vi.fn().mockResolvedValue(mockRuleReview),
318 | },
319 | };
320 |
321 | vi.mocked(CircletClient).mockImplementation(
322 | () => mockCircletInstance as any,
323 | );
324 |
325 | const mockArgs = {
326 | params: {
327 | speedMode: false,
328 | filterBy: FilterBy.none,
329 | diff: `diff --git a/src/component.ts b/src/component.ts
330 | index 1234567..abcdefg 100644
331 | --- a/src/component.ts
332 | +++ b/src/component.ts
333 | @@ -1,5 +1,8 @@
334 | export class Component {
335 | + private data: any = {};
336 | +
337 | constructor() {
338 | + console.log("Component created");
339 | }
340 | }`,
341 | rules: `Rule 1: No console.log statements
342 | Rule 2: Avoid using 'any' type
343 | Rule 3: Use proper TypeScript types`,
344 | },
345 | };
346 |
347 | const controller = new AbortController();
348 | const result = await analyzeDiff(mockArgs, { signal: controller.signal });
349 |
350 | expect(mockCircletInstance.circlet.ruleReview).toHaveBeenCalledWith({
351 | filterBy: FilterBy.none,
352 | speedMode: false,
353 | diff: mockArgs.params.diff,
354 | rules: mockArgs.params.rules,
355 | });
356 |
357 | expect(result).toEqual({
358 | content: [
359 | {
360 | type: 'text',
361 | text: `Rule: No console.log statements
362 | Reason: Console.log statements found in the code
363 | Confidence Score: 0.98
364 |
365 | Rule: Avoid using any type
366 | Reason: Any type usage reduces type safety
367 | Confidence Score: 0.92`,
368 | },
369 | ],
370 | });
371 | });
372 |
373 | it('should handle single violation correctly', async () => {
374 | const mockRuleReview: RuleReview = {
375 | isRuleCompliant: false,
376 | relatedRules: {
377 | compliant: [],
378 | violations: [
379 | {
380 | rule: 'No magic numbers',
381 | reason: 'Magic numbers make code less maintainable',
382 | confidenceScore: 0.85,
383 | violationInstances: [
384 | {
385 | file: 'src/component.ts',
386 | lineNumbersInDiff: ['2'],
387 | violatingCodeSnippet: 'const timeout = 5000;',
388 | explanationOfViolation: 'Hardcoded timeout value',
389 | },
390 | ],
391 | },
392 | ],
393 | requiresHumanReview: [],
394 | },
395 | unrelatedRules: [],
396 | };
397 |
398 | const mockCircletInstance = {
399 | circlet: {
400 | ruleReview: vi.fn().mockResolvedValue(mockRuleReview),
401 | },
402 | };
403 |
404 | vi.mocked(CircletClient).mockImplementation(
405 | () => mockCircletInstance as any,
406 | );
407 |
408 | const mockArgs = {
409 | params: {
410 | speedMode: false,
411 | filterBy: FilterBy.none,
412 | diff: '+const timeout = 5000;',
413 | rules: 'Rule: No magic numbers',
414 | },
415 | };
416 |
417 | const controller = new AbortController();
418 | const result = await analyzeDiff(mockArgs, { signal: controller.signal });
419 |
420 | expect(result).toEqual({
421 | content: [
422 | {
423 | type: 'text',
424 | text: `Rule: No magic numbers
425 | Reason: Magic numbers make code less maintainable
426 | Confidence Score: 0.85`,
427 | },
428 | ],
429 | });
430 | });
431 |
432 | it('should set default values for speedMode and filterBy when not provided', async () => {
433 | const mockRuleReview: RuleReview = {
434 | isRuleCompliant: true,
435 | relatedRules: {
436 | compliant: [],
437 | violations: [],
438 | requiresHumanReview: [],
439 | },
440 | unrelatedRules: [],
441 | };
442 |
443 | const mockCircletInstance = {
444 | circlet: {
445 | ruleReview: vi.fn().mockResolvedValue(mockRuleReview),
446 | },
447 | };
448 |
449 | vi.mocked(CircletClient).mockImplementation(
450 | () => mockCircletInstance as any,
451 | );
452 |
453 | const rawParams = {
454 | diff: '+const timeout = 5000;',
455 | rules: 'Rule: No magic numbers',
456 | };
457 |
458 | const parsedParams = analyzeDiffInputSchema.parse(rawParams);
459 | const mockArgs = {
460 | params: parsedParams,
461 | };
462 |
463 | const controller = new AbortController();
464 | await analyzeDiff(mockArgs, { signal: controller.signal });
465 |
466 | // Verify default values (filterBy: FilterBy.none & speedMode: false) are applied when not explictly stated
467 | expect(mockCircletInstance.circlet.ruleReview).toHaveBeenCalledWith({
468 | diff: rawParams.diff,
469 | rules: rawParams.rules,
470 | filterBy: FilterBy.none,
471 | speedMode: false,
472 | });
473 | });
474 | });
475 |
```
--------------------------------------------------------------------------------
/src/tools/runPipeline/handler.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
2 | import { runPipeline } from './handler.js';
3 | import * as projectDetection from '../../lib/project-detection/index.js';
4 | import * as clientModule from '../../clients/client.js';
5 |
6 | vi.mock('../../lib/project-detection/index.js');
7 | vi.mock('../../clients/client.js');
8 |
9 | describe('runPipeline handler', () => {
10 | const mockCircleCIClient = {
11 | projects: {
12 | getProject: vi.fn(),
13 | },
14 | pipelines: {
15 | getPipelineDefinitions: vi.fn(),
16 | runPipeline: vi.fn(),
17 | },
18 | };
19 |
20 | beforeEach(() => {
21 | vi.resetAllMocks();
22 | vi.spyOn(clientModule, 'getCircleCIClient').mockReturnValue(
23 | mockCircleCIClient as any,
24 | );
25 | });
26 |
27 | it('should return a valid MCP error response when no inputs are provided', async () => {
28 | const args = {
29 | params: {},
30 | } as any;
31 |
32 | const controller = new AbortController();
33 | const response = await runPipeline(args, {
34 | signal: controller.signal,
35 | });
36 |
37 | expect(response).toHaveProperty('content');
38 | expect(response).toHaveProperty('isError', true);
39 | expect(Array.isArray(response.content)).toBe(true);
40 | expect(response.content[0]).toHaveProperty('type', 'text');
41 | expect(typeof response.content[0].text).toBe('string');
42 | });
43 |
44 | it('should return a valid MCP error response when project is not found', async () => {
45 | vi.spyOn(projectDetection, 'identifyProjectSlug').mockResolvedValue(
46 | undefined,
47 | );
48 |
49 | const args = {
50 | params: {
51 | workspaceRoot: '/workspace',
52 | gitRemoteURL: 'https://github.com/org/repo.git',
53 | branch: 'main',
54 | },
55 | } as any;
56 |
57 | const controller = new AbortController();
58 | const response = await runPipeline(args, {
59 | signal: controller.signal,
60 | });
61 |
62 | expect(response).toHaveProperty('content');
63 | expect(response).toHaveProperty('isError', true);
64 | expect(Array.isArray(response.content)).toBe(true);
65 | expect(response.content[0]).toHaveProperty('type', 'text');
66 | expect(typeof response.content[0].text).toBe('string');
67 | });
68 |
69 | it('should return a valid MCP error response when no branch is provided', async () => {
70 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
71 | 'gh/org/repo',
72 | );
73 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue(undefined);
74 |
75 | const args = {
76 | params: {
77 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
78 | },
79 | } as any;
80 |
81 | const controller = new AbortController();
82 | const response = await runPipeline(args, {
83 | signal: controller.signal,
84 | });
85 |
86 | expect(response).toHaveProperty('content');
87 | expect(response).toHaveProperty('isError', true);
88 | expect(Array.isArray(response.content)).toBe(true);
89 | expect(response.content[0]).toHaveProperty('type', 'text');
90 | expect(typeof response.content[0].text).toBe('string');
91 | expect(response.content[0].text).toContain('No branch provided');
92 | });
93 |
94 | it('should return a valid MCP error response when projectSlug is provided without branch', async () => {
95 | const args = {
96 | params: {
97 | projectSlug: 'gh/org/repo',
98 | // No branch provided
99 | },
100 | } as any;
101 |
102 | const controller = new AbortController();
103 | const response = await runPipeline(args, {
104 | signal: controller.signal,
105 | });
106 |
107 | expect(response).toHaveProperty('content');
108 | expect(response).toHaveProperty('isError', true);
109 | expect(Array.isArray(response.content)).toBe(true);
110 | expect(response.content[0]).toHaveProperty('type', 'text');
111 | expect(response.content[0].text).toContain('Branch not provided');
112 |
113 | // Verify that CircleCI API was not called
114 | expect(mockCircleCIClient.projects.getProject).not.toHaveBeenCalled();
115 | expect(
116 | mockCircleCIClient.pipelines.getPipelineDefinitions,
117 | ).not.toHaveBeenCalled();
118 | expect(mockCircleCIClient.pipelines.runPipeline).not.toHaveBeenCalled();
119 | });
120 |
121 | it('should return a valid MCP error response when no pipeline definitions are found', async () => {
122 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
123 | 'gh/org/repo',
124 | );
125 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue('main');
126 |
127 | mockCircleCIClient.projects.getProject.mockResolvedValue({
128 | id: 'project-id',
129 | });
130 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([]);
131 |
132 | const args = {
133 | params: {
134 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
135 | },
136 | } as any;
137 |
138 | const controller = new AbortController();
139 | const response = await runPipeline(args, {
140 | signal: controller.signal,
141 | });
142 |
143 | expect(response).toHaveProperty('content');
144 | expect(response).toHaveProperty('isError', true);
145 | expect(Array.isArray(response.content)).toBe(true);
146 | expect(response.content[0]).toHaveProperty('type', 'text');
147 | expect(typeof response.content[0].text).toBe('string');
148 | expect(response.content[0].text).toContain('No pipeline definitions found');
149 | });
150 |
151 | it('should return a list of pipeline choices when multiple pipeline definitions are found and no choice is provided', async () => {
152 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
153 | 'gh/org/repo',
154 | );
155 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue('main');
156 |
157 | mockCircleCIClient.projects.getProject.mockResolvedValue({
158 | id: 'project-id',
159 | });
160 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
161 | { id: 'def1', name: 'Pipeline 1' },
162 | { id: 'def2', name: 'Pipeline 2' },
163 | ]);
164 |
165 | const args = {
166 | params: {
167 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
168 | },
169 | } as any;
170 |
171 | const controller = new AbortController();
172 | const response = await runPipeline(args, {
173 | signal: controller.signal,
174 | });
175 |
176 | expect(response).toHaveProperty('content');
177 | expect(Array.isArray(response.content)).toBe(true);
178 | expect(response.content[0]).toHaveProperty('type', 'text');
179 | expect(typeof response.content[0].text).toBe('string');
180 | expect(response.content[0].text).toContain(
181 | 'Multiple pipeline definitions found',
182 | );
183 | expect(response.content[0].text).toContain('Pipeline 1');
184 | expect(response.content[0].text).toContain('Pipeline 2');
185 | });
186 |
187 | it('should return an error when an invalid pipeline choice is provided', async () => {
188 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
189 | 'gh/org/repo',
190 | );
191 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue('main');
192 |
193 | mockCircleCIClient.projects.getProject.mockResolvedValue({
194 | id: 'project-id',
195 | });
196 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
197 | { id: 'def1', name: 'Pipeline 1' },
198 | { id: 'def2', name: 'Pipeline 2' },
199 | ]);
200 |
201 | const args = {
202 | params: {
203 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
204 | pipelineChoiceName: 'Non-existent Pipeline',
205 | },
206 | } as any;
207 |
208 | const controller = new AbortController();
209 | const response = await runPipeline(args, {
210 | signal: controller.signal,
211 | });
212 |
213 | expect(response).toHaveProperty('content');
214 | expect(response).toHaveProperty('isError', true);
215 | expect(Array.isArray(response.content)).toBe(true);
216 | expect(response.content[0]).toHaveProperty('type', 'text');
217 | expect(typeof response.content[0].text).toBe('string');
218 | expect(response.content[0].text).toContain(
219 | 'Pipeline definition with name Non-existent Pipeline not found',
220 | );
221 | });
222 |
223 | it('should run a pipeline with a specific choice when valid pipeline choice is provided', async () => {
224 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
225 | 'gh/org/repo',
226 | );
227 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue('main');
228 |
229 | mockCircleCIClient.projects.getProject.mockResolvedValue({
230 | id: 'project-id',
231 | });
232 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
233 | { id: 'def1', name: 'Pipeline 1' },
234 | { id: 'def2', name: 'Pipeline 2' },
235 | ]);
236 | mockCircleCIClient.pipelines.runPipeline.mockResolvedValue({
237 | number: 123,
238 | state: 'pending',
239 | id: 'pipeline-id',
240 | });
241 |
242 | const args = {
243 | params: {
244 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
245 | pipelineChoiceName: 'Pipeline 2',
246 | },
247 | } as any;
248 |
249 | const controller = new AbortController();
250 | const response = await runPipeline(args, {
251 | signal: controller.signal,
252 | });
253 |
254 | expect(response).toHaveProperty('content');
255 | expect(Array.isArray(response.content)).toBe(true);
256 | expect(response.content[0]).toHaveProperty('type', 'text');
257 | expect(typeof response.content[0].text).toBe('string');
258 | expect(response.content[0].text).toContain('Pipeline run successfully');
259 | expect(mockCircleCIClient.pipelines.runPipeline).toHaveBeenCalledWith({
260 | projectSlug: 'gh/org/repo',
261 | branch: 'main',
262 | definitionId: 'def2',
263 | });
264 | });
265 |
266 | it('should run a pipeline with the first choice when only one pipeline definition is found', async () => {
267 | vi.spyOn(projectDetection, 'getProjectSlugFromURL').mockReturnValue(
268 | 'gh/org/repo',
269 | );
270 | vi.spyOn(projectDetection, 'getBranchFromURL').mockReturnValue('main');
271 |
272 | mockCircleCIClient.projects.getProject.mockResolvedValue({
273 | id: 'project-id',
274 | });
275 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
276 | { id: 'def1', name: 'Pipeline 1' },
277 | ]);
278 | mockCircleCIClient.pipelines.runPipeline.mockResolvedValue({
279 | number: 123,
280 | state: 'pending',
281 | id: 'pipeline-id',
282 | });
283 |
284 | const args = {
285 | params: {
286 | projectURL: 'https://app.circleci.com/pipelines/gh/org/repo',
287 | },
288 | } as any;
289 |
290 | const controller = new AbortController();
291 | const response = await runPipeline(args, {
292 | signal: controller.signal,
293 | });
294 |
295 | expect(response).toHaveProperty('content');
296 | expect(Array.isArray(response.content)).toBe(true);
297 | expect(response.content[0]).toHaveProperty('type', 'text');
298 | expect(typeof response.content[0].text).toBe('string');
299 | expect(response.content[0].text).toContain('Pipeline run successfully');
300 | expect(mockCircleCIClient.pipelines.runPipeline).toHaveBeenCalledWith({
301 | projectSlug: 'gh/org/repo',
302 | branch: 'main',
303 | definitionId: 'def1',
304 | });
305 | });
306 |
307 | it('should detect project from git remote and run pipeline', async () => {
308 | vi.spyOn(projectDetection, 'identifyProjectSlug').mockResolvedValue(
309 | 'gh/org/repo',
310 | );
311 |
312 | mockCircleCIClient.projects.getProject.mockResolvedValue({
313 | id: 'project-id',
314 | });
315 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
316 | { id: 'def1', name: 'Pipeline 1' },
317 | ]);
318 | mockCircleCIClient.pipelines.runPipeline.mockResolvedValue({
319 | number: 123,
320 | state: 'pending',
321 | id: 'pipeline-id',
322 | });
323 |
324 | const args = {
325 | params: {
326 | workspaceRoot: '/workspace',
327 | gitRemoteURL: 'https://github.com/org/repo.git',
328 | branch: 'feature-branch',
329 | },
330 | } as any;
331 |
332 | const controller = new AbortController();
333 | const response = await runPipeline(args, {
334 | signal: controller.signal,
335 | });
336 |
337 | expect(response).toHaveProperty('content');
338 | expect(Array.isArray(response.content)).toBe(true);
339 | expect(response.content[0]).toHaveProperty('type', 'text');
340 | expect(typeof response.content[0].text).toBe('string');
341 | expect(response.content[0].text).toContain('Pipeline run successfully');
342 | expect(mockCircleCIClient.pipelines.runPipeline).toHaveBeenCalledWith({
343 | projectSlug: 'gh/org/repo',
344 | branch: 'feature-branch',
345 | definitionId: 'def1',
346 | });
347 | });
348 |
349 | it('should run a pipeline using projectSlug and branch inputs correctly', async () => {
350 | mockCircleCIClient.projects.getProject.mockResolvedValue({
351 | id: 'project-id',
352 | });
353 | mockCircleCIClient.pipelines.getPipelineDefinitions.mockResolvedValue([
354 | { id: 'def1', name: 'Pipeline 1' },
355 | ]);
356 | mockCircleCIClient.pipelines.runPipeline.mockResolvedValue({
357 | number: 123,
358 | state: 'pending',
359 | id: 'pipeline-id',
360 | });
361 |
362 | const args = {
363 | params: {
364 | projectSlug: 'gh/org/repo',
365 | branch: 'feature/new-feature',
366 | },
367 | } as any;
368 |
369 | const controller = new AbortController();
370 | const response = await runPipeline(args, {
371 | signal: controller.signal,
372 | });
373 |
374 | expect(mockCircleCIClient.projects.getProject).toHaveBeenCalledWith({
375 | projectSlug: 'gh/org/repo',
376 | });
377 |
378 | expect(mockCircleCIClient.pipelines.runPipeline).toHaveBeenCalledWith({
379 | projectSlug: 'gh/org/repo',
380 | branch: 'feature/new-feature',
381 | definitionId: 'def1',
382 | });
383 |
384 | expect(response).toHaveProperty('content');
385 | expect(Array.isArray(response.content)).toBe(true);
386 | expect(response.content[0]).toHaveProperty('type', 'text');
387 | expect(typeof response.content[0].text).toBe('string');
388 | expect(response.content[0].text).toContain('Pipeline run successfully');
389 | });
390 | });
391 |
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [0.14.1] - 2025-09-17
9 |
10 | ### Fixed
11 |
12 | - Output log messages to stderr instead of stdout, following MCP server specification. This avoids MCP server startup issues in some agents, such as Warp. Fixes #125
13 |
14 | ## [0.14.0] - 2025-08-11
15 |
16 | ### Added
17 |
18 | - Added `download_usage_api_data` tool to start and retrieve CircleCI usage export jobs
19 |
20 | - Added `find_underused_resource_classes` tool to analyze usage CSV for underused resource classes (default threshold 40%)
21 |
22 | ## [0.13.0] - 2025-08-05
23 |
24 | ### Added
25 |
26 | - Added `listComponentVersions` tool to list all versions for a CircleCI component
27 |
28 | ### Changed
29 |
30 | - Simplified `runRollbackPipeline` tool by moving part of its inner logic to `listComponentVersions`.
31 |
32 | ## [0.12.2] - 2025-08-01
33 |
34 | ### Added
35 |
36 | - Added support for rerunning workflow in `runRollbackPipeline` when no rollback pipeline is defined.
37 |
38 | ## [0.12.1] - 2025-07-30
39 |
40 | ### Fixed
41 |
42 | - Fixed `runRollbackPipeline` tool to not suggest other projects
43 |
44 | ## [0.12.0] - 2025-07-29
45 |
46 | ### Added
47 |
48 | - Added `runRollbackPipeline` tool to run a rollback pipeline
49 |
50 | ## [0.11.4] - 2025-07-28
51 |
52 | ### Fixed
53 |
54 | - Remove `tool/` prefix from tool list entries, this was breaking tool name resolution in some MCP clients.
55 |
56 | ## [0.11.3] - 2025-07-24
57 |
58 | ### Changed
59 |
60 | - Make the diff reviewer tool work with other IDE based rule systems.
61 |
62 | ## [0.11.2] - 2025-06-20
63 |
64 | ### Added
65 |
66 | - Add `file` property to the `RuleReviewSchema` to align with updated endpoint
67 |
68 | ## [0.11.1] - 2025-06-18
69 |
70 | ### Fixed
71 |
72 | - Fixed bug in `get_flaky_tests` tool where unrelated tests were being returned
73 | - Fixed bug in `get_flaky_tests` tool where if the output directory cannot be created the tool would respond with an error. We now throw in that case, which makes us fallback to the text output.
74 |
75 | ## [0.11.0] - 2025-06-18
76 |
77 | ### Fixed
78 |
79 | - Fixed bug in `get_flaky_tests` tool where the same job number was being fetched multiple times
80 | - Fixed bug in `get_flaky_tests` where the output directory was not being created when using file output mode
81 |
82 | ## [0.10.2] - 2025-06-18
83 |
84 | ### Added
85 |
86 | - Add `speedMode` and `filterBy` parameters to the `analyze_diff` tool
87 |
88 | ## [0.10.1] - 2025-06-17
89 |
90 | ### Fixed
91 |
92 | - Add a .gitignore file to the flaky-tests-output directory to ignore all files in the directory
93 |
94 | ## [0.10.0] - 2025-06-17
95 |
96 | ### Added
97 |
98 | - Added `USE_FILE_OUTPUT` environment variable to `get_flaky_tests` tool
99 | - When set to `true`, the tool will write flaky tests to files in the `./flaky-tests-output` directory instead of returning the results in the response
100 | - The tool will return the file paths of the written files in the response
101 |
102 | ## [0.9.2] - 2025-06-17
103 |
104 | ### Added
105 |
106 | - Anthropic support on prompt eval script (w. auto-detection for OpenAI and Anthropic models)
107 | - Added `temperature` parameter support to prompt template tools
108 | - Enhanced `create_prompt_template` tool with configurable temperature setting
109 | - Enhanced `recommend_prompt_template_tests` tool with temperature parameter
110 | - Default temperature value set to 1.0 for consistent prompt template generation
111 |
112 | ### Updated
113 |
114 | - Updated default model from `gpt-4o-mini` to `gpt-4.1-mini` for prompt template tools
115 | - Enhanced evaluation script dependencies for improved compatibility
116 | - Updated `deepeval` to version 3.0.3+ (from 2.8.2+)
117 | - Updated `openai` to version 1.84.0+ (from 1.76.2+)
118 | - Added `anthropic` version 0.54.0+ for Anthropic model support
119 | - Updated `PyYAML` to version 6.0.2+
120 |
121 | ## [0.9.1] - 2025-06-12
122 |
123 | ### Added
124 |
125 | - Added `analyze_diff` tool to analyze git diffs against cursor rules to identify rule violations
126 | - Evaluates code changes against repository coding standards and best practices
127 | - Provides detailed violation reports with confidence scores and explanations
128 | - Supports both staged and unstaged changes and all changes analysis
129 | - Returns actionable feedback for maintaining code quality consistency
130 |
131 | ## [0.9.0] - 2025-06-03
132 |
133 | ### Added
134 |
135 | - Added `run_evaluation_tests` tool to run evaluation tests on CircleCI pipelines
136 | - Support for running prompt template evaluation tests in CircleCI
137 | - Integration with prompt template files from `./prompts` directory
138 | - Dynamic CircleCI configuration generation for evaluation workflows
139 | - Support for multiple prompt files with automatic parallelism configuration
140 | - Compatible with both JSON and YAML prompt template formats
141 | - Comprehensive error handling and validation for prompt template files
142 | - Enhanced `runPipeline` API to support custom configuration content
143 | - Added `configContent` parameter to override default pipeline configuration
144 | - Enables dynamic pipeline configuration for specialized use cases
145 |
146 | ## [0.8.1] - 2025-05-28
147 |
148 | ### Added
149 |
150 | - Enhanced prompt template tools with support for existing codebase prompts
151 | - Added `promptOrigin` parameter to distinguish between new requirements and existing codebase prompts
152 | - Added `model` parameter to specify target model for testing (defaults to gpt-4o-mini)
153 | - Enhanced documentation and examples for prompt template creation
154 | - Added integration guidance for codebase-sourced prompts
155 | - Improved prompt templates file location, naming conventions, and structure
156 |
157 | ## [0.8.0] - 2025-05-22
158 |
159 | ### Added
160 |
161 | - Added `rerun_workflow` tool to rerun a workflow from its start or from the failed job
162 |
163 | ## [0.7.1] - 2025-05-14
164 |
165 | ### Updated
166 |
167 | - Updated `get_build_failure_logs`, `get_job_test_results`, and `get_latest_pipeline_status` tools to require a branch parameter when using projectSlug option
168 |
169 | ## [0.7.0] - 2025-05-13
170 |
171 | ### Added
172 |
173 | - Added `list_followed_projects` tool to list all projects that the user is following on CircleCI
174 |
175 | ## [0.6.2] - 2025-05-13
176 |
177 | ### Fixed
178 |
179 | - Fixed `get_job_test_results` tool to filter tests by result when a job number is provided
180 |
181 | ## [0.6.1] - 2025-05-13
182 |
183 | ### Updated
184 |
185 | - Updated `get_build_failure_logs` tool to support legacy job url format like `https://circleci.com/gh/organization/project/123`
186 |
187 | ## [0.6.0] - 2025-05-13
188 |
189 | ### Added
190 |
191 | - Added `filterByTestsResult` parameter to `get_job_test_results` tool
192 | - Filter the tests by result
193 | - Support for filtering by `failure` or `success`
194 |
195 | ## [0.5.1] - 2025-05-12
196 |
197 | ### Added
198 |
199 | - Fix handling of legacy job url format in tools
200 | - Fix handling of pagination of test results when no test results are found
201 |
202 | ## [0.5.0] - 2025-05-09
203 |
204 | ### Added
205 |
206 | - Added `run_pipeline` tool to run a pipeline
207 | - Support for triggering pipelines using project URL or local git repository context
208 | - Branch detection from URLs or local git context
209 | - Handles multiple pipeline definitions with interactive selection
210 | - Provides direct link to monitor pipeline execution
211 |
212 | ## [0.4.4] - 2025-05-08
213 |
214 | ### Fixed
215 |
216 | - Fixed project detection and pipeline number extraction from URLs with custom server domains
217 |
218 | ## [0.4.3] - 2025-05-08
219 |
220 | ### Fixed
221 |
222 | - Fixed project detection when branch is provided in URL but not in params
223 | - Improved error handling for failed pipeline workflow fetches
224 | - Enhanced error messaging when project is not found or inputs are missing
225 |
226 | ## [0.4.2] - 2025-05-08
227 |
228 | ### Improvements
229 |
230 | - Enhanced prompt template file structure and organization for consistency
231 | - Added standardized file naming convention for prompt templates
232 | - Implemented structured JSON format with required fields (name, description, version, template, contextSchema, tests, sampleInputs, etc.)
233 | - Added support for test case naming in Title Case format
234 | - Improved documentation requirements for prompt templates
235 |
236 | ## [0.4.1] - 2025-05-05
237 |
238 | ### Added
239 |
240 | - Update project detection to correctly paginate the followed projects
241 |
242 | ## [0.4.0] - 2025-04-30
243 |
244 | ### Added
245 |
246 | - Added `get_job_test_results` tool to retrieve and analyze test metadata from CircleCI jobs
247 | - Support for retrieving test results using job, workflow, or pipeline URLs
248 | - Support for retrieving test results using local git repository context
249 | - Displays comprehensive test result summary (total, successful, failed)
250 | - Provides detailed information for failed tests including name, class, file, error messages, and runtime
251 | - Lists successful tests with timing information
252 | - Offers actionable guidance when no test results are found
253 | - Includes documentation link to help users properly configure test metadata collection
254 |
255 | ## [0.3.0] - 2025-04-30
256 |
257 | ### Added
258 |
259 | - Added `get_latest_pipeline_status` tool to get the latest pipeline status
260 | - Support for both project URL and local git repository context
261 | - Displays all workflows within the latest pipeline
262 | - Provides formatted details including pipeline number, workflow status, duration, and timestamps
263 |
264 | ## [0.2.0] - 2025-04-18
265 |
266 | ### Added
267 |
268 | - Added `create_prompt_template` tool to help generate structured prompt templates
269 |
270 | - Converts feature requirements into optimized prompt templates
271 | - Generates context schema for input parameters
272 | - Enables building robust AI-powered features
273 | - Integrates with prompt template testing workflow
274 |
275 | - Added `recommend_prompt_template_tests` tool for prompt template validation
276 | - Creates diverse test scenarios based on templates
277 | - Generates test cases with varied parameter combinations
278 | - Helps identify edge cases and potential issues
279 | - Ensures consistent AI responses across inputs
280 |
281 | ## [0.1.10] - 2025-04-17
282 |
283 | ### Fixed
284 |
285 | - Fixed rate limiting issues when fetching job logs and flaky tests (#32)
286 | - Implemented `rateLimitedRequests` utility for controlled API request batching
287 | - Added configurable batch size and interval controls
288 | - Improved error handling for rate-limited responses
289 | - Added progress tracking for batch operations
290 | - Applied rate limiting fix to both job logs and flaky test detection
291 | - Enhanced reliability of test results retrieval
292 |
293 | ### Improvements
294 |
295 | - Enhanced HTTP client configuration flexibility
296 | - Configurable base URL through environment variables
297 | - Better support for different CircleCI deployment scenarios
298 | - Streamlined client initialization process
299 | - Added output text truncation
300 | - Prevents response overload by limiting output size
301 | - Includes clear warning when content is truncated
302 | - Preserves most recent and relevant information
303 |
304 | ## [0.1.9] - 2025-04-16
305 |
306 | ### Added
307 |
308 | - Added support for API subdomain configuration in CircleCI client
309 | - New `useAPISubdomain` option in HTTP client configuration
310 | - Automatic subdomain handling for API-specific endpoints
311 | - Improved support for CircleCI enterprise and on-premise installations
312 | - Added `config_helper` tool to assist with CircleCI configuration tasks
313 | - Support for validating .circleci/config.yml files
314 | - Integration with CircleCI Config Validation API
315 | - Detailed validation results and configuration recommendations
316 | - Helpful error messages and best practice suggestions
317 |
318 | ## [0.1.8] - 2025-04-10
319 |
320 | ### Fixed
321 |
322 | - Fixed bug in flaky test detection where pipelineNumber was incorrectly used instead of projectSlug when URL not provided
323 |
324 | ### Improvements
325 |
326 | - Consolidated project slug detection functions into a single `getPipelineNumberFromURL` function with enhanced test coverage
327 | - Simplified build logs tool to use only `projectURL` parameter instead of separate pipeline and job URLs
328 | - Updated tool descriptions to provide clearer guidance on accepted URL formats
329 | - Removed redundant error handling wrapper
330 |
331 | ## [0.1.7] - 2025-04-10
332 |
333 | ### Added
334 |
335 | - Added `find_flaky_tests` tool to identify and analyze flaky tests in CircleCI projects
336 | - Support for both project URL and local git repository context
337 | - Integration with CircleCI Insights API for flaky test detection
338 | - Integration with CircleCI Tests API to fetch detailed test execution results
339 | - Formatted output of flaky test analysis results with complete test logs
340 |
341 | ## [0.1.6] - 2025-04-09
342 |
343 | ### Added
344 |
345 | - Added User-Agent header to CircleCI API requests
346 |
347 | ## [0.1.5] - 2025-04-08
348 |
349 | ### Added
350 |
351 | - Support for configurable CircleCI base URL through `CIRCLECI_BASE_URL` environment variable
352 |
353 | ## [0.1.4] - 2025-04-08
354 |
355 | ### Fixed
356 |
357 | - Handle missing job numbers in CircleCI API responses by making job_number optional in schema
358 | - Skip jobs without job numbers when fetching job logs instead of failing
359 |
360 | ## [0.1.3] - 2025-04-04
361 |
362 | ### Added
363 |
364 | - Improved schema validation and output formatting for job information
365 |
366 | ## [0.1.2] - 2025-04-04
367 |
368 | ### Fixed
369 |
370 | - More permissive schema validation for CircleCI API parameters
371 | - Allow optional parameters in API requests
372 |
373 | ### Documentation
374 |
375 | - Updated documentation around package publishing
376 | - Removed note about package not being published
377 |
378 | ## [0.1.1] - 2025-04-04
379 |
380 | ### Fixed
381 |
382 | - Non functional fixes
383 |
384 | ## [0.1.0] - 2025-04-04
385 |
386 | Initial release of the CircleCI MCP Server, enabling natural language interactions with CircleCI functionality through MCP-enabled clients.
387 |
388 | ### Added
389 |
390 | - Core MCP server implementation with CircleCI integration
391 |
392 | - Support for MCP protocol version 1.8.0
393 | - Robust error handling and response formatting
394 | - Standardized HTTP client for CircleCI API interactions
395 |
396 | - CircleCI API Integration
397 |
398 | - Support for both CircleCI API v1.1 and v2
399 | - Comprehensive API client implementation for Jobs, Pipelines, and Workflows
400 | - Private API integration for enhanced functionality
401 | - Secure token-based authentication
402 |
403 | - Build Failure Analysis Tool
404 |
405 | - Implemented `get_build_failure_logs` tool for retrieving detailed failure logs
406 | - Support for both URL-based and local project context-based queries
407 | - Intelligent project detection from git repository information
408 | - Formatted log output with job names and step-by-step execution details
409 |
410 | - Development Tools and Infrastructure
411 | - Comprehensive test suite with Vitest
412 | - ESLint and Prettier configuration for code quality
413 | - TypeScript configuration for type safety
414 | - Development workflow with MCP Inspector support
415 | - Watch mode for rapid development
416 |
417 | ### Security
418 |
419 | - Secure handling of CircleCI API tokens
420 | - Masked sensitive data in log outputs
421 | - Proper error handling to prevent information leakage
422 |
```