Skip to content

Commit 1f794e4

Browse files
authored
Merge pull request #1838 from quantified-uncertainty/claude/pr-patrol-comments
feat: add structured status comments to PR Patrol
2 parents ef729c6 + 7400033 commit 1f794e4

File tree

3 files changed

+694
-28
lines changed

3 files changed

+694
-28
lines changed

crux/pr-patrol/comments.test.ts

Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
STATUS_MARKER,
4+
buildStatusCommentBody,
5+
stripTimestamp,
6+
buildMergeComment,
7+
buildMergeFailedComment,
8+
buildFixAttemptComment,
9+
buildFixCompleteComment,
10+
buildAbandonmentComment,
11+
buildTimeoutComment,
12+
buildNoOpComment,
13+
} from './comments.ts';
14+
import type { GqlPrNode } from './index.ts';
15+
16+
// ── Test helpers ─────────────────────────────────────────────────────────────
17+
18+
function makePrNode(overrides: Partial<GqlPrNode> = {}): GqlPrNode {
19+
return {
20+
number: 1,
21+
title: 'Test PR',
22+
headRefName: 'claude/test',
23+
mergeable: 'MERGEABLE',
24+
isDraft: false,
25+
createdAt: '2026-01-01T00:00:00Z',
26+
updatedAt: '2026-03-05T00:00:00Z',
27+
body: '## Summary\n\n- [x] Task done\n\n## Test plan\n\n- [x] Tests pass\n\nCloses #1',
28+
labels: { nodes: [{ name: 'ready-to-merge' }] },
29+
commits: {
30+
nodes: [
31+
{
32+
commit: {
33+
statusCheckRollup: {
34+
contexts: {
35+
nodes: [{ conclusion: 'SUCCESS' }],
36+
},
37+
},
38+
},
39+
},
40+
],
41+
},
42+
reviewThreads: { nodes: [] },
43+
...overrides,
44+
};
45+
}
46+
47+
// ── buildStatusCommentBody ───────────────────────────────────────────────────
48+
49+
describe('buildStatusCommentBody', () => {
50+
it('includes the status marker for identification', () => {
51+
const body = buildStatusCommentBody(makePrNode(), []);
52+
expect(body).toContain(STATUS_MARKER);
53+
});
54+
55+
it('shows all checks passing when PR is clean', () => {
56+
const body = buildStatusCommentBody(makePrNode(), []);
57+
expect(body).toContain('passing');
58+
expect(body).toContain('clean');
59+
expect(body).toContain('none unresolved');
60+
expect(body).toContain('complete');
61+
expect(body).toContain('Ready to merge');
62+
});
63+
64+
it('shows CI failing when there are failures', () => {
65+
const pr = makePrNode({
66+
commits: {
67+
nodes: [
68+
{
69+
commit: {
70+
statusCheckRollup: {
71+
contexts: {
72+
nodes: [{ conclusion: 'FAILURE' }],
73+
},
74+
},
75+
},
76+
},
77+
],
78+
},
79+
});
80+
const body = buildStatusCommentBody(pr, ['ci-failing']);
81+
expect(body).toContain('failing');
82+
expect(body).toContain('`ci-failing`');
83+
});
84+
85+
it('shows CI pending when checks are in progress', () => {
86+
const pr = makePrNode({
87+
commits: {
88+
nodes: [
89+
{
90+
commit: {
91+
statusCheckRollup: {
92+
contexts: {
93+
nodes: [{ conclusion: null }],
94+
},
95+
},
96+
},
97+
},
98+
],
99+
},
100+
});
101+
const body = buildStatusCommentBody(pr, ['ci-pending']);
102+
expect(body).toContain('pending');
103+
expect(body).toContain('Waiting for CI to complete');
104+
});
105+
106+
it('shows conflicts when PR is not mergeable', () => {
107+
const pr = makePrNode({ mergeable: 'CONFLICTING' });
108+
const body = buildStatusCommentBody(pr, ['not-mergeable']);
109+
expect(body).toContain('has conflicts');
110+
expect(body).toContain('`not-mergeable`');
111+
});
112+
113+
it('shows unresolved review threads', () => {
114+
const pr = makePrNode({
115+
reviewThreads: {
116+
nodes: [
117+
{
118+
isResolved: false,
119+
isOutdated: false,
120+
path: 'src/foo.ts',
121+
line: 10,
122+
startLine: null,
123+
comments: {
124+
nodes: [{ author: { login: 'user' }, body: 'Fix this' }],
125+
},
126+
},
127+
{
128+
isResolved: false,
129+
isOutdated: false,
130+
path: 'src/bar.ts',
131+
line: 20,
132+
startLine: null,
133+
comments: {
134+
nodes: [{ author: { login: 'user' }, body: 'Fix that' }],
135+
},
136+
},
137+
],
138+
},
139+
});
140+
const body = buildStatusCommentBody(pr, ['unresolved-threads']);
141+
expect(body).toContain('2 unresolved');
142+
expect(body).toContain('`unresolved-threads`');
143+
});
144+
145+
it('shows unchecked items in checklist', () => {
146+
const pr = makePrNode({
147+
body: '## Test plan\n\n- [ ] Not done\n- [x] Done\n\nCloses #1',
148+
});
149+
const body = buildStatusCommentBody(pr, ['unchecked-items']);
150+
expect(body).toContain('1 unchecked');
151+
});
152+
153+
it('does not include Blocks line when no block reasons', () => {
154+
const body = buildStatusCommentBody(makePrNode(), []);
155+
expect(body).not.toContain('**Blocks**');
156+
});
157+
158+
it('includes multiple block reasons', () => {
159+
const pr = makePrNode({ mergeable: 'CONFLICTING' });
160+
const body = buildStatusCommentBody(pr, ['not-mergeable', 'ci-failing']);
161+
expect(body).toContain('`not-mergeable`');
162+
expect(body).toContain('`ci-failing`');
163+
});
164+
165+
it('shows stage for claude-working', () => {
166+
const body = buildStatusCommentBody(makePrNode(), ['claude-working']);
167+
expect(body).toContain('Claude is working on this PR');
168+
});
169+
170+
it('shows draft stage', () => {
171+
const pr = makePrNode({ isDraft: true });
172+
const body = buildStatusCommentBody(pr, ['is-draft']);
173+
expect(body).toContain('Draft PR');
174+
});
175+
176+
it('includes a timestamp line', () => {
177+
const body = buildStatusCommentBody(makePrNode(), []);
178+
expect(body).toMatch(/<sub>Updated:.*UTC<\/sub>/);
179+
});
180+
181+
it('shows "no checks" when statusCheckRollup is null', () => {
182+
const pr = makePrNode({
183+
commits: {
184+
nodes: [
185+
{
186+
commit: {
187+
statusCheckRollup: null,
188+
},
189+
},
190+
],
191+
},
192+
});
193+
const body = buildStatusCommentBody(pr, []);
194+
expect(body).toContain('no checks');
195+
});
196+
197+
it('uses first matching block reason for stage when multiple overlap', () => {
198+
// 'claude-working' takes precedence over 'ci-failing' because it appears first
199+
// in the computeStage priority chain
200+
const body = buildStatusCommentBody(makePrNode(), [
201+
'ci-failing',
202+
'claude-working',
203+
]);
204+
expect(body).toContain('Claude is working on this PR');
205+
// Both block reasons should still appear in the Blocks line
206+
expect(body).toContain('`ci-failing`');
207+
expect(body).toContain('`claude-working`');
208+
});
209+
210+
it('picks ci-pending stage over ci-failing when both present', () => {
211+
// 'claude-working' is checked first, then 'ci-pending', then 'ci-failing'
212+
const body = buildStatusCommentBody(makePrNode(), [
213+
'ci-failing',
214+
'ci-pending',
215+
]);
216+
expect(body).toContain('Waiting for CI to complete');
217+
});
218+
219+
it('falls back to generic stage when only resolution-type blocks present', () => {
220+
const body = buildStatusCommentBody(makePrNode(), [
221+
'not-mergeable',
222+
'unresolved-threads',
223+
]);
224+
expect(body).toContain('Waiting for issues to be resolved');
225+
expect(body).toContain('`not-mergeable`');
226+
expect(body).toContain('`unresolved-threads`');
227+
});
228+
});
229+
230+
// ── stripTimestamp ────────────────────────────────────────────────────────────
231+
232+
describe('stripTimestamp', () => {
233+
it('strips timestamp from status comment for comparison', () => {
234+
const body1 = buildStatusCommentBody(makePrNode(), []);
235+
// Simulate a later update with different timestamp
236+
const body2 = body1.replace(
237+
/<sub>Updated:.*<\/sub>/,
238+
'<sub>Updated: 2026-03-06 23:59 UTC</sub>',
239+
);
240+
expect(stripTimestamp(body1)).toBe(stripTimestamp(body2));
241+
});
242+
243+
it('does not modify bodies without timestamps', () => {
244+
const body = 'No timestamp here';
245+
expect(stripTimestamp(body)).toBe(body);
246+
});
247+
});
248+
249+
// ── Event comment builders ───────────────────────────────────────────────────
250+
251+
describe('event comment builders', () => {
252+
it('buildMergeComment produces expected format', () => {
253+
const result = buildMergeComment();
254+
expect(result).toContain('PR Patrol');
255+
expect(result).toContain('Merged to main via squash merge');
256+
});
257+
258+
it('buildMergeFailedComment includes reason', () => {
259+
const result = buildMergeFailedComment('Branch protection rule violated');
260+
expect(result).toContain('Merge failed');
261+
expect(result).toContain('Branch protection rule violated');
262+
});
263+
264+
it('buildFixAttemptComment lists issues', () => {
265+
const result = buildFixAttemptComment(['conflict', 'ci-failure']);
266+
expect(result).toContain('Attempting fix for');
267+
expect(result).toContain('conflict');
268+
expect(result).toContain('ci-failure');
269+
});
270+
271+
it('buildFixCompleteComment includes timing and output', () => {
272+
const result = buildFixCompleteComment(
273+
45,
274+
12,
275+
'sonnet',
276+
['conflict'],
277+
'Fixed the merge conflict in src/foo.ts',
278+
);
279+
expect(result).toContain('45s');
280+
expect(result).toContain('12 max turns');
281+
expect(result).toContain('sonnet');
282+
expect(result).toContain('conflict');
283+
expect(result).toContain('Fixed the merge conflict');
284+
});
285+
286+
it('buildFixCompleteComment truncates long output to 300 chars', () => {
287+
const longOutput = 'x'.repeat(500);
288+
const result = buildFixCompleteComment(10, 5, 'haiku', ['ci-failure'], longOutput);
289+
// The function takes the last 300 chars of the output
290+
expect(result).toContain('x'.repeat(300));
291+
expect(result).not.toContain('x'.repeat(301));
292+
});
293+
294+
it('buildAbandonmentComment includes fail count and issues', () => {
295+
const result = buildAbandonmentComment(2, ['conflict', 'ci-failure']);
296+
expect(result).toContain('2 failed fix attempts');
297+
expect(result).toContain('human intervention');
298+
expect(result).toContain('conflict');
299+
expect(result).toContain('ci-failure');
300+
});
301+
302+
it('buildTimeoutComment includes timeout and attempt info', () => {
303+
const result = buildTimeoutComment(1, 30, ['conflict']);
304+
expect(result).toContain('timed out');
305+
expect(result).toContain('30m');
306+
expect(result).toContain('attempt 1');
307+
expect(result).toContain('conflict');
308+
});
309+
310+
it('buildNoOpComment includes issues', () => {
311+
const result = buildNoOpComment(['ci-failure']);
312+
expect(result).toContain('human intervention');
313+
expect(result).toContain('ci-failure');
314+
});
315+
});

0 commit comments

Comments
 (0)