1import {
2 type PullRequestCheck,
3 PullRequestComment,
4 ThreadPullRequestWatch,
5} from "@t3tools/contracts";
6import { assert, describe, it } from "@effect/vitest";
7
8import {
9 PULL_REQUEST_WATCH_WAKE_LIMIT,
10 evaluatePullRequestWatch as evaluate,
11 pullRequestWatchMessage,
12} from "./pullRequestWatch.ts";
13
14// These cases read the head once or at one moment, so the no-checks notice never comes due.
15const evaluatePullRequestWatch = (
16 ...args: Parameters<typeof evaluate> extends [...infer Head, number] ? Head : never
17) => evaluate(...args, 0);
18
19const STARTED = "2026-10-02T12:00:00.000Z";
20
21const watch = (overrides: Partial<ThreadPullRequestWatch> = {}): ThreadPullRequestWatch => ({
22 startedAt: STARTED,
23 headSha: null,
24 failedChecks: [],
25 passed: false,
26 passedChecks: [],
27 remarksThrough: STARTED,
28 remarkIds: [],
29 conflicting: false,
30 wakes: 0,
31 ...overrides,
32});
33
34const run = (id: number) => `https://github.com/o/r/actions/runs/${id}`;
35
36const check = (name: string, status: PullRequestCheck["status"]): PullRequestCheck => ({
37 name,
38 status,
39 description: null,
40 url: `https://ci.example/${name}`,
41});
42
43type Detail = Parameters<typeof evaluatePullRequestWatch>[1];
44
45const capabilities = (checksAlwaysReport: boolean): Detail["capabilities"] => ({
46 diff: true,
47 comment: true,
48 actions: [],
49 mergeMethods: [],
50 search: false,
51 review: { inlineComment: false, reply: false, resolve: false, verdicts: [] },
52 reviewers: { request: false, listCandidates: false },
53 checksAlwaysReport,
54});
55
56const detail = (overrides: Partial<Detail> = {}): Detail => ({
57 capabilities: capabilities(true),
58 headSha: "aaaaaaaaaa",
59 checks: [check("lint", "success"), check("test", "pending")],
60 mergeability: "mergeable",
61 viewer: "agent-user",
62 author: { login: "agent-user", name: null, avatarUrl: null },
63 ...overrides,
64});
65
66const remark = (
67 login: string,
68 createdAt: string,
69 body = "Please rename this.",
70): PullRequestComment => ({
71 id: `${login}-${createdAt}`,
72 kind: "review-comment",
73 author: { login, name: null, avatarUrl: null },
74 body,
75 createdAt,
76 url: `https://github.com/o/r/pull/1#${login}`,
77 path: "src/index.ts",
78 reviewState: null,
79});
80
81const noRemarks: ReadonlyArray<PullRequestComment> = [];
82
83describe("evaluatePullRequestWatch", () => {
84 it.each([false, true])("baselines already-passed checks (required: %s)", (required) => {
85 const green = detail({ checks: [{ ...check("test", "success"), required }] });
86 const baseline = evaluatePullRequestWatch(watch({ awaitingBaseline: "all" }), green, noRemarks);
87 assert.deepEqual(baseline.changes, []);
88 assert.equal(baseline.next.headSha, green.headSha);
89 assert.isTrue(baseline.next.passed);
90 assert.deepEqual(baseline.next.passedChecks, ["test"]);
91 assert.isFalse(baseline.exhausted);
92 assert.deepEqual(evaluatePullRequestWatch(baseline.next, green, noRemarks).changes, []);
93
94 const pending = evaluatePullRequestWatch(
95 baseline.next,
96 detail({ checks: [{ ...check("test", "pending"), required }] }),
97 noRemarks,
98 );
99 assert.deepEqual(pending.changes, []);
100 const failed = evaluatePullRequestWatch(
101 pending.next,
102 detail({ checks: [{ ...check("test", "failure"), required }] }),
103 noRemarks,
104 );
105 assert.equal(failed.changes[0]?.kind, "checks-failed");
106 const passed = evaluatePullRequestWatch(failed.next, green, noRemarks);
107 assert.deepEqual(passed.changes, [{ kind: "checks-passed", count: 1, required }]);
108 assert.deepEqual(
109 evaluatePullRequestWatch(passed.next, { ...green, headSha: "bbbbbbbbbb" }, noRemarks).changes,
110 [{ kind: "checks-passed", count: 1, required }],
111 );
112 });
113
114 it("baselines existing failures and conflicts, then reports new ones", () => {
115 const broken = detail({
116 checks: [check("lint", "failure"), check("test", "pending")],
117 mergeability: "conflicting",
118 });
119 const baseline = evaluatePullRequestWatch(
120 watch({ awaitingBaseline: "all" }),
121 broken,
122 noRemarks,
123 );
124 assert.deepEqual(baseline.changes, []);
125 assert.deepEqual(baseline.next.failedChecks, ["lint"]);
126 assert.isFalse(baseline.next.passed);
127 assert.isTrue(baseline.next.conflicting);
128 assert.deepEqual(evaluatePullRequestWatch(baseline.next, broken, noRemarks).changes, []);
129
130 const failed = evaluatePullRequestWatch(
131 baseline.next,
132 { ...broken, checks: [check("lint", "failure"), check("test", "failure")] },
133 noRemarks,
134 );
135 assert.deepEqual(failed.changes, [
136 { kind: "checks-failed", failed: [check("test", "failure")] },
137 ]);
138 const clean = evaluatePullRequestWatch(failed.next, detail(), noRemarks);
139 assert.deepEqual(clean.changes, []);
140 assert.deepEqual(evaluatePullRequestWatch(clean.next, broken, noRemarks).changes, [
141 { kind: "checks-failed", failed: [check("lint", "failure")] },
142 { kind: "conflicting" },
143 ]);
144 });
145
146 it("baselines checks already awaiting approval", () => {
147 const gated = detail({ checks: [{ ...check("CI", "action-required"), url: run(1) }] });
148 const baseline = evaluatePullRequestWatch(watch({ awaitingBaseline: "all" }), gated, noRemarks);
149 assert.deepEqual(baseline.changes, []);
150 assert.deepEqual(evaluatePullRequestWatch(baseline.next, gated, noRemarks).changes, []);
151 });
152
153 it("reports remarks after the watch started while baselining checks and conflicts", () => {
154 const green = detail({
155 checks: [check("test", "success")],
156 mergeability: "conflicting",
157 });
158 const comments = [
159 remark("reviewer", "2026-10-02T11:00:00Z"),
160 remark("reviewer", "2026-10-02T12:06:00Z"),
161 ];
162 const baseline = evaluatePullRequestWatch(watch({ awaitingBaseline: "all" }), green, comments);
163 assert.deepEqual(baseline.changes, [{ kind: "remarks", remarks: [comments[1]!] }]);
164 assert.isTrue(baseline.next.passed);
165 assert.isTrue(baseline.next.conflicting);
166 assert.equal(baseline.next.wakes, 1);
167 assert.deepEqual(evaluatePullRequestWatch(baseline.next, green, comments).changes, []);
168 });
169
170 it("reports a failure once the head SHA appears after a baseline without one", () => {
171 const waiting = evaluatePullRequestWatch(
172 watch({ awaitingBaseline: "all" }),
173 detail({ headSha: undefined, checks: [check("test", "pending")] }),
174 noRemarks,
175 );
176 assert.deepEqual(waiting.changes, []);
177 const failed = evaluatePullRequestWatch(
178 waiting.next,
179 detail({ checks: [check("test", "failure")] }),
180 noRemarks,
181 );
182 assert.deepEqual(failed.changes, [
183 { kind: "checks-failed", failed: [check("test", "failure")] },
184 ]);
185 });
186
187 it("keeps a baseline taken without a head SHA once the SHA appears", () => {
188 const green = detail({ checks: [check("test", "success")] });
189 const baseline = evaluatePullRequestWatch(
190 watch({ awaitingBaseline: "all" }),
191 { ...green, headSha: undefined },
192 noRemarks,
193 );
194 const known = evaluatePullRequestWatch(baseline.next, green, noRemarks);
195 assert.deepEqual(known.changes, []);
196 assert.equal(known.next.headSha, green.headSha);
197 const unknown = evaluatePullRequestWatch(
198 known.next,
199 { ...green, headSha: undefined },
200 noRemarks,
201 );
202 assert.equal(unknown.next.headSha, green.headSha);
203 assert.deepEqual(
204 evaluatePullRequestWatch(unknown.next, { ...green, headSha: "bbbbbbbbbb" }, noRemarks)
205 .changes,
206 [{ kind: "checks-passed", count: 1, required: false }],
207 );
208 });
209
210 it.each(["success", "failure"] as const)(
211 "reports %s on checks that appear after a first read without any",
212 (status) => {
213 const empty = evaluatePullRequestWatch(
214 watch({ awaitingBaseline: "all" }),
215 detail({ checks: [] }),
216 noRemarks,
217 );
218 assert.deepEqual(empty.changes, []);
219 assert.isUndefined(empty.next.awaitingBaseline);
220 assert.isTrue(empty.next.checksPending);
221 assert.deepEqual(
222 evaluatePullRequestWatch(empty.next, detail({ checks: [check("test", status)] }), noRemarks)
223 .changes,
224 status === "success"
225 ? [{ kind: "checks-passed", count: 1, required: false }]
226 : [{ kind: "checks-failed", failed: [check("test", "failure")] }],
227 );
228 },
229 );
230
231 it("keeps the check baseline through a read whose approval lookup failed", () => {
232 const bot = check("bot", "success");
233 const unavailable = { ...check("Workflow approval status", "pending"), placeholder: true };
234 const partial = evaluatePullRequestWatch(
235 watch({ awaitingBaseline: "all" }),
236 detail({ checks: [bot, unavailable] }),
237 noRemarks,
238 );
239 assert.deepEqual(partial.changes, []);
240 assert.equal(partial.next.awaitingBaseline, "checks");
241 const approval = { ...check("CI", "action-required"), url: run(1) };
242 for (const checks of [[bot], [bot, approval]]) {
243 assert.deepEqual(
244 evaluatePullRequestWatch(partial.next, detail({ checks }), noRemarks).changes,
245 [],
246 );
247 }
248 });
249
250 it("reports pending advisory checks finishing after a required-check baseline", () => {
251 const initial = detail({
252 checks: [{ ...check("test", "success"), required: true }, check("bot", "pending")],
253 });
254 const baseline = evaluatePullRequestWatch(
255 watch({ awaitingBaseline: "all" }),
256 initial,
257 noRemarks,
258 );
259 assert.deepEqual(baseline.changes, []);
260 const settled = evaluatePullRequestWatch(
261 baseline.next,
262 {
263 ...initial,
264 checks: [initial.checks[0]!, check("bot", "success")],
265 },
266 noRemarks,
267 );
268 assert.deepEqual(settled.changes, [{ kind: "checks-passed", count: 2, required: false }]);
269 assert.deepEqual(
270 evaluatePullRequestWatch(
271 settled.next,
272 {
273 ...initial,
274 checks: [initial.checks[0]!, check("bot", "success")],
275 },
276 noRemarks,
277 ).changes,
278 [],
279 );
280 });
281
282 it("waits for workflow approval once, through reads that miss it, until the run finishes", () => {
283 const bot = check("bot", "success");
284 const approval = { ...check("CI", "action-required"), url: run(1) };
285 const gated = detail({ checks: [bot, approval] });
286 const first = evaluatePullRequestWatch(watch(), gated, noRemarks);
287 assert.deepEqual(first.changes, [{ kind: "checks-awaiting-approval", checks: [approval] }]);
288
289 // GitHub's approval read can answer empty while the rest of the checks read fine.
290 const missing = evaluatePullRequestWatch(first.next, detail({ checks: [bot] }), noRemarks);
291 assert.deepEqual(missing.changes, []);
292 assert.isFalse(missing.next.passed);
293 assert.deepEqual(evaluatePullRequestWatch(missing.next, gated, noRemarks).changes, []);
294 const later = { ...check("Preview", "action-required"), url: run(2) };
295 assert.deepEqual(
296 evaluatePullRequestWatch(missing.next, detail({ checks: [bot, approval, later] }), noRemarks)
297 .changes,
298 [],
299 );
300 const unnamed = { ...check("Docs", "action-required"), url: null };
301 const named = detail({ checks: [bot, unnamed] });
302 const alone = evaluatePullRequestWatch(watch(), named, noRemarks);
303 assert.deepEqual(alone.changes, [{ kind: "checks-awaiting-approval", checks: [unnamed] }]);
304 const placeholder = { ...unnamed, name: "Workflow approval status", placeholder: true };
305 const unlisted = evaluatePullRequestWatch(
306 alone.next,
307 detail({ checks: [bot, placeholder] }),
308 noRemarks,
309 );
310 assert.deepEqual(unlisted.changes, []);
311 assert.isFalse(unlisted.next.passed);
312 assert.deepEqual(evaluatePullRequestWatch(unlisted.next, named, noRemarks).changes, []);
313 const dropped = evaluatePullRequestWatch(alone.next, detail({ checks: [bot] }), noRemarks);
314 assert.deepEqual(dropped.changes, []);
315 assert.isFalse(dropped.next.passed);
316 assert.deepEqual(dropped.next.awaitingApproval, [unnamed.name]);
317 assert.deepEqual(evaluatePullRequestWatch(dropped.next, named, noRemarks).changes, []);
318 const namedPending = evaluatePullRequestWatch(
319 dropped.next,
320 detail({
321 checks: [
322 { ...bot, required: true },
323 { ...unnamed, status: "pending" },
324 ],
325 }),
326 noRemarks,
327 );
328 assert.deepEqual(namedPending.changes, []);
329 assert.isFalse(namedPending.next.passed);
330 const namedDone = detail({ checks: [bot, { ...unnamed, status: "success" }] });
331 const namedFinished = evaluatePullRequestWatch(namedPending.next, namedDone, noRemarks);
332 assert.deepEqual(namedFinished.changes, [{ kind: "checks-passed", count: 2, required: false }]);
333 assert.deepEqual(namedFinished.next.awaitingApproval, []);
334 assert.deepEqual(
335 evaluatePullRequestWatch(namedFinished.next, namedDone, noRemarks).changes,
336 [],
337 );
338 const pushed = detail({ headSha: "bbbbbbbbbb", checks: [bot] });
339 assert.deepEqual(evaluatePullRequestWatch(missing.next, pushed, noRemarks).changes, [
340 { kind: "checks-passed", count: 1, required: false },
341 ]);
342
343 // Approved, the run's jobs carry their own names under the run's URL.
344 const job = { ...check("lint", "success"), url: `${run(1)}/job/7` };
345 const pending = { ...check("test", "pending"), url: `${run(1)}/job/8` };
346 const required = { ...bot, required: true };
347 const running = evaluatePullRequestWatch(
348 missing.next,
349 detail({ checks: [required, job, pending] }),
350 noRemarks,
351 );
352 assert.deepEqual(running.changes, []);
353 assert.isFalse(running.next.passed);
354 assert.deepEqual(running.next.awaitingApproval, [run(1)]);
355 const overlap = evaluatePullRequestWatch(
356 running.next,
357 detail({ checks: [required, job, { ...pending, url: `${run(2)}/job/9` }] }),
358 noRemarks,
359 );
360 assert.deepEqual(overlap.changes, []);
361 assert.isFalse(overlap.next.passed);
362 assert.deepEqual(overlap.next.awaitingApproval, [run(1)]);
363 const unlinked = evaluatePullRequestWatch(
364 overlap.next,
365 detail({ checks: [required, job, { ...pending, url: null }] }),
366 noRemarks,
367 );
368 assert.deepEqual(unlinked.changes, []);
369 assert.isFalse(unlinked.next.passed);
370 assert.deepEqual(unlinked.next.awaitingApproval, [run(1)]);
371 const advisory = evaluatePullRequestWatch(
372 unlinked.next,
373 detail({ checks: [required, job, { ...pending, url: "https://ci.example/test" }] }),
374 noRemarks,
375 );
376 assert.deepEqual(advisory.changes, [{ kind: "checks-passed", count: 1, required: true }]);
377 assert.deepEqual(advisory.next.awaitingApproval, []);
378 const completed = detail({ checks: [required, job, { ...pending, status: "success" }] });
379 const finished = evaluatePullRequestWatch(unlinked.next, completed, noRemarks);
380 assert.deepEqual(finished.changes, [{ kind: "checks-passed", count: 1, required: true }]);
381 assert.deepEqual(finished.next.awaitingApproval, []);
382 assert.deepEqual(evaluatePullRequestWatch(finished.next, completed, noRemarks).changes, []);
383
384 const done = evaluatePullRequestWatch(missing.next, detail({ checks: [bot, job] }), noRemarks);
385 assert.deepEqual(done.changes, [{ kind: "checks-passed", count: 2, required: false }]);
386 });
387
388 it("does not pass or fail while the host cannot tell whether workflows await approval", () => {
389 const bot = check("bot", "success");
390 const unknown = {
391 ...check("Workflow approval status", "action-required"),
392 url: null,
393 placeholder: true,
394 };
395 const unread = evaluatePullRequestWatch(watch(), detail({ checks: [bot, unknown] }), noRemarks);
396 assert.deepEqual(unread.changes, []);
397 assert.isFalse(unread.next.passed);
398 const read = evaluatePullRequestWatch(unread.next, detail({ checks: [bot] }), noRemarks);
399 assert.deepEqual(read.changes, [{ kind: "checks-passed", count: 1, required: false }]);
400 const blocked = evaluatePullRequestWatch(
401 read.next,
402 detail({ checks: [{ ...bot, status: "pending" }, unknown] }),
403 noRemarks,
404 );
405 assert.isFalse(blocked.next.passed);
406 assert.deepEqual(
407 evaluatePullRequestWatch(blocked.next, detail({ checks: [bot] }), noRemarks).changes,
408 [{ kind: "checks-passed", count: 1, required: false }],
409 );
410 });
411
412 it("reports each failure at once, even while another check never finishes", () => {
413 const bot = check("CodeRabbit", "pending");
414 const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] });
415 const lint = evaluatePullRequestWatch(watch(), first, noRemarks);
416 assert.deepEqual(lint.changes, [{ kind: "checks-failed", failed: [check("lint", "failure")] }]);
417 assert.deepEqual(evaluatePullRequestWatch(lint.next, first, noRemarks).changes, []);
418
419 const second = detail({ checks: [check("lint", "failure"), check("test", "failure"), bot] });
420 const test = evaluatePullRequestWatch(lint.next, second, noRemarks);
421 assert.deepEqual(test.changes, [{ kind: "checks-failed", failed: [check("test", "failure")] }]);
422
423 const rerun = evaluatePullRequestWatch(test.next, first, noRemarks);
424 assert.equal(evaluatePullRequestWatch(rerun.next, second, noRemarks).changes.length, 1);
425
426 const pushed = detail({ ...second, headSha: "bbbbbbbbbb" });
427 assert.equal(evaluatePullRequestWatch(test.next, pushed, noRemarks).changes.length, 1);
428
429 const approval = { ...check("CI", "action-required"), url: run(1) };
430 const gated = detail({ checks: [approval, check("lint", "failure")] });
431 assert.deepEqual(evaluatePullRequestWatch(watch(), gated, noRemarks).changes, [
432 { kind: "checks-failed", failed: [check("lint", "failure")] },
433 { kind: "checks-awaiting-approval", checks: [approval] },
434 ]);
435 });
436
437 it("reports passed once the required checks pass, whatever the others do", () => {
438 const required = (name: string, status: PullRequestCheck["status"]) => ({
439 ...check(name, status),
440 required: true,
441 });
442 const green = detail({
443 checks: [required("test", "success"), required("lint", "success"), check("bot", "pending")],
444 });
445 const passed = evaluatePullRequestWatch(watch(), green, noRemarks);
446 assert.deepEqual(passed.changes, [{ kind: "checks-passed", count: 2, required: true }]);
447 assert.deepEqual(evaluatePullRequestWatch(passed.next, green, noRemarks).changes, []);
448
449 const plain = detail({ checks: [check("test", "success"), check("bot", "pending")] });
450 assert.deepEqual(evaluatePullRequestWatch(watch(), plain, noRemarks).changes, []);
451 });
452
453 it("reports passed again when a required check shows up already passed", () => {
454 const required = (name: string, status: PullRequestCheck["status"]) => ({
455 ...check(name, status),
456 required: true,
457 });
458 const tests = detail({
459 checks: [required("Tests", "success"), check("Smoke Tests", "pending")],
460 });
461 const first = evaluatePullRequestWatch(watch(), tests, noRemarks);
462 assert.deepEqual(first.changes, [{ kind: "checks-passed", count: 1, required: true }]);
463
464 const gated = detail({
465 checks: [
466 required("Tests", "success"),
467 check("Smoke Tests", "success"),
468 required("Smoke Tests Gate", "success"),
469 ],
470 });
471 const second = evaluatePullRequestWatch(first.next, gated, noRemarks);
472 assert.deepEqual(second.changes, [{ kind: "checks-passed", count: 2, required: true }]);
473 assert.deepEqual(evaluatePullRequestWatch(second.next, gated, noRemarks).changes, []);
474
475 const pending = detail({
476 checks: [required("Tests", "success"), required("Smoke Tests Gate", "pending")],
477 });
478 const waiting = evaluatePullRequestWatch(first.next, pending, noRemarks);
479 assert.deepEqual(waiting.changes, []);
480 assert.deepEqual(evaluatePullRequestWatch(waiting.next, gated, noRemarks).changes, [
481 { kind: "checks-passed", count: 2, required: true },
482 ]);
483 });
484
485 // regression: #23712 required checks woke the agent early and the rest finishing never did
486 it("wakes again when the other checks finish after the required ones passed", () => {
487 const required = (name: string, status: PullRequestCheck["status"]) => ({
488 ...check(name, status),
489 required: true,
490 });
491 const early = detail({
492 checks: [required("test", "success"), check("e2e", "pending")],
493 });
494 const first = evaluatePullRequestWatch(watch(), early, noRemarks);
495 assert.deepEqual(first.changes, [{ kind: "checks-passed", count: 1, required: true }]);
496
497 const done = detail({
498 checks: [required("test", "success"), check("e2e", "success")],
499 });
500 const final = evaluatePullRequestWatch(first.next, done, noRemarks);
501 assert.deepEqual(
502 final.changes.map((change) => change.kind),
503 ["checks-passed"],
504 );
505 assert.deepEqual(evaluatePullRequestWatch(final.next, done, noRemarks).changes, []);
506 // A later advisory job running and passing is not more news.
507 const botPending = detail({ checks: [...done.checks, check("bot", "pending")] });
508 const churn = evaluatePullRequestWatch(final.next, botPending, noRemarks);
509 assert.deepEqual(churn.changes, []);
510 const botDone = detail({ checks: [...done.checks, check("bot", "success")] });
511 assert.deepEqual(evaluatePullRequestWatch(churn.next, botDone, noRemarks).changes, []);
512
513 // A pending check that turns required on the next read still finishes with one wake.
514 const promoted = detail({
515 checks: [required("test", "success"), required("e2e", "success")],
516 });
517 const gated = evaluatePullRequestWatch(first.next, promoted, noRemarks);
518 assert.deepEqual(gated.changes, [{ kind: "checks-passed", count: 2, required: true }]);
519 assert.deepEqual(evaluatePullRequestWatch(gated.next, promoted, noRemarks).changes, []);
520
521 // Nothing else was pending when the required ones passed, so nothing more is owed.
522 const green = evaluatePullRequestWatch(watch(), done, noRemarks);
523 assert.deepEqual(green.changes, [{ kind: "checks-passed", count: 1, required: true }]);
524 assert.deepEqual(evaluatePullRequestWatch(green.next, botDone, noRemarks).changes, []);
525 });
526
527 it("reports an owed non-required failure once after the required notice", () => {
528 const required = { ...check("test", "success"), required: true };
529 const early = detail({ checks: [required, check("e2e", "pending")] });
530 const first = evaluatePullRequestWatch(watch(), early, noRemarks);
531 assert.deepEqual(first.changes, [{ kind: "checks-passed", count: 1, required: true }]);
532 const failed = detail({ checks: [required, check("e2e", "failure")] });
533 const failure = evaluatePullRequestWatch(first.next, failed, noRemarks);
534 assert.deepEqual(failure.changes, [
535 { kind: "checks-failed", failed: [check("e2e", "failure")] },
536 ]);
537 const repeated = evaluatePullRequestWatch(failure.next, failed, noRemarks);
538 assert.deepEqual(repeated.changes, []);
539 const recovered = detail({ checks: [required, check("e2e", "success")] });
540 const final = evaluatePullRequestWatch(repeated.next, recovered, noRemarks);
541 assert.deepEqual(final.changes, [{ kind: "checks-passed", count: 2, required: false }]);
542 assert.deepEqual(evaluatePullRequestWatch(final.next, recovered, noRemarks).changes, []);
543 });
544
545 it("clears old-head owed checks when a new head is pushed", () => {
546 const required = { ...check("test", "success"), required: true };
547 const early = detail({ checks: [required, check("e2e", "pending")] });
548 const first = evaluatePullRequestWatch(watch(), early, noRemarks);
549 assert.deepEqual(first.next.owedChecks, ["e2e"]);
550 const unreadHead = detail({ headSha: "bbbbbbbbbb", checks: [] });
551 const unread = evaluatePullRequestWatch(first.next, unreadHead, noRemarks);
552 assert.deepEqual(unread.changes, []);
553 assert.deepEqual(unread.next.owedChecks, []);
554 const pushed = detail({ ...unreadHead, checks: [required] });
555 const newHead = evaluatePullRequestWatch(unread.next, pushed, noRemarks);
556 assert.deepEqual(newHead.changes, [{ kind: "checks-passed", count: 1, required: true }]);
557 assert.deepEqual(newHead.next.owedChecks, []);
558 const lateResult = detail({ ...pushed, checks: [required, check("e2e", "success")] });
559 const final = evaluatePullRequestWatch(newHead.next, lateResult, noRemarks);
560 assert.deepEqual(final.changes, []);
561 assert.deepEqual(evaluatePullRequestWatch(final.next, lateResult, noRemarks).changes, []);
562 });
563
564 // regression: #16208 PR watches burned GitHub's rate limit and gave up
565 it("does not report passed again for a new passed check where none is required", () => {
566 const first = evaluatePullRequestWatch(
567 watch(),
568 detail({ checks: [check("test", "success")] }),
569 noRemarks,
570 );
571 assert.deepEqual(first.changes, [{ kind: "checks-passed", count: 1, required: false }]);
572 const both = detail({ checks: [check("test", "success"), check("lint", "success")] });
573 assert.deepEqual(evaluatePullRequestWatch(first.next, both, noRemarks).changes, []);
574 });
575
576 it("does not wake a watch saved before passed checks were recorded", () => {
577 const green = detail({ checks: [{ ...check("test", "success"), required: true }] });
578 const told = watch({ headSha: "aaaaaaaaaa", passed: true });
579 const saved = evaluatePullRequestWatch(told, green, noRemarks);
580 assert.deepEqual(saved.changes, []);
581 assert.deepEqual(saved.next.passedChecks, ["test"]);
582 });
583
584 it("keeps remarks for a later pass when the conversation was not read whole", () => {
585 const comments = [remark("reviewer", "2026-10-02T12:06:00Z")];
586 const partial = evaluatePullRequestWatch(watch(), detail(), null);
587 assert.deepEqual(partial.changes, []);
588 assert.equal(
589 evaluatePullRequestWatch(partial.next, detail(), comments).changes[0]?.kind,
590 "remarks",
591 );
592 });
593
594 it("reports a remark that shows up late with the same time as a reported one", () => {
595 const first = remark("reviewer", "2026-10-02T12:06:00Z");
596 const late = { ...remark("bot", "2026-10-02T12:06:00Z"), id: "late" };
597 const reported = evaluatePullRequestWatch(watch(), detail(), [first]);
598 const again = evaluatePullRequestWatch(reported.next, detail(), [first, late]);
599 assert.deepEqual(again.changes, [{ kind: "remarks", remarks: [late] }]);
600 assert.deepEqual(again.next.remarkIds, [first.id, "late"]);
601 });
602
603 it("reports edits after the watermark once and counts them toward the wake limit", () => {
604 const old = remark("greptile[bot]", "2026-10-02T11:00:00Z");
605 const watching = watch({
606 headSha: "aaaaaaaaaa",
607 remarkIds: [old.id],
608 wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1,
609 });
610 assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [old]).changes, []);
611 const edited = { ...old, editedAt: "2026-10-02T12:06:00Z" };
612 const report = evaluatePullRequestWatch(watching, detail(), [edited]);
613 assert.deepEqual(report.changes, [{ kind: "remarks", remarks: [edited] }]);
614 assert.equal(report.next.remarksThrough, edited.editedAt);
615 assert.deepEqual(report.next.remarkIds, [old.id]);
616 assert.isTrue(report.exhausted);
617 assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), [edited]).changes, []);
618 const late = { ...edited, id: "late" };
619 assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), [edited, late]).changes, [
620 { kind: "remarks", remarks: [late] },
621 ]);
622 });
623
624 it("wakes once per edit of a remark seen at the watermark, never for the agent's own", () => {
625 const seen = remark("ci-bot", "2026-10-10T19:52:40.000Z");
626 const watching = watch({
627 headSha: "aaaaaaaaaa",
628 remarksThrough: seen.createdAt,
629 remarkIds: [seen.id],
630 wakes: 2,
631 });
632 assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [seen]).changes, []);
633 const edited = { ...seen, editedAt: "2026-10-10T20:04:50.000Z" };
634 const first = evaluatePullRequestWatch(watching, detail(), [edited]);
635 assert.deepEqual(first.changes, [{ kind: "remarks", remarks: [edited] }]);
636 assert.strictEqual(first.next.remarksThrough, edited.editedAt);
637 assert.deepEqual(evaluatePullRequestWatch(first.next, detail(), [edited]).changes, []);
638 const again = { ...seen, editedAt: "2026-10-10T20:05:50.000Z" };
639 const second = evaluatePullRequestWatch(first.next, detail(), [again]);
640 assert.deepEqual(second.changes, [{ kind: "remarks", remarks: [again] }]);
641 assert.deepEqual(evaluatePullRequestWatch(second.next, detail(), [again]).changes, []);
642 const own = { ...edited, author: { login: "agent-user", name: null, avatarUrl: null } };
643 assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [own]).changes, []);
644 });
645
646 it("does not treat a failed check read as a rerun", () => {
647 const failed = detail({ checks: [check("lint", "failure")] });
648 const reported = evaluatePullRequestWatch(watch(), failed, noRemarks);
649 assert.equal(reported.changes.length, 1);
650 const unreadable = evaluatePullRequestWatch(reported.next, detail({ checks: [] }), noRemarks);
651 assert.deepEqual(evaluatePullRequestWatch(unreadable.next, failed, noRemarks).changes, []);
652 });
653
654 it("wakes for the pull request's author when the agent is someone else", () => {
655 const contributor = detail({ author: { login: "contributor", name: null, avatarUrl: null } });
656 const reply = remark("contributor", "2026-10-02T12:06:00Z");
657 assert.deepEqual(evaluatePullRequestWatch(watch(), contributor, [reply]).changes, [
658 { kind: "remarks", remarks: [reply] },
659 ]);
660 const noViewer = detail({ viewer: undefined, author: contributor.author });
661 assert.deepEqual(evaluatePullRequestWatch(watch(), noViewer, [reply]).changes, []);
662 });
663
664 it("reports remarks from others once and never the agent's own", () => {
665 const comments = [
666 remark("agent-user", "2026-10-02T12:05:00Z", "Fixed in the latest push."),
667 remark("macroscope-app[bot]", "2026-10-02T12:06:00Z"),
668 remark("reviewer", "2026-10-02T11:00:00Z", "Older than the watch."),
669 ];
670 const report = evaluatePullRequestWatch(watch(), detail(), comments);
671 assert.deepEqual(report.changes, [{ kind: "remarks", remarks: [comments[1]!] }]);
672 assert.equal(report.next.remarksThrough, "2026-10-02T12:06:00Z");
673 assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), comments).changes, []);
674 });
675
676 it("cuts a long remark in the wake message without splitting an emoji", () => {
677 const body = `${"x".repeat(196)}🚀 and more`;
678 const report = evaluatePullRequestWatch(watch(), detail(), [
679 remark("reviewer", "2026-10-02T12:06:00Z", body),
680 ]);
681 const { text } = pullRequestWatchMessage({
682 number: 1,
683 url: "https://github.com/o/r/pull/1",
684 baseBranch: "main",
685 headSha: null,
686 report,
687 });
688 assert.isTrue(text.isWellFormed());
689 assert.include(text, `"${"x".repeat(196)}..."`);
690 });
691
692 it("reports a conflict once, until the branch is clean again", () => {
693 const conflicting = detail({ mergeability: "conflicting" });
694 const first = evaluatePullRequestWatch(watch(), conflicting, noRemarks);
695 assert.deepEqual(first.changes, [{ kind: "conflicting" }]);
696 const { headSha: _unreadHead, ...headUnread } = conflicting;
697 const unread = evaluatePullRequestWatch(first.next, headUnread, noRemarks);
698 assert.deepEqual(unread.changes, []);
699 assert.deepEqual(evaluatePullRequestWatch(unread.next, conflicting, noRemarks).changes, []);
700 const recomputing = evaluatePullRequestWatch(
701 first.next,
702 detail({ mergeability: "unknown" }),
703 noRemarks,
704 );
705 assert.deepEqual(
706 evaluatePullRequestWatch(recomputing.next, conflicting, noRemarks).changes,
707 [],
708 );
709 const clean = evaluatePullRequestWatch(first.next, detail(), noRemarks);
710 assert.deepEqual(evaluatePullRequestWatch(clean.next, conflicting, noRemarks).changes, [
711 { kind: "conflicting" },
712 ]);
713 const pushed = { headSha: "bbbbbbbbbb" };
714 const recomputed = evaluatePullRequestWatch(
715 first.next,
716 detail({ ...pushed, mergeability: "unknown" }),
717 noRemarks,
718 );
719 assert.deepEqual(
720 evaluatePullRequestWatch(
721 recomputed.next,
722 detail({ ...pushed, mergeability: "conflicting" }),
723 noRemarks,
724 ).changes,
725 [{ kind: "conflicting" }],
726 );
727 });
728
729 it("reports each merge queue removal once while the head checks stay green", () => {
730 const green = detail({ checks: [check("test", "success")] });
731 const watching = evaluatePullRequestWatch(watch(), green, noRemarks).next;
732 const removed = (id: string, removedAt: string) =>
733 detail({ ...green, mergeQueueRemoval: { id, removedAt, reason: "Merge conflict" } });
734
735 const dropped = removed("first", "2026-10-02T12:10:00Z");
736 const first = evaluatePullRequestWatch(watching, dropped, noRemarks);
737 assert.deepEqual(first.changes, [{ kind: "merge-queue-removed", reason: "Merge conflict" }]);
738 const wake = pullRequestWatchMessage({
739 number: 1,
740 url: "https://github.com/o/r/pull/1",
741 baseBranch: "main",
742 headSha: first.next.headSha,
743 report: first,
744 });
745 assert.deepEqual(wake.notification, {
746 source: { kind: "monitor" },
747 outcome: "failed",
748 summary: "#1: removed from merge queue",
749 });
750 assert.deepEqual(evaluatePullRequestWatch(first.next, dropped, noRemarks).changes, []);
751 // A read without queue news keeps the removal already told.
752 const quiet = evaluatePullRequestWatch(first.next, green, noRemarks);
753 assert.deepEqual(evaluatePullRequestWatch(quiet.next, dropped, noRemarks).changes, []);
754
755 const again = removed("second", "2026-10-02T12:20:00Z");
756 assert.equal(evaluatePullRequestWatch(first.next, again, noRemarks).changes.length, 1);
757 const before = removed("before", "2026-10-02T11:00:00Z");
758 const baseline = evaluatePullRequestWatch(
759 watch({ headSha: watching.headSha, passed: true, passedChecks: watching.passedChecks }),
760 before,
761 noRemarks,
762 );
763 assert.deepEqual(baseline.changes, []);
764 for (const prior of [watching, baseline.next]) {
765 const skewed = evaluatePullRequestWatch(
766 prior,
767 removed("new", "2026-10-02T11:59:58Z"),
768 noRemarks,
769 );
770 assert.deepEqual(skewed.changes, [{ kind: "merge-queue-removed", reason: "Merge conflict" }]);
771 assert.deepEqual(
772 evaluatePullRequestWatch(skewed.next, removed("new", "2026-10-02T11:59:58Z"), noRemarks)
773 .changes,
774 [],
775 );
776 }
777 });
778
779 it("reports a merge queue removal stamped in the second the watch started", () => {
780 const watching = watch({ startedAt: "2026-10-02T12:00:00.500Z" });
781 const removed = (removedAt: string) =>
782 detail({ mergeQueueRemoval: { id: "first", removedAt, reason: "CI failed" } });
783 assert.deepEqual(
784 evaluatePullRequestWatch(watching, removed("2026-10-02T12:00:00Z"), noRemarks).changes,
785 [{ kind: "merge-queue-removed", reason: "CI failed" }],
786 );
787 assert.deepEqual(
788 evaluatePullRequestWatch(watching, removed("2026-10-02T11:59:59Z"), noRemarks).changes,
789 [],
790 );
791 });
792
793 it("stays quiet when the queue lets go of a pull request by merging it", () => {
794 const watching = evaluatePullRequestWatch(watch(), detail(), noRemarks).next;
795 const merging = detail({
796 mergeQueueRemoval: { id: "landed", removedAt: "2026-10-02T12:10:00Z", reason: "merged" },
797 });
798 const read = evaluatePullRequestWatch(watching, merging, noRemarks);
799 assert.deepEqual(read.changes, []);
800 assert.equal(read.next.mergeQueueRemovalId, "landed");
801 });
802
803 it("does not spend the comment wake limit on check results", () => {
804 const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 });
805 const result = evaluatePullRequestWatch(
806 tired,
807 detail({ checks: [check("lint", "failure")] }),
808 noRemarks,
809 );
810 assert.isFalse(result.exhausted);
811 assert.equal(result.next.wakes, 0);
812 });
813
814 it("stops after the wake limit unless the head moves", () => {
815 const comments = [remark("reviewer", "2026-10-02T12:10:00Z")];
816 const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 });
817 assert.isTrue(evaluatePullRequestWatch(tired, detail(), comments).exhausted);
818 const pushed = evaluatePullRequestWatch(tired, detail({ headSha: "cccccccccc" }), comments);
819 assert.isFalse(pushed.exhausted);
820 assert.equal(pushed.next.wakes, 1);
821 });
822
823 const anHourLater = (
824 first: ThreadPullRequestWatch,
825 read: Detail,
826 ): ReadonlyArray<ReadonlyArray<string>> => {
827 const kinds: Array<ReadonlyArray<string>> = [];
828 let current = first;
829 for (let minute = 0; minute <= 60; minute += 10) {
830 const { changes, next } = evaluate(current, read, noRemarks, minute * 60_000);
831 kinds.push(changes.map((change) => change.kind));
832 current = next;
833 }
834 return kinds;
835 };
836
837 it("does not tell a watch saved before the no-checks clock that its seen head has none", () => {
838 // Such a watch may have seen this head's checks pending, which records nothing.
839 const legacy = watch({ headSha: "aaaaaaaaaa" });
840 assert.isTrue(anHourLater(legacy, detail({ checks: [] })).every((kinds) => kinds.length === 0));
841 const pushed = anHourLater(legacy, detail({ headSha: "bbbbbbbbbb", checks: [] }));
842 assert.deepEqual(pushed.flat(), ["no-checks"]);
843 const unknown = detail({
844 checks: [{ ...check("Workflow approval status", "action-required"), placeholder: true }],
845 });
846 const fresh = watch({ awaitingBaseline: "all" });
847 assert.deepEqual(anHourLater(fresh, unknown).flat(), []);
848 const unread = evaluate(fresh, unknown, noRemarks, 0);
849 assert.deepEqual(
850 evaluate(unread.next, detail({ checks: [] }), noRemarks, 60 * 60_000).changes,
851 [{ kind: "no-checks", minutes: 60 }],
852 );
853 });
854
855 it("starts the no-checks clock while a fresh watch awaits its baseline", () => {
856 assert.deepEqual(
857 anHourLater(watch({ awaitingBaseline: "all" }), detail({ checks: [] })).flat(),
858 ["no-checks"],
859 );
860 });
861
862 it("never reports missing checks on a host where an empty list says nothing", () => {
863 const silent = detail({ capabilities: capabilities(false), checks: [] });
864 assert.isTrue(anHourLater(watch(), silent).every((kinds) => kinds.length === 0));
865 });
866
867 it("counts checks as pending until every one, a review bot's included, has finished", () => {
868 const pending = (checks: ReadonlyArray<PullRequestCheck>, from = watch()) =>
869 evaluatePullRequestWatch(from, detail({ checks }), noRemarks).next.checksPending;
870 const ci = { ...check("test", "success"), required: true };
871 assert.isTrue(pending([ci, check("CodeRabbit", "pending")]));
872 assert.isFalse(pending([ci, check("CodeRabbit", "success")]));
873 assert.isFalse(pending([ci, check("CodeRabbit", "failure")]));
874 assert.isFalse(pending([ci, check("deploy", "action-required")]));
875 // A failed read keeps the last answer, as does one with only an unreadable approval check.
876 assert.isFalse(pending([], watch({ headSha: "aaaaaaaaaa", checksPending: false })));
877 const unreadable = { ...check("approvals", "action-required"), placeholder: true };
878 assert.isTrue(pending([unreadable], watch({ headSha: "aaaaaaaaaa", checksPending: true })));
879 });
880
881 it("waits a while for a new head's checks on every host", () => {
882 const pushed = (checksAlwaysReport: boolean) =>
883 evaluatePullRequestWatch(
884 watch({ headSha: "aaaaaaaaaa", checksPending: false }),
885 detail({
886 headSha: "bbbbbbbbbb",
887 capabilities: capabilities(checksAlwaysReport),
888 checks: [],
889 }),
890 noRemarks,
891 ).next.checksPending;
892 assert.isTrue(pushed(true));
893 assert.isTrue(pushed(false));
894
895 // Once the no-checks window passes, the watch stops waiting for them.
896 for (const checksAlwaysReport of [true, false]) {
897 let current = watch({ headSha: "aaaaaaaaaa" });
898 for (const minute of [0, 20]) {
899 current = evaluate(
900 current,
901 detail({
902 headSha: "bbbbbbbbbb",
903 capabilities: capabilities(checksAlwaysReport),
904 checks: [],
905 }),
906 noRemarks,
907 minute * 60_000,
908 ).next;
909 }
910 assert.isFalse(current.checksPending);
911 }
912
913 // A watch with no answer waits on an empty list only while the no-checks clock runs.
914 const unanswered = (checksAwaitedSince: string | null) =>
915 evaluatePullRequestWatch(
916 watch({ headSha: "bbbbbbbbbb", checksAwaitedSince }),
917 detail({ headSha: "bbbbbbbbbb", checks: [] }),
918 noRemarks,
919 ).next.checksPending;
920 assert.isFalse(unanswered(null));
921 assert.isTrue(unanswered(STARTED));
922 });
923});