Skip to content

Commit 024d521

Browse files
authored
feat: add real-time deployment streaming and comparison tools (#7)
* feat: add real-time deployment streaming and comparison tools - Add 3 new MCP tools: watch_deployment, compare_deployments, get_deployment_logs - Implement deployment streaming with dynamic polling intervals - Add deployment comparison with risk assessment and performance metrics - Create log analysis with error detection and suggested fixes - Refactor API layer with native fetch and per-token rate limiting - Add Markdown response formatting for consistent output across AI tools - Extract constants and remove magic numbers - Update documentation with tool examples and troubleshooting section - Clean up landing page design and remove excessive emojis - Add test coverage (87 tests passing) * chore: remove redundant placeholder tests and TODO comments - Remove empty test placeholders in mcp-handler.test.ts - Remove TODO comment for rate limiting (intentionally simple for now) - Clean up codebase from unnecessary comments * fix: resolve TypeScript errors for CI build - Add missing required parameters to test function calls - Replace hardcoded values with constants - Fix return type mismatches in deployment methods - Add deployments field to comparison result structure - Handle undefined analysis parameter in formatter - Fix type indexing in event formatter - Update test assertions for filtered log output * fix: resolve unhandled promise rejections and clean up codebase - Fix unhandled promise rejections in request deduplication logic - Update test assertions to use expect().rejects.toThrow() pattern - Add test coverage for deduplicated error handling - Remove redundant and obvious comments throughout codebase - Clean up promise handling in api-client to prevent warnings
1 parent 7ba329a commit 024d521

24 files changed

Lines changed: 2960 additions & 293 deletions

README.md

Lines changed: 264 additions & 71 deletions
Large diffs are not rendered by default.

eslint.config.js

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,66 @@
1-
import js from '@eslint/js';
2-
import tseslint from '@typescript-eslint/eslint-plugin';
3-
import tsparser from '@typescript-eslint/parser';
4-
import prettier from 'eslint-plugin-prettier';
5-
import prettierConfig from 'eslint-config-prettier';
1+
import js from "@eslint/js";
2+
import tseslint from "@typescript-eslint/eslint-plugin";
3+
import tsparser from "@typescript-eslint/parser";
4+
import prettier from "eslint-plugin-prettier";
5+
import prettierConfig from "eslint-config-prettier";
66

77
export default [
88
js.configs.recommended,
99
{
10-
files: ['**/*.ts', '**/*.tsx'],
10+
files: ["**/*.ts", "**/*.tsx"],
1111
languageOptions: {
1212
parser: tsparser,
1313
parserOptions: {
1414
ecmaVersion: 2022,
15-
sourceType: 'module',
16-
project: './tsconfig.json',
15+
sourceType: "module",
16+
project: "./tsconfig.json",
1717
},
1818
globals: {
19-
console: 'readonly',
20-
process: 'readonly',
21-
global: 'readonly',
22-
fetch: 'readonly',
23-
setTimeout: 'readonly',
24-
clearTimeout: 'readonly',
25-
crypto: 'readonly',
26-
KVNamespace: 'readonly',
19+
console: "readonly",
20+
process: "readonly",
21+
global: "readonly",
22+
fetch: "readonly",
23+
setTimeout: "readonly",
24+
clearTimeout: "readonly",
25+
setInterval: "readonly",
26+
clearInterval: "readonly",
27+
crypto: "readonly",
28+
KVNamespace: "readonly",
2729
},
2830
},
2931
plugins: {
30-
'@typescript-eslint': tseslint,
32+
"@typescript-eslint": tseslint,
3133
prettier: prettier,
3234
},
3335
rules: {
3436
...tseslint.configs.recommended.rules,
3537
...prettierConfig.rules,
36-
'prettier/prettier': 'error',
37-
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
38-
'@typescript-eslint/no-explicit-any': 'warn',
39-
'@typescript-eslint/explicit-function-return-type': 'off',
40-
'@typescript-eslint/explicit-module-boundary-types': 'off',
41-
'@typescript-eslint/no-non-null-assertion': 'warn',
38+
"prettier/prettier": "error",
39+
"@typescript-eslint/no-unused-vars": [
40+
"error",
41+
{ argsIgnorePattern: "^_" },
42+
],
43+
"@typescript-eslint/no-explicit-any": "warn",
44+
"@typescript-eslint/no-non-null-assertion": "warn",
45+
"no-console": ["error", { allow: ["warn", "error"] }],
46+
"no-debugger": "error",
47+
"no-var": "error",
48+
"prefer-const": "error",
49+
eqeqeq: ["error", "always"],
50+
"no-eval": "error",
51+
"no-implied-eval": "error",
52+
"no-throw-literal": "error",
53+
"no-duplicate-imports": "error",
4254
},
4355
},
4456
{
45-
files: ['**/*.js'],
57+
files: ["**/*.js"],
4658
languageOptions: {
4759
ecmaVersion: 2022,
48-
sourceType: 'module',
60+
sourceType: "module",
4961
},
5062
},
5163
{
52-
ignores: ['dist/**', 'node_modules/**', '*.config.js'],
64+
ignores: ["dist/**", "node_modules/**", "*.config.js"],
5365
},
54-
];
66+
];
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { BaseAPIClient } from "../api-client.js";
3+
4+
class TestAPIClient extends BaseAPIClient {
5+
protected endpoints = {
6+
test: {
7+
path: "test",
8+
method: "GET" as const,
9+
docsUrl: "https://example.com/docs",
10+
description: "Test endpoint",
11+
},
12+
post: {
13+
path: "test/post",
14+
method: "POST" as const,
15+
docsUrl: "https://example.com/docs/post",
16+
description: "POST endpoint",
17+
},
18+
};
19+
20+
async testRequest() {
21+
return this.request(this.endpoints.test);
22+
}
23+
24+
async testRequestWithOptions(options: any) {
25+
return this.request(this.endpoints.test, options);
26+
}
27+
28+
async testPostRequest(body: any) {
29+
return this.request(this.endpoints.post, { body });
30+
}
31+
}
32+
33+
describe("BaseAPIClient", () => {
34+
let client: TestAPIClient;
35+
36+
beforeEach(() => {
37+
vi.clearAllMocks();
38+
global.fetch = vi.fn();
39+
client = new TestAPIClient({
40+
baseUrl: "https://api.example.com",
41+
timeout: 5000,
42+
retry: 2,
43+
});
44+
});
45+
46+
afterEach(() => {
47+
vi.restoreAllMocks();
48+
});
49+
50+
it("should make successful API request", async () => {
51+
const mockResponse = { data: "test" };
52+
(global.fetch as any).mockResolvedValueOnce({
53+
ok: true,
54+
text: async () => JSON.stringify(mockResponse),
55+
});
56+
57+
const result = await client.testRequest();
58+
59+
expect(result).toEqual(mockResponse);
60+
expect(fetch).toHaveBeenCalled();
61+
});
62+
63+
it("should pass headers and options", async () => {
64+
(global.fetch as any).mockResolvedValueOnce({
65+
ok: true,
66+
text: async () => JSON.stringify({ success: true }),
67+
});
68+
69+
await client.testRequestWithOptions({
70+
headers: {
71+
Authorization: "Bearer token123",
72+
},
73+
searchParams: {
74+
limit: 10,
75+
},
76+
});
77+
78+
expect(fetch).toHaveBeenCalled();
79+
const [url, options] = (fetch as any).mock.calls[0];
80+
expect(url.toString()).toContain("limit=10");
81+
expect(options.headers.get("Authorization")).toBe("Bearer token123");
82+
});
83+
84+
it("should handle HTTP errors properly", async () => {
85+
(global.fetch as any).mockResolvedValueOnce({
86+
ok: false,
87+
status: 404,
88+
statusText: "Not Found",
89+
text: async () => "",
90+
});
91+
92+
await expect(client.testRequest()).rejects.toThrow(
93+
"API request failed for test"
94+
);
95+
});
96+
97+
it("should retry on network errors", async () => {
98+
let callCount = 0;
99+
(global.fetch as any).mockImplementation(() => {
100+
callCount++;
101+
if (callCount <= 2) {
102+
throw new Error("Network error");
103+
}
104+
return Promise.resolve({
105+
ok: true,
106+
text: async () => JSON.stringify({ retried: true }),
107+
});
108+
});
109+
110+
const result = await client.testRequest();
111+
expect(result).toEqual({ retried: true });
112+
expect(callCount).toBe(3);
113+
});
114+
115+
it("should not retry on 4xx errors", async () => {
116+
let callCount = 0;
117+
(global.fetch as any).mockImplementation(() => {
118+
callCount++;
119+
return Promise.resolve({
120+
ok: false,
121+
status: 401,
122+
statusText: "Unauthorized",
123+
text: async () => "",
124+
});
125+
});
126+
127+
await expect(client.testRequest()).rejects.toThrow("API request failed");
128+
expect(callCount).toBe(1);
129+
});
130+
131+
it("should handle request deduplication for GET requests", async () => {
132+
let fetchCallCount = 0;
133+
(global.fetch as any).mockImplementation(() => {
134+
fetchCallCount++;
135+
return new Promise(resolve => {
136+
setTimeout(() => {
137+
resolve({
138+
ok: true,
139+
text: async () => JSON.stringify({ count: fetchCallCount }),
140+
});
141+
}, 10);
142+
});
143+
});
144+
145+
const [result1, result2] = await Promise.all([
146+
client.testRequest(),
147+
client.testRequest(),
148+
]);
149+
150+
expect(result1).toEqual(result2);
151+
expect(fetchCallCount).toBe(1);
152+
});
153+
154+
it("should handle deduplicated request errors properly", async () => {
155+
let fetchCallCount = 0;
156+
(global.fetch as any).mockImplementation(() => {
157+
fetchCallCount++;
158+
return Promise.resolve({
159+
ok: false,
160+
status: 404,
161+
statusText: "Not Found",
162+
text: async () => "",
163+
});
164+
});
165+
166+
const promise1 = client
167+
.testRequest()
168+
.catch(err => ({ error: err.message }));
169+
const promise2 = client
170+
.testRequest()
171+
.catch(err => ({ error: err.message }));
172+
173+
const [result1, result2] = await Promise.all([promise1, promise2]);
174+
175+
expect(result1).toHaveProperty("error");
176+
expect(result2).toHaveProperty("error");
177+
expect(result1).toEqual(result2);
178+
expect(fetchCallCount).toBe(1);
179+
});
180+
181+
it("should not deduplicate POST requests", async () => {
182+
let fetchCallCount = 0;
183+
(global.fetch as any).mockImplementation(() => {
184+
fetchCallCount++;
185+
return Promise.resolve({
186+
ok: true,
187+
text: async () => JSON.stringify({ count: fetchCallCount }),
188+
});
189+
});
190+
191+
await Promise.all([
192+
client.testPostRequest({ data: 1 }),
193+
client.testPostRequest({ data: 2 }),
194+
]);
195+
196+
expect(fetchCallCount).toBe(2);
197+
});
198+
199+
it("should handle empty responses", async () => {
200+
(global.fetch as any).mockResolvedValueOnce({
201+
ok: true,
202+
text: async () => "",
203+
});
204+
205+
const result = await client.testRequest();
206+
expect(result).toEqual({});
207+
});
208+
209+
it("should handle invalid JSON responses", async () => {
210+
(global.fetch as any).mockResolvedValue({
211+
ok: true,
212+
text: async () => "not json",
213+
});
214+
215+
await expect(client.testRequest()).rejects.toThrow(
216+
"API request failed for test"
217+
);
218+
});
219+
});

src/adapters/base/adapter.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,19 @@ export abstract class BaseAdapter {
1010

1111
abstract authenticate(token: string): Promise<boolean>;
1212

13+
abstract getDeploymentById(deploymentId: string, token: string): Promise<any>;
14+
15+
abstract getRecentDeployments(
16+
project: string,
17+
token: string,
18+
limit?: number
19+
): Promise<any[]>;
20+
21+
abstract getDeploymentLogs(
22+
deploymentId: string,
23+
token: string
24+
): Promise<string>;
25+
1326
protected formatTimestamp(date: Date | string | number): string {
1427
return new Date(date).toISOString();
1528
}

0 commit comments

Comments
 (0)