1import type {
2 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,
11 pullRequestWatchMessage,
12} from "./pullRequestWatch.ts";
13
14const STARTED = "2026-10-02T12:00:00.000Z";
15
16const watch = (overrides: Partial<ThreadPullRequestWatch> = {}): ThreadPullRequestWatch => ({
17 startedAt: STARTED,
18 headSha: null,
19 failedChecks: [],
20 passed: false,
21 passedChecks: [],
22 remarksThrough: STARTED,
23 remarkIds: [],
24 conflicting: false,
25 wakes: 0,
26 ...overrides,
27});
28
29const check = (name: string, status: PullRequestCheck["status"]): PullRequestCheck => ({
30 name,
31 status,
32 description: null,
33 url: `https://ci.example/${name}`,
34});
35
36type Detail = Parameters<typeof evaluatePullRequestWatch>[1];
37
38const detail = (overrides: Partial<Detail> = {}): Detail => ({
39 headSha: "aaaaaaaaaa",
40 checks: [check("lint", "success"), check("test", "pending")],
41 mergeability: "mergeable",
42 viewer: "agent-user",
43 author: { login: "agent-user", name: null, avatarUrl: null },
44 ...overrides,
45});
46
47const remark = (
48 login: string,
49 createdAt: string,
50 body = "Please rename this.",
51): PullRequestComment => ({
52 id: `${login}-${createdAt}`,
53 kind: "review-comment",
54 author: { login, name: null, avatarUrl: null },
55 body,
56 createdAt,
57 url: `https://github.com/o/r/pull/1#${login}`,
58 path: "src/index.ts",
59 reviewState: null,
60});
61
62const noRemarks: ReadonlyArray<PullRequestComment> = [];
63
64describe("evaluatePullRequestWatch", () => {
65 it("reports each failure at once, even while another check never finishes", () => {
66 const bot = check("CodeRabbit", "pending");
67 const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] });
68 const lint = evaluatePullRequestWatch(watch(), first, noRemarks);
69 assert.deepEqual(lint.changes, [{ kind: "checks-failed", failed: [check("lint", "failure")] }]);
70 assert.deepEqual(evaluatePullRequestWatch(lint.next, first, noRemarks).changes, []);
71
72 // A different job failing later is news of its own.
73 const second = detail({ checks: [check("lint", "failure"), check("test", "failure"), bot] });
74 const test = evaluatePullRequestWatch(lint.next, second, noRemarks);
75 assert.deepEqual(test.changes, [{ kind: "checks-failed", failed: [check("test", "failure")] }]);
76
77 // A rerun leaves the list while it runs, so failing again is reported again.
78 const rerun = evaluatePullRequestWatch(test.next, first, noRemarks);
79 assert.equal(evaluatePullRequestWatch(rerun.next, second, noRemarks).changes.length, 1);
80
81 // A push reports its failures, even ones that failed between two passes.
82 const pushed = detail({ ...second, headSha: "bbbbbbbbbb" });
83 assert.equal(evaluatePullRequestWatch(test.next, pushed, noRemarks).changes.length, 1);
84 });
85
86 it("reports passed once the required checks pass, whatever the others do", () => {
87 const required = (name: string, status: PullRequestCheck["status"]) => ({
88 ...check(name, status),
89 required: true,
90 });
91 const green = detail({
92 checks: [required("test", "success"), required("lint", "success"), check("bot", "pending")],
93 });
94 const passed = evaluatePullRequestWatch(watch(), green, noRemarks);
95 assert.deepEqual(passed.changes, [{ kind: "checks-passed", count: 2, required: true }]);
96 assert.deepEqual(evaluatePullRequestWatch(passed.next, green, noRemarks).changes, []);
97
98 // Where nothing is marked required, every check has to pass.
99 const plain = detail({ checks: [check("test", "success"), check("bot", "pending")] });
100 assert.deepEqual(evaluatePullRequestWatch(watch(), plain, noRemarks).changes, []);
101 });
102
103 it("reports passed again when a required check shows up already passed", () => {
104 const required = (name: string, status: PullRequestCheck["status"]) => ({
105 ...check(name, status),
106 required: true,
107 });
108 const tests = detail({
109 checks: [required("Tests", "success"), check("Smoke Tests", "pending")],
110 });
111 const first = evaluatePullRequestWatch(watch(), tests, noRemarks);
112 assert.deepEqual(first.changes, [{ kind: "checks-passed", count: 1, required: true }]);
113
114 // The gate job was created and finished between two passes, so it was never seen pending.
115 const gated = detail({
116 checks: [
117 required("Tests", "success"),
118 check("Smoke Tests", "success"),
119 required("Smoke Tests Gate", "success"),
120 ],
121 });
122 const second = evaluatePullRequestWatch(first.next, gated, noRemarks);
123 assert.deepEqual(second.changes, [{ kind: "checks-passed", count: 2, required: true }]);
124 assert.deepEqual(evaluatePullRequestWatch(second.next, gated, noRemarks).changes, []);
125
126 // Seen pending first, the gate is reported once it passes, as before.
127 const pending = detail({
128 checks: [required("Tests", "success"), required("Smoke Tests Gate", "pending")],
129 });
130 const waiting = evaluatePullRequestWatch(first.next, pending, noRemarks);
131 assert.deepEqual(waiting.changes, []);
132 assert.deepEqual(evaluatePullRequestWatch(waiting.next, gated, noRemarks).changes, [
133 { kind: "checks-passed", count: 2, required: true },
134 ]);
135 });
136
137 it("does not report passed again for a new passed check where none is required", () => {
138 const first = evaluatePullRequestWatch(
139 watch(),
140 detail({ checks: [check("test", "success")] }),
141 noRemarks,
142 );
143 assert.deepEqual(first.changes, [{ kind: "checks-passed", count: 1, required: false }]);
144 const both = detail({ checks: [check("test", "success"), check("lint", "success")] });
145 assert.deepEqual(evaluatePullRequestWatch(first.next, both, noRemarks).changes, []);
146 });
147
148 it("does not wake a watch saved before passed checks were recorded", () => {
149 const green = detail({ checks: [{ ...check("test", "success"), required: true }] });
150 const told = watch({ headSha: "aaaaaaaaaa", passed: true });
151 const saved = evaluatePullRequestWatch(told, green, noRemarks);
152 assert.deepEqual(saved.changes, []);
153 assert.deepEqual(saved.next.passedChecks, ["test"]);
154 });
155
156 it("keeps remarks for a later pass when the conversation was not read whole", () => {
157 const comments = [remark("reviewer", "2026-10-02T12:06:00Z")];
158 const partial = evaluatePullRequestWatch(watch(), detail(), null);
159 assert.deepEqual(partial.changes, []);
160 assert.equal(
161 evaluatePullRequestWatch(partial.next, detail(), comments).changes[0]?.kind,
162 "remarks",
163 );
164 });
165
166 it("reports a remark that shows up late with the same time as a reported one", () => {
167 const first = remark("reviewer", "2026-10-02T12:06:00Z");
168 const late = { ...remark("bot", "2026-10-02T12:06:00Z"), id: "late" };
169 const reported = evaluatePullRequestWatch(watch(), detail(), [first]);
170 const again = evaluatePullRequestWatch(reported.next, detail(), [first, late]);
171 assert.deepEqual(again.changes, [{ kind: "remarks", remarks: [late] }]);
172 assert.deepEqual(again.next.remarkIds, [first.id, "late"]);
173 });
174
175 it("reports edits after the watermark once and counts them toward the wake limit", () => {
176 const old = remark("greptile[bot]", "2026-10-02T11:00:00Z");
177 const watching = watch({
178 headSha: "aaaaaaaaaa",
179 remarkIds: [old.id],
180 wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1,
181 });
182 assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [old]).changes, []);
183 const edited = { ...old, editedAt: "2026-10-02T12:06:00Z" };
184 const report = evaluatePullRequestWatch(watching, detail(), [edited]);
185 assert.deepEqual(report.changes, [{ kind: "remarks", remarks: [edited] }]);
186 assert.equal(report.next.remarksThrough, edited.editedAt);
187 assert.deepEqual(report.next.remarkIds, [old.id]);
188 assert.isTrue(report.exhausted);
189 assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), [edited]).changes, []);
190 const late = { ...edited, id: "late" };
191 assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), [edited, late]).changes, [
192 { kind: "remarks", remarks: [late] },
193 ]);
194 });
195
196 it("does not treat a failed check read as a rerun", () => {
197 const failed = detail({ checks: [check("lint", "failure")] });
198 const reported = evaluatePullRequestWatch(watch(), failed, noRemarks);
199 assert.equal(reported.changes.length, 1);
200 const unreadable = evaluatePullRequestWatch(reported.next, detail({ checks: [] }), noRemarks);
201 assert.deepEqual(evaluatePullRequestWatch(unreadable.next, failed, noRemarks).changes, []);
202 });
203
204 it("wakes for the pull request's author when the agent is someone else", () => {
205 const contributor = detail({ author: { login: "contributor", name: null, avatarUrl: null } });
206 const reply = remark("contributor", "2026-10-02T12:06:00Z");
207 assert.deepEqual(evaluatePullRequestWatch(watch(), contributor, [reply]).changes, [
208 { kind: "remarks", remarks: [reply] },
209 ]);
210 // Without a viewer, the author is taken to be the agent.
211 const noViewer = detail({ viewer: undefined, author: contributor.author });
212 assert.deepEqual(evaluatePullRequestWatch(watch(), noViewer, [reply]).changes, []);
213 });
214
215 it("reports remarks from others once and never the agent's own", () => {
216 const comments = [
217 remark("agent-user", "2026-10-02T12:05:00Z", "Fixed in the latest push."),
218 remark("macroscope-app[bot]", "2026-10-02T12:06:00Z"),
219 remark("reviewer", "2026-10-02T11:00:00Z", "Older than the watch."),
220 ];
221 const report = evaluatePullRequestWatch(watch(), detail(), comments);
222 assert.deepEqual(report.changes, [{ kind: "remarks", remarks: [comments[1]!] }]);
223 assert.equal(report.next.remarksThrough, "2026-10-02T12:06:00Z");
224 assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), comments).changes, []);
225 });
226
227 it("reports a conflict once, until the branch is clean again", () => {
228 const conflicting = detail({ mergeability: "conflicting" });
229 const first = evaluatePullRequestWatch(watch(), conflicting, noRemarks);
230 assert.deepEqual(first.changes, [{ kind: "conflicting" }]);
231 // GitHub answers "unknown" while it recomputes after a push; that is not a resolution.
232 const recomputing = evaluatePullRequestWatch(
233 first.next,
234 detail({ mergeability: "unknown" }),
235 noRemarks,
236 );
237 assert.deepEqual(
238 evaluatePullRequestWatch(recomputing.next, conflicting, noRemarks).changes,
239 [],
240 );
241 const clean = evaluatePullRequestWatch(first.next, detail(), noRemarks);
242 assert.deepEqual(evaluatePullRequestWatch(clean.next, conflicting, noRemarks).changes, [
243 { kind: "conflicting" },
244 ]);
245 });
246
247 it("does not spend the comment wake limit on check results", () => {
248 const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 });
249 const result = evaluatePullRequestWatch(
250 tired,
251 detail({ checks: [check("lint", "failure")] }),
252 noRemarks,
253 );
254 assert.isFalse(result.exhausted);
255 assert.equal(result.next.wakes, 0);
256 });
257
258 it("stops after the wake limit unless the head moves", () => {
259 const comments = [remark("reviewer", "2026-10-02T12:10:00Z")];
260 const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 });
261 assert.isTrue(evaluatePullRequestWatch(tired, detail(), comments).exhausted);
262 const pushed = evaluatePullRequestWatch(tired, detail({ headSha: "cccccccccc" }), comments);
263 assert.isFalse(pushed.exhausted);
264 assert.equal(pushed.next.wakes, 1);
265 });
266});
267
268describe("pullRequestWatchMessage", () => {
269 it("tells the agent what changed and marks failures for the timeline", () => {
270 const report = evaluatePullRequestWatch(
271 watch(),
272 detail({ checks: [check("lint", "failure")] }),
273 [remark("reviewer", "2026-10-02T12:10:00Z", "<!-- bot -->Needs a test.")],
274 );
275 const message = pullRequestWatchMessage({
276 number: 12,
277 url: "https://github.com/o/r/pull/12",
278 baseBranch: "main",
279 headSha: report.next.headSha,
280 report,
281 });
282 assert.include(message.text, "- Checks failed on aaaaaaa:\n - lint https://ci.example/lint");
283 assert.include(message.text, ' - reviewer on src/index.ts: "Needs a test."');
284 assert.include(message.text, "unwatch_pull_request");
285 assert.deepEqual(message.notification, {
286 source: { kind: "monitor" },
287 outcome: "failed",
288 summary: "#12: checks failed, new comments",
289 });
290 });
291});