pr-shepherd 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +14 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/marketplace.json +8 -0
- package/package.json +62 -0
- package/skills/check/SKILL.md +70 -0
- package/skills/monitor/SKILL.md +108 -0
- package/skills/resolve/SKILL.md +85 -0
- package/src/cache/file-cache.mts +101 -0
- package/src/cache/file-cache.test.mts +91 -0
- package/src/cache/fix-attempts.mts +86 -0
- package/src/checks/classify.mts +80 -0
- package/src/checks/classify.test.mts +164 -0
- package/src/checks/triage.mock.test.mts +202 -0
- package/src/checks/triage.mts +88 -0
- package/src/cli.mts +423 -0
- package/src/commands/check.mts +188 -0
- package/src/commands/iterate.mock.test.mts +1111 -0
- package/src/commands/iterate.mts +371 -0
- package/src/commands/ready-delay.mts +117 -0
- package/src/commands/ready-delay.test.mts +116 -0
- package/src/commands/resolve.mts +92 -0
- package/src/commands/status.mts +173 -0
- package/src/comments/outdated.mts +18 -0
- package/src/comments/resolve.mts +179 -0
- package/src/config/load.mts +240 -0
- package/src/config.json +52 -0
- package/src/github/batch.mts +351 -0
- package/src/github/client.mts +207 -0
- package/src/github/client.test.mts +19 -0
- package/src/github/gql/batch-pr.gql +130 -0
- package/src/github/gql/dismiss-review.gql +7 -0
- package/src/github/gql/minimize-comment.gql +7 -0
- package/src/github/gql/multi-pr-status-paged.gql +31 -0
- package/src/github/gql/multi-pr-status.gql +32 -0
- package/src/github/gql/resolve-thread.gql +7 -0
- package/src/github/pagination.mts +86 -0
- package/src/github/pagination.test.mts +140 -0
- package/src/github/queries.mts +30 -0
- package/src/index.mts +17 -0
- package/src/merge-status/derive.mts +74 -0
- package/src/merge-status/derive.test.mts +130 -0
- package/src/reporters/json.mts +12 -0
- package/src/reporters/text.mts +140 -0
- package/src/types.mts +309 -0
- package/src/util/path-segment.mts +2 -0
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Hoist mocks BEFORE any imports so modules capture the mocked versions.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }));
|
|
8
|
+
|
|
9
|
+
vi.mock("node:child_process", () => ({
|
|
10
|
+
execFile: (
|
|
11
|
+
cmd: string,
|
|
12
|
+
args: string[],
|
|
13
|
+
optsOrCb:
|
|
14
|
+
| Record<string, unknown>
|
|
15
|
+
| ((err: Error | null, result: { stdout: string; stderr: string }) => void),
|
|
16
|
+
maybeCb?: (err: Error | null, result: { stdout: string; stderr: string }) => void,
|
|
17
|
+
) => {
|
|
18
|
+
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb!;
|
|
19
|
+
mockExecFile(cmd, args)
|
|
20
|
+
.then((result: { stdout: string; stderr: string }) => cb(null, result))
|
|
21
|
+
.catch((err: Error) => cb(err, { stdout: "", stderr: "" }));
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock("./check.mts", () => ({
|
|
26
|
+
runCheck: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock("./ready-delay.mts", () => ({
|
|
30
|
+
updateReadyDelay: vi.fn(),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock("../github/client.mts", () => ({
|
|
34
|
+
getCurrentPrNumber: vi.fn().mockResolvedValue(42),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
vi.mock("../checks/triage.mts", () => ({
|
|
38
|
+
// Pass checks through unchanged — failureKind is pre-set by test fixtures.
|
|
39
|
+
triageFailingChecks: vi.fn((checks: unknown[]) => Promise.resolve(checks)),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock("../cache/fix-attempts.mts", () => ({
|
|
43
|
+
readFixAttempts: vi.fn().mockResolvedValue(null),
|
|
44
|
+
writeFixAttempts: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
import { runIterate } from "./iterate.mts";
|
|
48
|
+
import { runCheck } from "./check.mts";
|
|
49
|
+
import { updateReadyDelay } from "./ready-delay.mts";
|
|
50
|
+
import { triageFailingChecks } from "../checks/triage.mts";
|
|
51
|
+
import { readFixAttempts, writeFixAttempts } from "../cache/fix-attempts.mts";
|
|
52
|
+
import type { ShepherdReport, IterateCommandOptions } from "../types.mts";
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
const mockRunCheck = vi.mocked(runCheck);
|
|
59
|
+
const mockUpdateReadyDelay = vi.mocked(updateReadyDelay);
|
|
60
|
+
const mockTriageFailingChecks = vi.mocked(triageFailingChecks);
|
|
61
|
+
const mockReadFixAttempts = vi.mocked(readFixAttempts);
|
|
62
|
+
const mockWriteFixAttempts = vi.mocked(writeFixAttempts);
|
|
63
|
+
|
|
64
|
+
function makeReport(overrides: Partial<ShepherdReport> = {}): ShepherdReport {
|
|
65
|
+
return {
|
|
66
|
+
pr: 42,
|
|
67
|
+
repo: "owner/repo",
|
|
68
|
+
status: "READY",
|
|
69
|
+
mergeStatus: {
|
|
70
|
+
status: "CLEAN",
|
|
71
|
+
state: "OPEN" as const,
|
|
72
|
+
isDraft: false,
|
|
73
|
+
mergeable: "MERGEABLE",
|
|
74
|
+
reviewDecision: "APPROVED",
|
|
75
|
+
copilotReviewInProgress: false,
|
|
76
|
+
mergeStateStatus: "CLEAN",
|
|
77
|
+
},
|
|
78
|
+
checks: {
|
|
79
|
+
passing: [
|
|
80
|
+
{
|
|
81
|
+
name: "ci",
|
|
82
|
+
status: "COMPLETED",
|
|
83
|
+
conclusion: "SUCCESS",
|
|
84
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/1",
|
|
85
|
+
event: "pull_request",
|
|
86
|
+
runId: "run-1",
|
|
87
|
+
category: "passed",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
failing: [],
|
|
91
|
+
inProgress: [],
|
|
92
|
+
skipped: [],
|
|
93
|
+
filtered: [],
|
|
94
|
+
filteredNames: [],
|
|
95
|
+
blockedByFilteredCheck: false,
|
|
96
|
+
},
|
|
97
|
+
threads: { actionable: [], autoResolved: [], autoResolveErrors: [] },
|
|
98
|
+
comments: { actionable: [] },
|
|
99
|
+
changesRequestedReviews: [],
|
|
100
|
+
lastPushTime: undefined,
|
|
101
|
+
...overrides,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function makeOpts(overrides: Partial<IterateCommandOptions> = {}): IterateCommandOptions {
|
|
106
|
+
return {
|
|
107
|
+
prNumber: 42,
|
|
108
|
+
format: "json",
|
|
109
|
+
noCache: true,
|
|
110
|
+
cacheTtlSeconds: 300,
|
|
111
|
+
cooldownSeconds: 30,
|
|
112
|
+
readyDelaySeconds: 600,
|
|
113
|
+
...overrides,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const NOW = 1_700_000_000;
|
|
118
|
+
const READY_STATE_DEFAULT = {
|
|
119
|
+
isReady: true,
|
|
120
|
+
shouldCancel: false,
|
|
121
|
+
remainingSeconds: 300,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Tests
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
beforeEach(() => {
|
|
129
|
+
vi.clearAllMocks();
|
|
130
|
+
// Default: last commit was 60s ago (outside cooldown)
|
|
131
|
+
mockExecFile.mockImplementation((cmd: string, args: string[]) => {
|
|
132
|
+
if (cmd === "git" && args[0] === "log") {
|
|
133
|
+
return Promise.resolve({ stdout: String(NOW - 60), stderr: "" });
|
|
134
|
+
}
|
|
135
|
+
if (cmd === "git" && args[0] === "rev-parse") {
|
|
136
|
+
return Promise.resolve({ stdout: "abc123", stderr: "" });
|
|
137
|
+
}
|
|
138
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
139
|
+
});
|
|
140
|
+
vi.useFakeTimers();
|
|
141
|
+
vi.setSystemTime(NOW * 1000);
|
|
142
|
+
mockUpdateReadyDelay.mockResolvedValue(READY_STATE_DEFAULT);
|
|
143
|
+
mockReadFixAttempts.mockResolvedValue(null);
|
|
144
|
+
mockWriteFixAttempts.mockResolvedValue(undefined);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
vi.useRealTimers();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("runIterate — cooldown", () => {
|
|
152
|
+
it("returns action: cooldown when last commit is 5s ago", async () => {
|
|
153
|
+
mockExecFile.mockImplementation((cmd: string, args: string[]) => {
|
|
154
|
+
if (cmd === "git" && args[0] === "log") {
|
|
155
|
+
return Promise.resolve({ stdout: String(NOW - 5), stderr: "" });
|
|
156
|
+
}
|
|
157
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = await runIterate(makeOpts({ cooldownSeconds: 30 }));
|
|
161
|
+
|
|
162
|
+
expect(result.action).toBe("cooldown");
|
|
163
|
+
expect(mockRunCheck).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("runIterate — wait", () => {
|
|
168
|
+
it("returns action: wait when all CI is passing and no threads", async () => {
|
|
169
|
+
mockRunCheck.mockResolvedValue(makeReport());
|
|
170
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
171
|
+
isReady: true,
|
|
172
|
+
shouldCancel: false,
|
|
173
|
+
remainingSeconds: 300,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const result = await runIterate(makeOpts({ noAutoMarkReady: true }));
|
|
177
|
+
|
|
178
|
+
expect(result.action).toBe("wait");
|
|
179
|
+
expect(result.pr).toBe(42);
|
|
180
|
+
expect(result.status).toBe("READY");
|
|
181
|
+
expect(result.summary.passing).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("runIterate — cancel", () => {
|
|
186
|
+
it("returns action: cancel when shouldCancel is true", async () => {
|
|
187
|
+
mockRunCheck.mockResolvedValue(makeReport());
|
|
188
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
189
|
+
isReady: true,
|
|
190
|
+
shouldCancel: true,
|
|
191
|
+
remainingSeconds: 0,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const result = await runIterate(makeOpts());
|
|
195
|
+
|
|
196
|
+
expect(result.action).toBe("cancel");
|
|
197
|
+
expect(result.shouldCancel).toBe(true);
|
|
198
|
+
expect(result.remainingSeconds).toBe(0);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("runIterate — fix_code (actionable threads)", () => {
|
|
203
|
+
it("returns action: fix_code with 2 actionable threads and 0 CI failures", async () => {
|
|
204
|
+
const thread1 = {
|
|
205
|
+
id: "thread-1",
|
|
206
|
+
isResolved: false,
|
|
207
|
+
isOutdated: false,
|
|
208
|
+
path: "src/foo.mts",
|
|
209
|
+
line: 10,
|
|
210
|
+
author: "reviewer",
|
|
211
|
+
body: "Fix this bug",
|
|
212
|
+
createdAtUnix: NOW - 3600,
|
|
213
|
+
};
|
|
214
|
+
const thread2 = {
|
|
215
|
+
id: "thread-2",
|
|
216
|
+
isResolved: false,
|
|
217
|
+
isOutdated: false,
|
|
218
|
+
path: "src/bar.mts",
|
|
219
|
+
line: 20,
|
|
220
|
+
author: "reviewer",
|
|
221
|
+
body: "Fix this too",
|
|
222
|
+
createdAtUnix: NOW - 3600,
|
|
223
|
+
};
|
|
224
|
+
mockRunCheck.mockResolvedValue(
|
|
225
|
+
makeReport({
|
|
226
|
+
status: "UNRESOLVED_COMMENTS",
|
|
227
|
+
threads: { actionable: [thread1, thread2], autoResolved: [], autoResolveErrors: [] },
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
231
|
+
isReady: false,
|
|
232
|
+
shouldCancel: false,
|
|
233
|
+
remainingSeconds: 600,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const result = await runIterate(makeOpts());
|
|
237
|
+
|
|
238
|
+
expect(result.action).toBe("fix_code");
|
|
239
|
+
if (result.action === "fix_code") {
|
|
240
|
+
expect(result.fix.threads).toHaveLength(2);
|
|
241
|
+
expect(result.fix.comments).toHaveLength(0);
|
|
242
|
+
expect(result.fix.checks).toHaveLength(0);
|
|
243
|
+
expect(result.cancelled).toHaveLength(0);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("runIterate — fix_code (actionable CI failure)", () => {
|
|
249
|
+
function makeActionableCheck(runId: string, name = "typecheck") {
|
|
250
|
+
return {
|
|
251
|
+
name,
|
|
252
|
+
status: "COMPLETED" as const,
|
|
253
|
+
conclusion: "FAILURE" as const,
|
|
254
|
+
detailsUrl: `https://github.com/owner/repo/actions/runs/${runId}`,
|
|
255
|
+
event: "pull_request",
|
|
256
|
+
runId,
|
|
257
|
+
category: "failing" as const,
|
|
258
|
+
failureKind: "actionable" as const,
|
|
259
|
+
logExcerpt: "error TS2345: type mismatch",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
it("calls gh run cancel and returns action: fix_code (all succeed)", async () => {
|
|
264
|
+
const actionableCheck = makeActionableCheck("run-99");
|
|
265
|
+
mockRunCheck.mockResolvedValue(
|
|
266
|
+
makeReport({
|
|
267
|
+
status: "FAILING",
|
|
268
|
+
checks: {
|
|
269
|
+
passing: [],
|
|
270
|
+
failing: [actionableCheck],
|
|
271
|
+
inProgress: [],
|
|
272
|
+
skipped: [],
|
|
273
|
+
filtered: [],
|
|
274
|
+
filteredNames: [],
|
|
275
|
+
blockedByFilteredCheck: false,
|
|
276
|
+
},
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
280
|
+
isReady: false,
|
|
281
|
+
shouldCancel: false,
|
|
282
|
+
remainingSeconds: 600,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const result = await runIterate(makeOpts());
|
|
286
|
+
|
|
287
|
+
expect(result.action).toBe("fix_code");
|
|
288
|
+
if (result.action === "fix_code") {
|
|
289
|
+
expect(result.fix.checks).toHaveLength(1);
|
|
290
|
+
expect(result.cancelled).toEqual(["run-99"]);
|
|
291
|
+
}
|
|
292
|
+
expect(mockExecFile).toHaveBeenCalledWith("gh", ["run", "cancel", "run-99"]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("returns fix_code with partial cancelled when one gh run cancel fails", async () => {
|
|
296
|
+
const check1 = makeActionableCheck("run-100", "typecheck");
|
|
297
|
+
const check2 = makeActionableCheck("run-101", "lint");
|
|
298
|
+
mockRunCheck.mockResolvedValue(
|
|
299
|
+
makeReport({
|
|
300
|
+
status: "FAILING",
|
|
301
|
+
checks: {
|
|
302
|
+
passing: [],
|
|
303
|
+
failing: [check1, check2],
|
|
304
|
+
inProgress: [],
|
|
305
|
+
skipped: [],
|
|
306
|
+
filtered: [],
|
|
307
|
+
filteredNames: [],
|
|
308
|
+
blockedByFilteredCheck: false,
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
313
|
+
isReady: false,
|
|
314
|
+
shouldCancel: false,
|
|
315
|
+
remainingSeconds: 600,
|
|
316
|
+
});
|
|
317
|
+
mockExecFile.mockImplementation((_cmd: string, args: string[]) => {
|
|
318
|
+
if (args[0] === "run" && args[1] === "cancel" && args[2] === "run-100") {
|
|
319
|
+
return Promise.reject(new Error("run already completed"));
|
|
320
|
+
}
|
|
321
|
+
return Promise.resolve({ stdout: "", stderr: "" });
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const result = await runIterate(makeOpts());
|
|
325
|
+
|
|
326
|
+
expect(result.action).toBe("fix_code");
|
|
327
|
+
if (result.action === "fix_code") {
|
|
328
|
+
expect(result.fix.checks).toHaveLength(2);
|
|
329
|
+
expect(result.cancelled).toEqual(["run-101"]);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("returns fix_code with empty cancelled when all gh run cancel calls fail (regression: PR #2112)", async () => {
|
|
334
|
+
const actionableCheck = makeActionableCheck("run-200");
|
|
335
|
+
mockRunCheck.mockResolvedValue(
|
|
336
|
+
makeReport({
|
|
337
|
+
status: "FAILING",
|
|
338
|
+
checks: {
|
|
339
|
+
passing: [],
|
|
340
|
+
failing: [actionableCheck],
|
|
341
|
+
inProgress: [],
|
|
342
|
+
skipped: [],
|
|
343
|
+
filtered: [],
|
|
344
|
+
filteredNames: [],
|
|
345
|
+
blockedByFilteredCheck: false,
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
350
|
+
isReady: false,
|
|
351
|
+
shouldCancel: false,
|
|
352
|
+
remainingSeconds: 600,
|
|
353
|
+
});
|
|
354
|
+
mockExecFile.mockRejectedValue(new Error("this run has already completed"));
|
|
355
|
+
|
|
356
|
+
const result = await runIterate(makeOpts());
|
|
357
|
+
|
|
358
|
+
// The fix_code decision must survive even when cancel side-effect fails entirely.
|
|
359
|
+
expect(result.action).toBe("fix_code");
|
|
360
|
+
if (result.action === "fix_code") {
|
|
361
|
+
expect(result.fix.checks).toHaveLength(1);
|
|
362
|
+
expect(result.cancelled).toEqual([]);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("deduplicates runIds — two checks sharing a runId call gh run cancel only once", async () => {
|
|
367
|
+
const check1 = makeActionableCheck("run-300", "typecheck");
|
|
368
|
+
const check2 = makeActionableCheck("run-300", "lint");
|
|
369
|
+
mockRunCheck.mockResolvedValue(
|
|
370
|
+
makeReport({
|
|
371
|
+
status: "FAILING",
|
|
372
|
+
checks: {
|
|
373
|
+
passing: [],
|
|
374
|
+
failing: [check1, check2],
|
|
375
|
+
inProgress: [],
|
|
376
|
+
skipped: [],
|
|
377
|
+
filtered: [],
|
|
378
|
+
filteredNames: [],
|
|
379
|
+
blockedByFilteredCheck: false,
|
|
380
|
+
},
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
384
|
+
isReady: false,
|
|
385
|
+
shouldCancel: false,
|
|
386
|
+
remainingSeconds: 600,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const result = await runIterate(makeOpts());
|
|
390
|
+
|
|
391
|
+
expect(result.action).toBe("fix_code");
|
|
392
|
+
if (result.action === "fix_code") {
|
|
393
|
+
expect(result.fix.checks).toHaveLength(2);
|
|
394
|
+
expect(result.cancelled).toEqual(["run-300"]);
|
|
395
|
+
}
|
|
396
|
+
const cancelCalls = mockExecFile.mock.calls.filter(
|
|
397
|
+
(call) => call[1]?.[0] === "run" && call[1]?.[1] === "cancel",
|
|
398
|
+
);
|
|
399
|
+
expect(cancelCalls).toHaveLength(1);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("runIterate — rerun_ci", () => {
|
|
404
|
+
it("calls gh run rerun for 2 timeout failures and returns action: rerun_ci", async () => {
|
|
405
|
+
const timeoutCheck1 = {
|
|
406
|
+
name: "test-1",
|
|
407
|
+
status: "COMPLETED" as const,
|
|
408
|
+
conclusion: "TIMED_OUT" as const,
|
|
409
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/10",
|
|
410
|
+
event: "pull_request",
|
|
411
|
+
runId: "run-10",
|
|
412
|
+
category: "failing" as const,
|
|
413
|
+
failureKind: "timeout" as const,
|
|
414
|
+
};
|
|
415
|
+
const timeoutCheck2 = {
|
|
416
|
+
name: "test-2",
|
|
417
|
+
status: "COMPLETED" as const,
|
|
418
|
+
conclusion: "TIMED_OUT" as const,
|
|
419
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/11",
|
|
420
|
+
event: "pull_request",
|
|
421
|
+
runId: "run-11",
|
|
422
|
+
category: "failing" as const,
|
|
423
|
+
failureKind: "timeout" as const,
|
|
424
|
+
};
|
|
425
|
+
mockRunCheck.mockResolvedValue(
|
|
426
|
+
makeReport({
|
|
427
|
+
status: "FAILING",
|
|
428
|
+
checks: {
|
|
429
|
+
passing: [],
|
|
430
|
+
failing: [timeoutCheck1, timeoutCheck2],
|
|
431
|
+
inProgress: [],
|
|
432
|
+
skipped: [],
|
|
433
|
+
filtered: [],
|
|
434
|
+
filteredNames: [],
|
|
435
|
+
blockedByFilteredCheck: false,
|
|
436
|
+
},
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
440
|
+
isReady: false,
|
|
441
|
+
shouldCancel: false,
|
|
442
|
+
remainingSeconds: 600,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const result = await runIterate(makeOpts());
|
|
446
|
+
|
|
447
|
+
expect(result.action).toBe("rerun_ci");
|
|
448
|
+
if (result.action === "rerun_ci") {
|
|
449
|
+
expect(result.reran).toContain("run-10");
|
|
450
|
+
expect(result.reran).toContain("run-11");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Verify gh run rerun was called for both
|
|
454
|
+
expect(mockExecFile).toHaveBeenCalledWith("gh", ["run", "rerun", "run-10", "--failed"]);
|
|
455
|
+
expect(mockExecFile).toHaveBeenCalledWith("gh", ["run", "rerun", "run-11", "--failed"]);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("deduplicates runIds when multiple failing steps share the same run", async () => {
|
|
459
|
+
const check1 = {
|
|
460
|
+
name: "test-step-1",
|
|
461
|
+
status: "COMPLETED" as const,
|
|
462
|
+
conclusion: "FAILURE" as const,
|
|
463
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/20",
|
|
464
|
+
event: "pull_request",
|
|
465
|
+
runId: "run-20",
|
|
466
|
+
category: "failing" as const,
|
|
467
|
+
failureKind: "infrastructure" as const,
|
|
468
|
+
};
|
|
469
|
+
const check2 = {
|
|
470
|
+
name: "test-step-2",
|
|
471
|
+
status: "COMPLETED" as const,
|
|
472
|
+
conclusion: "FAILURE" as const,
|
|
473
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/20",
|
|
474
|
+
event: "pull_request",
|
|
475
|
+
runId: "run-20", // same runId
|
|
476
|
+
category: "failing" as const,
|
|
477
|
+
failureKind: "infrastructure" as const,
|
|
478
|
+
};
|
|
479
|
+
mockRunCheck.mockResolvedValue(
|
|
480
|
+
makeReport({
|
|
481
|
+
status: "FAILING",
|
|
482
|
+
checks: {
|
|
483
|
+
passing: [],
|
|
484
|
+
failing: [check1, check2],
|
|
485
|
+
inProgress: [],
|
|
486
|
+
skipped: [],
|
|
487
|
+
filtered: [],
|
|
488
|
+
filteredNames: [],
|
|
489
|
+
blockedByFilteredCheck: false,
|
|
490
|
+
},
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
494
|
+
isReady: false,
|
|
495
|
+
shouldCancel: false,
|
|
496
|
+
remainingSeconds: 600,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const result = await runIterate(makeOpts());
|
|
500
|
+
|
|
501
|
+
expect(result.action).toBe("rerun_ci");
|
|
502
|
+
if (result.action === "rerun_ci") {
|
|
503
|
+
expect(result.reran).toHaveLength(1);
|
|
504
|
+
expect(result.reran[0]).toBe("run-20");
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe("runIterate — rebase", () => {
|
|
510
|
+
it("returns action: rebase when flaky failure + BEHIND", async () => {
|
|
511
|
+
const flakyCheck = {
|
|
512
|
+
name: "flaky-test",
|
|
513
|
+
status: "COMPLETED" as const,
|
|
514
|
+
conclusion: "FAILURE" as const,
|
|
515
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/30",
|
|
516
|
+
event: "pull_request",
|
|
517
|
+
runId: "run-30",
|
|
518
|
+
category: "failing" as const,
|
|
519
|
+
failureKind: "flaky" as const,
|
|
520
|
+
};
|
|
521
|
+
mockRunCheck.mockResolvedValue(
|
|
522
|
+
makeReport({
|
|
523
|
+
status: "FAILING",
|
|
524
|
+
mergeStatus: {
|
|
525
|
+
status: "BEHIND",
|
|
526
|
+
state: "OPEN" as const,
|
|
527
|
+
isDraft: false,
|
|
528
|
+
mergeable: "MERGEABLE",
|
|
529
|
+
reviewDecision: null,
|
|
530
|
+
copilotReviewInProgress: false,
|
|
531
|
+
mergeStateStatus: "BEHIND",
|
|
532
|
+
},
|
|
533
|
+
checks: {
|
|
534
|
+
passing: [],
|
|
535
|
+
failing: [flakyCheck],
|
|
536
|
+
inProgress: [],
|
|
537
|
+
skipped: [],
|
|
538
|
+
filtered: [],
|
|
539
|
+
filteredNames: [],
|
|
540
|
+
blockedByFilteredCheck: false,
|
|
541
|
+
},
|
|
542
|
+
}),
|
|
543
|
+
);
|
|
544
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
545
|
+
isReady: false,
|
|
546
|
+
shouldCancel: false,
|
|
547
|
+
remainingSeconds: 600,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const result = await runIterate(makeOpts());
|
|
551
|
+
|
|
552
|
+
expect(result.action).toBe("rebase");
|
|
553
|
+
expect(result.mergeStateStatus).toBe("BEHIND");
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
describe("runIterate — fix_code (merge conflicts)", () => {
|
|
558
|
+
it("returns action: fix_code when mergeStatus is CONFLICTS (rebase happens in fix_code handler)", async () => {
|
|
559
|
+
mockRunCheck.mockResolvedValue(
|
|
560
|
+
makeReport({
|
|
561
|
+
status: "FAILING",
|
|
562
|
+
mergeStatus: {
|
|
563
|
+
status: "CONFLICTS",
|
|
564
|
+
state: "OPEN" as const,
|
|
565
|
+
isDraft: false,
|
|
566
|
+
mergeable: "CONFLICTING",
|
|
567
|
+
reviewDecision: null,
|
|
568
|
+
copilotReviewInProgress: false,
|
|
569
|
+
mergeStateStatus: "DIRTY",
|
|
570
|
+
},
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
574
|
+
isReady: false,
|
|
575
|
+
shouldCancel: false,
|
|
576
|
+
remainingSeconds: 600,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const result = await runIterate(makeOpts());
|
|
580
|
+
|
|
581
|
+
expect(result.action).toBe("fix_code");
|
|
582
|
+
if (result.action === "fix_code") {
|
|
583
|
+
expect(result.fix.threads).toHaveLength(0);
|
|
584
|
+
expect(result.fix.checks).toHaveLength(0);
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("returns fix_code with threads when CONFLICTS + actionable comments exist (one push)", async () => {
|
|
589
|
+
const thread = {
|
|
590
|
+
id: "thread-1",
|
|
591
|
+
isResolved: false,
|
|
592
|
+
isOutdated: false,
|
|
593
|
+
path: "src/foo.mts",
|
|
594
|
+
line: 10,
|
|
595
|
+
author: "reviewer",
|
|
596
|
+
body: "Fix this",
|
|
597
|
+
createdAtUnix: 1700000000,
|
|
598
|
+
};
|
|
599
|
+
mockRunCheck.mockResolvedValue(
|
|
600
|
+
makeReport({
|
|
601
|
+
status: "FAILING",
|
|
602
|
+
mergeStatus: {
|
|
603
|
+
status: "CONFLICTS",
|
|
604
|
+
state: "OPEN" as const,
|
|
605
|
+
isDraft: false,
|
|
606
|
+
mergeable: "CONFLICTING",
|
|
607
|
+
reviewDecision: null,
|
|
608
|
+
copilotReviewInProgress: false,
|
|
609
|
+
mergeStateStatus: "DIRTY",
|
|
610
|
+
},
|
|
611
|
+
threads: { actionable: [thread], autoResolved: [], autoResolveErrors: [] },
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
614
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
615
|
+
isReady: false,
|
|
616
|
+
shouldCancel: false,
|
|
617
|
+
remainingSeconds: 600,
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
const result = await runIterate(makeOpts());
|
|
621
|
+
|
|
622
|
+
expect(result.action).toBe("fix_code");
|
|
623
|
+
if (result.action === "fix_code") {
|
|
624
|
+
expect(result.fix.threads).toHaveLength(1);
|
|
625
|
+
expect(result.fix.threads[0]?.id).toBe("thread-1");
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
describe("runIterate — mark_ready", () => {
|
|
631
|
+
it("calls gh pr ready and returns action: mark_ready for READY + CLEAN + isDraft", async () => {
|
|
632
|
+
mockRunCheck.mockResolvedValue(
|
|
633
|
+
makeReport({
|
|
634
|
+
status: "READY",
|
|
635
|
+
mergeStatus: {
|
|
636
|
+
status: "CLEAN",
|
|
637
|
+
state: "OPEN" as const,
|
|
638
|
+
isDraft: true,
|
|
639
|
+
mergeable: "MERGEABLE",
|
|
640
|
+
reviewDecision: "APPROVED",
|
|
641
|
+
copilotReviewInProgress: false,
|
|
642
|
+
mergeStateStatus: "CLEAN",
|
|
643
|
+
},
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
647
|
+
isReady: true,
|
|
648
|
+
shouldCancel: false,
|
|
649
|
+
remainingSeconds: 300,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const result = await runIterate(makeOpts());
|
|
653
|
+
|
|
654
|
+
expect(result.action).toBe("mark_ready");
|
|
655
|
+
if (result.action === "mark_ready") {
|
|
656
|
+
expect(result.markedReady).toBe(true);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
expect(mockExecFile).toHaveBeenCalledWith("gh", ["pr", "ready", "42"]);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("does NOT mark ready when copilotReviewInProgress", async () => {
|
|
663
|
+
mockRunCheck.mockResolvedValue(
|
|
664
|
+
makeReport({
|
|
665
|
+
status: "READY",
|
|
666
|
+
mergeStatus: {
|
|
667
|
+
status: "CLEAN",
|
|
668
|
+
state: "OPEN" as const,
|
|
669
|
+
isDraft: true,
|
|
670
|
+
mergeable: "MERGEABLE",
|
|
671
|
+
reviewDecision: "APPROVED",
|
|
672
|
+
copilotReviewInProgress: true,
|
|
673
|
+
mergeStateStatus: "CLEAN",
|
|
674
|
+
},
|
|
675
|
+
}),
|
|
676
|
+
);
|
|
677
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
678
|
+
isReady: true,
|
|
679
|
+
shouldCancel: false,
|
|
680
|
+
remainingSeconds: 300,
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const result = await runIterate(makeOpts());
|
|
684
|
+
|
|
685
|
+
expect(result.action).toBe("wait");
|
|
686
|
+
expect(mockExecFile).not.toHaveBeenCalledWith("gh", expect.arrayContaining(["pr", "ready"]));
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
describe("runIterate — cancel on merged/closed PR", () => {
|
|
691
|
+
it("returns action: cancel and does not call updateReadyDelay when PR is MERGED", async () => {
|
|
692
|
+
mockRunCheck.mockResolvedValue(
|
|
693
|
+
makeReport({
|
|
694
|
+
mergeStatus: {
|
|
695
|
+
status: "UNKNOWN",
|
|
696
|
+
state: "MERGED",
|
|
697
|
+
isDraft: false,
|
|
698
|
+
mergeable: "UNKNOWN",
|
|
699
|
+
reviewDecision: null,
|
|
700
|
+
copilotReviewInProgress: false,
|
|
701
|
+
mergeStateStatus: "UNKNOWN",
|
|
702
|
+
},
|
|
703
|
+
}),
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
const result = await runIterate(makeOpts());
|
|
707
|
+
|
|
708
|
+
expect(result.action).toBe("cancel");
|
|
709
|
+
expect(result.state).toBe("MERGED");
|
|
710
|
+
expect(mockUpdateReadyDelay).not.toHaveBeenCalled();
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
it("returns action: cancel when PR is CLOSED", async () => {
|
|
714
|
+
mockRunCheck.mockResolvedValue(
|
|
715
|
+
makeReport({
|
|
716
|
+
mergeStatus: {
|
|
717
|
+
status: "UNKNOWN",
|
|
718
|
+
state: "CLOSED",
|
|
719
|
+
isDraft: false,
|
|
720
|
+
mergeable: "UNKNOWN",
|
|
721
|
+
reviewDecision: null,
|
|
722
|
+
copilotReviewInProgress: false,
|
|
723
|
+
mergeStateStatus: "UNKNOWN",
|
|
724
|
+
},
|
|
725
|
+
}),
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
const result = await runIterate(makeOpts());
|
|
729
|
+
|
|
730
|
+
expect(result.action).toBe("cancel");
|
|
731
|
+
expect(result.state).toBe("CLOSED");
|
|
732
|
+
expect(mockUpdateReadyDelay).not.toHaveBeenCalled();
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
describe("runIterate — deferred triage", () => {
|
|
737
|
+
it("skips triage when PR is MERGED and checks are failing", async () => {
|
|
738
|
+
mockRunCheck.mockResolvedValue(
|
|
739
|
+
makeReport({
|
|
740
|
+
mergeStatus: {
|
|
741
|
+
status: "UNKNOWN",
|
|
742
|
+
state: "MERGED",
|
|
743
|
+
isDraft: false,
|
|
744
|
+
mergeable: "UNKNOWN",
|
|
745
|
+
reviewDecision: null,
|
|
746
|
+
copilotReviewInProgress: false,
|
|
747
|
+
mergeStateStatus: "UNKNOWN",
|
|
748
|
+
},
|
|
749
|
+
checks: {
|
|
750
|
+
passing: [],
|
|
751
|
+
failing: [
|
|
752
|
+
{
|
|
753
|
+
name: "ci",
|
|
754
|
+
status: "COMPLETED",
|
|
755
|
+
conclusion: "FAILURE",
|
|
756
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/1",
|
|
757
|
+
event: "pull_request",
|
|
758
|
+
runId: "run-1",
|
|
759
|
+
category: "failing",
|
|
760
|
+
},
|
|
761
|
+
],
|
|
762
|
+
inProgress: [],
|
|
763
|
+
skipped: [],
|
|
764
|
+
filtered: [],
|
|
765
|
+
filteredNames: [],
|
|
766
|
+
blockedByFilteredCheck: false,
|
|
767
|
+
},
|
|
768
|
+
}),
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
const result = await runIterate(makeOpts());
|
|
772
|
+
|
|
773
|
+
expect(result.action).toBe("cancel");
|
|
774
|
+
expect(mockTriageFailingChecks).not.toHaveBeenCalled();
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it("runs triage for CONFLICTS + failing checks, returns fix_code", async () => {
|
|
778
|
+
mockRunCheck.mockResolvedValue(
|
|
779
|
+
makeReport({
|
|
780
|
+
status: "FAILING",
|
|
781
|
+
mergeStatus: {
|
|
782
|
+
status: "CONFLICTS",
|
|
783
|
+
state: "OPEN",
|
|
784
|
+
isDraft: false,
|
|
785
|
+
mergeable: "CONFLICTING",
|
|
786
|
+
reviewDecision: null,
|
|
787
|
+
copilotReviewInProgress: false,
|
|
788
|
+
mergeStateStatus: "DIRTY",
|
|
789
|
+
},
|
|
790
|
+
checks: {
|
|
791
|
+
passing: [],
|
|
792
|
+
failing: [
|
|
793
|
+
{
|
|
794
|
+
name: "ci",
|
|
795
|
+
status: "COMPLETED",
|
|
796
|
+
conclusion: "FAILURE",
|
|
797
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/2",
|
|
798
|
+
event: "pull_request",
|
|
799
|
+
runId: "run-2",
|
|
800
|
+
category: "failing",
|
|
801
|
+
},
|
|
802
|
+
],
|
|
803
|
+
inProgress: [],
|
|
804
|
+
skipped: [],
|
|
805
|
+
filtered: [],
|
|
806
|
+
filteredNames: [],
|
|
807
|
+
blockedByFilteredCheck: false,
|
|
808
|
+
},
|
|
809
|
+
}),
|
|
810
|
+
);
|
|
811
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
812
|
+
isReady: false,
|
|
813
|
+
shouldCancel: false,
|
|
814
|
+
remainingSeconds: 600,
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
const result = await runIterate(makeOpts());
|
|
818
|
+
|
|
819
|
+
// Triage runs before the CONFLICTS/actionable check now.
|
|
820
|
+
expect(mockTriageFailingChecks).toHaveBeenCalledOnce();
|
|
821
|
+
// CONFLICTS is actionable — fix_code handler does the rebase.
|
|
822
|
+
expect(result.action).toBe("fix_code");
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it("calls triage for OPEN PR with failing checks and surfaces actionable failureKind in fix payload", async () => {
|
|
826
|
+
const failingCheck = {
|
|
827
|
+
name: "typecheck",
|
|
828
|
+
status: "COMPLETED" as const,
|
|
829
|
+
conclusion: "FAILURE" as const,
|
|
830
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/3",
|
|
831
|
+
event: "pull_request",
|
|
832
|
+
runId: "run-3",
|
|
833
|
+
category: "failing" as const,
|
|
834
|
+
};
|
|
835
|
+
mockRunCheck.mockResolvedValue(
|
|
836
|
+
makeReport({
|
|
837
|
+
status: "FAILING",
|
|
838
|
+
checks: {
|
|
839
|
+
passing: [],
|
|
840
|
+
failing: [failingCheck],
|
|
841
|
+
inProgress: [],
|
|
842
|
+
skipped: [],
|
|
843
|
+
filtered: [],
|
|
844
|
+
filteredNames: [],
|
|
845
|
+
blockedByFilteredCheck: false,
|
|
846
|
+
},
|
|
847
|
+
}),
|
|
848
|
+
);
|
|
849
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
850
|
+
isReady: false,
|
|
851
|
+
shouldCancel: false,
|
|
852
|
+
remainingSeconds: 600,
|
|
853
|
+
});
|
|
854
|
+
// Mock triage to return check with failureKind: 'actionable'
|
|
855
|
+
mockTriageFailingChecks.mockResolvedValue([
|
|
856
|
+
{ ...failingCheck, failureKind: "actionable", logExcerpt: "error TS2345: type mismatch" },
|
|
857
|
+
]);
|
|
858
|
+
|
|
859
|
+
const result = await runIterate(makeOpts());
|
|
860
|
+
|
|
861
|
+
expect(mockTriageFailingChecks).toHaveBeenCalledOnce();
|
|
862
|
+
expect(result.action).toBe("fix_code");
|
|
863
|
+
if (result.action === "fix_code") {
|
|
864
|
+
expect(result.fix.checks).toHaveLength(1);
|
|
865
|
+
expect(result.fix.checks[0]?.failureKind).toBe("actionable");
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
// ---------------------------------------------------------------------------
|
|
871
|
+
// Escalate
|
|
872
|
+
// ---------------------------------------------------------------------------
|
|
873
|
+
|
|
874
|
+
const THREAD = {
|
|
875
|
+
id: "thread-1",
|
|
876
|
+
isResolved: false,
|
|
877
|
+
isOutdated: false,
|
|
878
|
+
path: "src/foo.mts",
|
|
879
|
+
line: 10,
|
|
880
|
+
author: "reviewer",
|
|
881
|
+
body: "Fix this",
|
|
882
|
+
createdAtUnix: NOW - 3600,
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
describe("runIterate — escalate (fix-thrash)", () => {
|
|
886
|
+
it("escalates when a thread has been attempted >= maxFixAttempts times", async () => {
|
|
887
|
+
mockReadFixAttempts.mockResolvedValue({ headSha: "abc123", threadAttempts: { "thread-1": 3 } });
|
|
888
|
+
mockRunCheck.mockResolvedValue(
|
|
889
|
+
makeReport({
|
|
890
|
+
status: "UNRESOLVED_COMMENTS",
|
|
891
|
+
threads: { actionable: [THREAD], autoResolved: [], autoResolveErrors: [] },
|
|
892
|
+
}),
|
|
893
|
+
);
|
|
894
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
895
|
+
isReady: false,
|
|
896
|
+
shouldCancel: false,
|
|
897
|
+
remainingSeconds: 600,
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const result = await runIterate(makeOpts());
|
|
901
|
+
|
|
902
|
+
expect(result.action).toBe("escalate");
|
|
903
|
+
if (result.action === "escalate") {
|
|
904
|
+
expect(result.escalate.triggers).toContain("fix-thrash");
|
|
905
|
+
expect(result.escalate.attemptHistory).toHaveLength(1);
|
|
906
|
+
expect(result.escalate.attemptHistory?.[0]?.threadId).toBe("thread-1");
|
|
907
|
+
expect(result.escalate.attemptHistory?.[0]?.attempts).toBe(3);
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("does NOT escalate when attempt count is below threshold (attempt=2)", async () => {
|
|
912
|
+
mockReadFixAttempts.mockResolvedValue({ headSha: "abc123", threadAttempts: { "thread-1": 2 } });
|
|
913
|
+
mockRunCheck.mockResolvedValue(
|
|
914
|
+
makeReport({
|
|
915
|
+
status: "UNRESOLVED_COMMENTS",
|
|
916
|
+
threads: { actionable: [THREAD], autoResolved: [], autoResolveErrors: [] },
|
|
917
|
+
}),
|
|
918
|
+
);
|
|
919
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
920
|
+
isReady: false,
|
|
921
|
+
shouldCancel: false,
|
|
922
|
+
remainingSeconds: 600,
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
const result = await runIterate(makeOpts());
|
|
926
|
+
|
|
927
|
+
expect(result.action).toBe("fix_code");
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it("resets attempt counts when HEAD SHA changes and does NOT escalate", async () => {
|
|
931
|
+
// Stored state has SHA 'old-sha' with 5 attempts — should be discarded.
|
|
932
|
+
mockReadFixAttempts.mockResolvedValue({
|
|
933
|
+
headSha: "old-sha",
|
|
934
|
+
threadAttempts: { "thread-1": 5 },
|
|
935
|
+
});
|
|
936
|
+
mockRunCheck.mockResolvedValue(
|
|
937
|
+
makeReport({
|
|
938
|
+
status: "UNRESOLVED_COMMENTS",
|
|
939
|
+
threads: { actionable: [THREAD], autoResolved: [], autoResolveErrors: [] },
|
|
940
|
+
}),
|
|
941
|
+
);
|
|
942
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
943
|
+
isReady: false,
|
|
944
|
+
shouldCancel: false,
|
|
945
|
+
remainingSeconds: 600,
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
const result = await runIterate(makeOpts());
|
|
949
|
+
|
|
950
|
+
// Old SHA 'old-sha' ≠ current 'abc123' → counts reset → no escalation.
|
|
951
|
+
expect(result.action).toBe("fix_code");
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
it("increments attempt count and calls writeFixAttempts on fix_code dispatch", async () => {
|
|
955
|
+
mockReadFixAttempts.mockResolvedValue({ headSha: "abc123", threadAttempts: { "thread-1": 1 } });
|
|
956
|
+
mockRunCheck.mockResolvedValue(
|
|
957
|
+
makeReport({
|
|
958
|
+
status: "UNRESOLVED_COMMENTS",
|
|
959
|
+
threads: { actionable: [THREAD], autoResolved: [], autoResolveErrors: [] },
|
|
960
|
+
}),
|
|
961
|
+
);
|
|
962
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
963
|
+
isReady: false,
|
|
964
|
+
shouldCancel: false,
|
|
965
|
+
remainingSeconds: 600,
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
const result = await runIterate(makeOpts());
|
|
969
|
+
|
|
970
|
+
expect(result.action).toBe("fix_code");
|
|
971
|
+
expect(mockWriteFixAttempts).toHaveBeenCalledOnce();
|
|
972
|
+
const [, written] = mockWriteFixAttempts.mock.calls[0]!;
|
|
973
|
+
expect(written.threadAttempts["thread-1"]).toBe(2);
|
|
974
|
+
});
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
describe("runIterate — escalate (pr-level-changes-requested)", () => {
|
|
978
|
+
it("escalates when changesRequestedReviews with no inline threads or CI failures", async () => {
|
|
979
|
+
mockRunCheck.mockResolvedValue(
|
|
980
|
+
makeReport({
|
|
981
|
+
status: "UNRESOLVED_COMMENTS",
|
|
982
|
+
changesRequestedReviews: [{ id: "review-1", author: "boss", body: "Needs rework" }],
|
|
983
|
+
threads: { actionable: [], autoResolved: [], autoResolveErrors: [] },
|
|
984
|
+
comments: { actionable: [] },
|
|
985
|
+
}),
|
|
986
|
+
);
|
|
987
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
988
|
+
isReady: false,
|
|
989
|
+
shouldCancel: false,
|
|
990
|
+
remainingSeconds: 600,
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
const result = await runIterate(makeOpts());
|
|
994
|
+
|
|
995
|
+
expect(result.action).toBe("escalate");
|
|
996
|
+
if (result.action === "escalate") {
|
|
997
|
+
expect(result.escalate.triggers).toContain("pr-level-changes-requested");
|
|
998
|
+
expect(result.escalate.changesRequestedReviews).toHaveLength(1);
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
describe("runIterate — escalate (pr-level-changes-requested suppressed during CONFLICTS)", () => {
|
|
1004
|
+
it("does NOT escalate when changesRequestedReviews + merge CONFLICTS (fix_code handles rebase)", async () => {
|
|
1005
|
+
mockRunCheck.mockResolvedValue(
|
|
1006
|
+
makeReport({
|
|
1007
|
+
status: "FAILING",
|
|
1008
|
+
mergeStatus: {
|
|
1009
|
+
status: "CONFLICTS",
|
|
1010
|
+
state: "OPEN" as const,
|
|
1011
|
+
isDraft: false,
|
|
1012
|
+
mergeable: "CONFLICTING",
|
|
1013
|
+
reviewDecision: null,
|
|
1014
|
+
copilotReviewInProgress: false,
|
|
1015
|
+
mergeStateStatus: "DIRTY",
|
|
1016
|
+
},
|
|
1017
|
+
changesRequestedReviews: [{ id: "review-1", author: "boss", body: "Needs rework" }],
|
|
1018
|
+
threads: { actionable: [], autoResolved: [], autoResolveErrors: [] },
|
|
1019
|
+
comments: { actionable: [] },
|
|
1020
|
+
}),
|
|
1021
|
+
);
|
|
1022
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
1023
|
+
isReady: false,
|
|
1024
|
+
shouldCancel: false,
|
|
1025
|
+
remainingSeconds: 600,
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
const result = await runIterate(makeOpts());
|
|
1029
|
+
|
|
1030
|
+
expect(result.action).toBe("fix_code");
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
describe("runIterate — escalate (pr-level-changes-requested with actionable comments)", () => {
|
|
1035
|
+
it("does NOT escalate when changesRequestedReviews + actionable comments exist", async () => {
|
|
1036
|
+
mockRunCheck.mockResolvedValue(
|
|
1037
|
+
makeReport({
|
|
1038
|
+
status: "UNRESOLVED_COMMENTS",
|
|
1039
|
+
changesRequestedReviews: [{ id: "review-1", author: "boss", body: "Needs rework" }],
|
|
1040
|
+
threads: { actionable: [], autoResolved: [], autoResolveErrors: [] },
|
|
1041
|
+
comments: {
|
|
1042
|
+
actionable: [
|
|
1043
|
+
{
|
|
1044
|
+
id: "comment-1",
|
|
1045
|
+
isMinimized: false,
|
|
1046
|
+
author: "boss",
|
|
1047
|
+
body: "See review",
|
|
1048
|
+
createdAtUnix: NOW - 100,
|
|
1049
|
+
},
|
|
1050
|
+
],
|
|
1051
|
+
},
|
|
1052
|
+
}),
|
|
1053
|
+
);
|
|
1054
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
1055
|
+
isReady: false,
|
|
1056
|
+
shouldCancel: false,
|
|
1057
|
+
remainingSeconds: 600,
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
const result = await runIterate(makeOpts());
|
|
1061
|
+
|
|
1062
|
+
expect(result.action).toBe("fix_code");
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
describe("runIterate — escalate (thread-missing-location)", () => {
|
|
1067
|
+
it("escalates when an actionable thread has no file/line reference", async () => {
|
|
1068
|
+
const threadNoPath = { ...THREAD, id: "thread-noloc", path: null, line: null };
|
|
1069
|
+
mockRunCheck.mockResolvedValue(
|
|
1070
|
+
makeReport({
|
|
1071
|
+
status: "UNRESOLVED_COMMENTS",
|
|
1072
|
+
threads: { actionable: [threadNoPath], autoResolved: [], autoResolveErrors: [] },
|
|
1073
|
+
}),
|
|
1074
|
+
);
|
|
1075
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
1076
|
+
isReady: false,
|
|
1077
|
+
shouldCancel: false,
|
|
1078
|
+
remainingSeconds: 600,
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
const result = await runIterate(makeOpts());
|
|
1082
|
+
|
|
1083
|
+
expect(result.action).toBe("escalate");
|
|
1084
|
+
if (result.action === "escalate") {
|
|
1085
|
+
expect(result.escalate.triggers).toContain("thread-missing-location");
|
|
1086
|
+
expect(result.escalate.suggestion).toBeTruthy();
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("escalates when an actionable thread has path but null line", async () => {
|
|
1091
|
+
const threadNoLine = { ...THREAD, id: "thread-noline", path: "src/foo.mts", line: null };
|
|
1092
|
+
mockRunCheck.mockResolvedValue(
|
|
1093
|
+
makeReport({
|
|
1094
|
+
status: "UNRESOLVED_COMMENTS",
|
|
1095
|
+
threads: { actionable: [threadNoLine], autoResolved: [], autoResolveErrors: [] },
|
|
1096
|
+
}),
|
|
1097
|
+
);
|
|
1098
|
+
mockUpdateReadyDelay.mockResolvedValue({
|
|
1099
|
+
isReady: false,
|
|
1100
|
+
shouldCancel: false,
|
|
1101
|
+
remainingSeconds: 600,
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
const result = await runIterate(makeOpts());
|
|
1105
|
+
|
|
1106
|
+
expect(result.action).toBe("escalate");
|
|
1107
|
+
if (result.action === "escalate") {
|
|
1108
|
+
expect(result.escalate.triggers).toContain("thread-missing-location");
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
});
|