Skip to content

Commit 05f4e29

Browse files
snomiaoclaude
andcommitted
feat(gh-test-evidence): add test evidence checker for PR submissions (#61)
Add automated test evidence checking for PRs in Comfy-Org/desktop and Comfy-Org/ComfyUI repositories. Uses GPT-4o-mini to analyze PR descriptions for test explanations, screenshots, and videos, then posts/updates/deletes warning comments based on what evidence is present. Key features: - AI-powered analysis of PR descriptions for test evidence - Smart comment management (create/update/delete based on state) - Database-backed state tracking to avoid redundant analysis - Batch limiting (50 PRs) and time limiting (8 min) to prevent CI timeout - Skip already-analyzed PRs when state is consistent Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f416db5 commit 05f4e29

3 files changed

Lines changed: 602 additions & 0 deletions

File tree

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
#!/usr/bin/env bun test
2+
import { server } from "@/src/test/msw-setup";
3+
import { beforeEach, describe, expect, it, mock } from "bun:test";
4+
import { http, HttpResponse } from "msw";
5+
6+
// Mock database operations
7+
let mockTasks: any = {};
8+
const mockCollection = {
9+
findOneAndUpdate: mock(async (filter: any, update: any, options: any) => {
10+
const key = filter.prUrl;
11+
if (options?.upsert) {
12+
mockTasks[key] = { ...mockTasks[key], ...update.$set };
13+
return mockTasks[key];
14+
}
15+
return mockTasks[key];
16+
}),
17+
};
18+
19+
const mockDb = {
20+
collection: mock(() => mockCollection),
21+
close: mock(() => Promise.resolve()),
22+
};
23+
24+
mock.module("@/src/db", () => ({ db: mockDb }));
25+
26+
// Mock GitHub client
27+
const mockGhc = {
28+
pulls: {
29+
list: mock(() => Promise.resolve({ data: [] })),
30+
},
31+
issues: {
32+
listComments: mock(() => Promise.resolve({ data: [] })),
33+
},
34+
};
35+
36+
const mockGh = {
37+
issues: {
38+
createComment: mock(() => Promise.resolve({ data: { id: 123 } })),
39+
updateComment: mock(() => Promise.resolve({ data: { id: 123 } })),
40+
deleteComment: mock(() => Promise.resolve()),
41+
},
42+
};
43+
44+
mock.module("@/src/ghc", () => ({ ghc: mockGhc }));
45+
mock.module("@/src/gh", () => ({ gh: mockGh }));
46+
mock.module("@/src/ghUser", () => ({ ghUser: mock(() => Promise.resolve({ login: "test-bot" })) }));
47+
48+
describe("gh-test-evidence", () => {
49+
beforeEach(() => {
50+
// Reset mocks before each test
51+
mockTasks = {};
52+
mockDb.collection.mockClear();
53+
mockCollection.findOneAndUpdate.mockClear();
54+
mockGhc.pulls.list.mockClear();
55+
mockGhc.issues.listComments.mockClear();
56+
mockGh.issues.createComment.mockClear();
57+
mockGh.issues.updateComment.mockClear();
58+
mockGh.issues.deleteComment.mockClear();
59+
60+
// Setup default pulls.list to return empty for ComfyUI repo
61+
// This prevents duplicate processing in tests
62+
mockGhc.pulls.list.mockImplementation(async (params: any) => {
63+
if (params.owner === "comfyanonymous" && params.repo === "ComfyUI") {
64+
return { data: [] };
65+
}
66+
return { data: [] };
67+
});
68+
69+
// Setup default OpenAI handler
70+
server.use(
71+
http.post("https://api.openai.com/v1/chat/completions", () => {
72+
return HttpResponse.json({
73+
choices: [
74+
{
75+
message: {
76+
content: JSON.stringify({
77+
isTestExplanationIncluded: false,
78+
isTestScreenshotIncluded: false,
79+
isTestVideoIncluded: false,
80+
}),
81+
},
82+
},
83+
],
84+
});
85+
}),
86+
);
87+
});
88+
89+
it("should analyze PR with missing test evidence", async () => {
90+
const mockPR = {
91+
html_url: "https://github.com/Comfy-Org/desktop/pull/1",
92+
number: 1,
93+
title: "Test PR",
94+
body: "Some changes",
95+
updated_at: new Date().toISOString(),
96+
draft: false,
97+
base: {
98+
repo: {
99+
html_url: "https://github.com/Comfy-Org/desktop",
100+
},
101+
},
102+
};
103+
104+
// Mock pulls.list to return our test PR only for desktop repo
105+
mockGhc.pulls.list.mockImplementation(async (params: any) => {
106+
if (params.owner === "Comfy-Org" && params.repo === "desktop") {
107+
return { data: [mockPR] };
108+
}
109+
return { data: [] };
110+
});
111+
112+
// Mock issues.listComments to return no existing comments
113+
mockGhc.issues.listComments.mockResolvedValue({ data: [] });
114+
115+
// Mock createComment to return a comment ID
116+
mockGh.issues.createComment.mockResolvedValue({ data: { id: 456 } });
117+
118+
// Import and run the task
119+
const runGhTestEvidenceTask = (await import("./gh-test-evidence")).default;
120+
await runGhTestEvidenceTask();
121+
122+
// Verify that a comment was created
123+
expect(mockGh.issues.createComment).toHaveBeenCalledTimes(1);
124+
const createCall = mockGh.issues.createComment.mock.calls[0][0];
125+
expect(createCall.owner).toBe("Comfy-Org");
126+
expect(createCall.repo).toBe("desktop");
127+
expect(createCall.issue_number).toBe(1);
128+
expect(createCall.body).toContain("<!-- COMFY_PR_BOT_TEST_EVIDENCE -->");
129+
expect(createCall.body).toContain("Test Evidence Check");
130+
expect(createCall.body).toContain("Test Explanation Missing");
131+
});
132+
133+
it("should skip draft PRs", async () => {
134+
const mockPR = {
135+
html_url: "https://github.com/Comfy-Org/desktop/pull/2",
136+
number: 2,
137+
title: "Draft PR",
138+
body: "Draft changes",
139+
updated_at: new Date().toISOString(),
140+
draft: true,
141+
base: {
142+
repo: {
143+
html_url: "https://github.com/Comfy-Org/desktop",
144+
},
145+
},
146+
};
147+
148+
// Mock pulls.list to return a draft PR only for desktop repo
149+
mockGhc.pulls.list.mockImplementation(async (params: any) => {
150+
if (params.owner === "Comfy-Org" && params.repo === "desktop") {
151+
return { data: [mockPR] };
152+
}
153+
return { data: [] };
154+
});
155+
156+
// Import and run the task
157+
const runGhTestEvidenceTask = (await import("./gh-test-evidence")).default;
158+
await runGhTestEvidenceTask();
159+
160+
// Verify that no comments were created for draft PRs
161+
expect(mockGh.issues.createComment).not.toHaveBeenCalled();
162+
expect(mockGh.issues.updateComment).not.toHaveBeenCalled();
163+
expect(mockGh.issues.deleteComment).not.toHaveBeenCalled();
164+
165+
// Verify that OpenAI was not called (no requests to the mock server)
166+
// Draft PRs should be skipped entirely without analysis
167+
});
168+
169+
it("should delete comment when all evidence is present", async () => {
170+
const mockPR = {
171+
html_url: "https://github.com/Comfy-Org/desktop/pull/3",
172+
number: 3,
173+
title: "Complete PR",
174+
body: "Here's my test plan. And here's a screenshot: ![image](https://example.com/img.png). Here's a video: https://youtube.com/watch?v=test",
175+
updated_at: new Date().toISOString(),
176+
draft: false,
177+
base: {
178+
repo: {
179+
html_url: "https://github.com/Comfy-Org/desktop",
180+
},
181+
},
182+
};
183+
184+
const existingComment = {
185+
id: 456,
186+
user: { login: "test-bot" },
187+
body: "<!-- COMFY_PR_BOT_TEST_EVIDENCE -->\nWarning message",
188+
};
189+
190+
// Mock pulls.list to return a PR with all evidence only for desktop repo
191+
mockGhc.pulls.list.mockImplementation(async (params: any) => {
192+
if (params.owner === "Comfy-Org" && params.repo === "desktop") {
193+
return { data: [mockPR] };
194+
}
195+
return { data: [] };
196+
});
197+
198+
// Mock issues.listComments to return an existing bot comment
199+
mockGhc.issues.listComments.mockResolvedValue({ data: [existingComment] });
200+
201+
// Override OpenAI response for this test to indicate all evidence is present
202+
server.use(
203+
http.post("https://api.openai.com/v1/chat/completions", () => {
204+
return HttpResponse.json({
205+
choices: [
206+
{
207+
message: {
208+
content: JSON.stringify({
209+
isTestExplanationIncluded: true,
210+
isTestScreenshotIncluded: true,
211+
isTestVideoIncluded: true,
212+
}),
213+
},
214+
},
215+
],
216+
});
217+
}),
218+
);
219+
220+
// Import and run the task
221+
const runGhTestEvidenceTask = (await import("./gh-test-evidence")).default;
222+
await runGhTestEvidenceTask();
223+
224+
// Verify that the existing comment was deleted
225+
expect(mockGh.issues.deleteComment).toHaveBeenCalledTimes(1);
226+
const deleteCall = mockGh.issues.deleteComment.mock.calls[0][0];
227+
expect(deleteCall.owner).toBe("Comfy-Org");
228+
expect(deleteCall.repo).toBe("desktop");
229+
expect(deleteCall.issue_number).toBe(3);
230+
expect(deleteCall.comment_id).toBe(456);
231+
232+
// Verify that no new comments were created
233+
expect(mockGh.issues.createComment).not.toHaveBeenCalled();
234+
});
235+
236+
it("should generate correct warning message format", async () => {
237+
const mockPR = {
238+
html_url: "https://github.com/Comfy-Org/desktop/pull/4",
239+
number: 4,
240+
title: "Test PR",
241+
body: "No test evidence",
242+
updated_at: new Date().toISOString(),
243+
draft: false,
244+
base: {
245+
repo: {
246+
html_url: "https://github.com/Comfy-Org/desktop",
247+
},
248+
},
249+
};
250+
251+
// Mock pulls.list to return a PR with missing evidence only for desktop repo
252+
mockGhc.pulls.list.mockImplementation(async (params: any) => {
253+
if (params.owner === "Comfy-Org" && params.repo === "desktop") {
254+
return { data: [mockPR] };
255+
}
256+
return { data: [] };
257+
});
258+
259+
// Mock issues.listComments to return no existing comments
260+
mockGhc.issues.listComments.mockResolvedValue({ data: [] });
261+
262+
// Mock createComment to capture the warning message
263+
mockGh.issues.createComment.mockResolvedValue({ data: { id: 789 } });
264+
265+
// Import and run the task
266+
const runGhTestEvidenceTask = (await import("./gh-test-evidence")).default;
267+
await runGhTestEvidenceTask();
268+
269+
// Get the created comment body
270+
const createCall = mockGh.issues.createComment.mock.calls[0][0];
271+
const warningMessage = createCall.body;
272+
273+
// Verify warning message format
274+
expect(warningMessage).toContain("<!-- COMFY_PR_BOT_TEST_EVIDENCE -->");
275+
expect(warningMessage).toContain("## Test Evidence Check");
276+
expect(warningMessage).toContain("⚠️");
277+
expect(warningMessage).toContain("**Warning: Test Explanation Missing**");
278+
expect(warningMessage).toContain("**Warning: Visual Documentation Missing**");
279+
expect(warningMessage).toContain("screen recording or screenshot");
280+
expect(warningMessage).toContain("GitHub: Drag & drop");
281+
expect(warningMessage).toContain("YouTube:");
282+
});
283+
});

0 commit comments

Comments
 (0)