Skip to content
Source
Sign inSign up
Open History to see the last commit

pullRequestWatch.ts

407 lines17.5 KB

1import type {
2 OrchestrationV2Notification,
3 PullRequestCheck,
4 PullRequestComment,
5 PullRequestDetail,
6 ThreadPullRequestWatch,
7} from "@t3tools/contracts";
8import * as DateTime from "effect/DateTime";
9​
10/** Bounds consecutive comment-only wakes to stop bot reply loops; check, conflict, or push news resets it. */
11export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10;
12/** How long a head may go without any check before the agent is told none reported. */
13export const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000;
14const LISTED_ITEMS = 10;
15const SNIPPET_LENGTH = 200;
16​
17export type PullRequestWatchChange =
18 | { readonly kind: "checks-failed"; readonly failed: ReadonlyArray<PullRequestCheck> }
19 | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean }
20 | { readonly kind: "checks-awaiting-approval"; readonly checks: ReadonlyArray<PullRequestCheck> }
21 | { readonly kind: "remarks"; readonly remarks: ReadonlyArray<PullRequestComment> }
22 | { readonly kind: "no-checks"; readonly minutes: number }
23 | { readonly kind: "conflicting" }
24 | { readonly kind: "merge-queue-removed"; readonly reason: string | null };
25​
26export interface PullRequestWatchReport {
27 readonly changes: ReadonlyArray<PullRequestWatchChange>;
28 readonly next: ThreadPullRequestWatch;
29 readonly exhausted: boolean;
31​
32const isFailedCheck = (check: PullRequestCheck) =>
33 check.status === "failure" || check.status === "cancelled";
34​
35// A GitHub Actions job's URL extends its workflow run's URL; their names need not match.
36const workflowRunOf = (url: string | null) => url?.match(/^.*\/actions\/runs\/\d+/)?.[0] ?? null;
37const approvalKey = (check: PullRequestCheck) => workflowRunOf(check.url) ?? check.name;
38​
39export function evaluatePullRequestWatch(
40 watch: ThreadPullRequestWatch,
41 detail: Pick<
42 PullRequestDetail,
43 | "capabilities"
44 | "headSha"
45 | "checks"
46 | "mergeability"
47 | "viewer"
48 | "author"
49 | "mergeQueueRemoval"
50 >,
51 remarks: ReadonlyArray<PullRequestComment> | null,
52 now: number,
53): PullRequestWatchReport {
54 const changes: Array<PullRequestWatchChange> = [];
55 // A read without a head says nothing about pushes, so it keeps the last known one.
56 const headSha = detail.headSha ?? watch.headSha;
57 const headMoved = watch.headSha !== null && headSha !== watch.headSha;
58 const checksBaseline = watch.awaitingBaseline !== undefined && !headMoved;
59​
60 // Hosts can return no checks on read failure; preserve prior state.
61 let failedChecks = headMoved ? [] : watch.failedChecks;
62 let passed = headMoved ? false : watch.passed;
63 let passedChecks = headMoved ? [] : watch.passedChecks;
64 let owedChecks = headMoved ? [] : (watch.owedChecks ?? []);
65 let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []);
66 let approvalAnnounced = headMoved ? false : (watch.approvalAnnounced ?? false);
67 // A watch with no answer (saved before the field, or since a push) waits only while the clock
68 // below runs.
69 let checksPending = headMoved ? false : (watch.checksPending ?? watch.checksAwaitedSince != null);
70 // An unavailable approval read cannot establish success or that no checks exist.
71 const unread = detail.checks.some((check) => check.placeholder === true);
72 const checks = detail.checks.filter((check) => check.placeholder !== true);
73 const failed = checks.filter(isFailedCheck);
74 const awaiting = checks.filter((check) => check.status === "action-required");
75 if (checks.length > 0) {
76 // A run awaiting approval waits on a person, like a review, so only running checks count.
77 checksPending = checks.some((check) => check.status === "pending");
78 const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name));
79 if (!checksBaseline && newlyFailed.length > 0) {
80 changes.push({ kind: "checks-failed", failed: newlyFailed });
81 }
82 if (!approvalAnnounced && awaiting.length > 0) {
83 if (!checksBaseline) changes.push({ kind: "checks-awaiting-approval", checks: awaiting });
84 approvalAnnounced = true;
85 }
86 failedChecks = failed.map((check) => check.name);
87​
88 // Approval reads can omit gated runs. Keep each hold until its jobs finish.
89 const awaitingNow = [...new Set(awaiting.map(approvalKey))];
90 const nameUnfinished = (name: string) => {
91 const matching = checks.filter((check) => check.name === name);
92 return (
93 matching.length === 0 ||
94 matching.some((check) => check.status === "pending" || check.status === "action-required")
95 );
96 };
97 // GitHub deduplicates jobs across runs, so a visible pending job may hide one in an awaited run.
98 // A pending check without a URL could be such a job; one with another host's URL cannot.
99 const jobsPending = checks.some(
100 (check) =>
101 check.status === "pending" && (check.url === null || workflowRunOf(check.url) !== null),
102 );
103 const finished = new Set(
104 jobsPending
105 ? []
106 : checks
107 .filter((check) => check.status !== "action-required" && check.status !== "pending")
108 .map((check) => workflowRunOf(check.url)),
109 );
110 awaitingApproval = [
111 ...awaitingNow,
112 ...awaitingApproval.filter(
113 (key) =>
114 !awaitingNow.includes(key) &&
115 (workflowRunOf(key) === key ? !finished.has(key) : nameUnfinished(key)),
116 ),
117 ];
118 }
119 const required = checks.filter((check) => check.required === true);
120 const gate = required.length > 0 ? required : checks;
121 const passedNow =
122 awaitingApproval.length === 0 &&
123 gate.every((check) => check.status !== "pending" && !isFailedCheck(check));
124 if (checks.length > 0 && !(unread && passedNow)) {
125 const gateNames = gate.map((check) => check.name);
126 const told = passed && passedChecks.length === 0 ? gateNames : passedChecks;
127 // Required checks can finish between sweeps; detect new names. With no required checks, ignore advisory gate growth.
128 const gateGrew = required.length > 0 && gateNames.some((name) => !told.includes(name));
129 // Other checks still pending when required ones passed are owed one wake when all settle.
130 // Only those are owed, so later advisory churn never wakes. One turned required is gate news.
131 const owed =
132 passedNow && passed
133 ? checks
134 .filter((check) => check.required !== true && owedChecks.includes(check.name))
135 .map((check) => check.name)
136 : [];
137 const settled =
138 failed.length === 0 &&
139 awaitingApproval.length === 0 &&
140 checks.every((check) => check.status !== "pending");
141 owedChecks = owed;
142 if (passedNow && (!passed || gateGrew)) {
143 if (!checksBaseline) {
144 changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 });
145 }
146 owedChecks =
147 required.length > 0
148 ? checks
149 .filter((check) => check.required !== true && check.status === "pending")
150 .map((check) => check.name)
151 : [];
152 } else if (settled && owed.length > 0) {
153 changes.push({ kind: "checks-passed", count: checks.length, required: false });
154 owedChecks = [];
155 }
156 passed = passedNow;
157 passedChecks = passedNow ? gateNames : [];
158 }
159​
160 // Only a head never read with checks waits for them; a legacy watch's clock starts at its next
161 // push. CI can register late on any host, so every host waits, but running out of time is news
162 // only where a host always reports checks: elsewhere an empty list can mean there are none.
163 const awaitedBefore =
164 headMoved || watch.awaitingBaseline === "all"
165 ? DateTime.formatIso(DateTime.makeUnsafe(now))
166 : (watch.checksAwaitedSince ?? null);
167 let checksAwaitedSince = checks.length > 0 ? null : awaitedBefore;
168 const awaitedMs = checksAwaitedSince === null ? 0 : now - Date.parse(checksAwaitedSince);
169 if (!unread && awaitedMs >= PULL_REQUEST_WATCH_NO_CHECKS_MS) {
170 if (detail.capabilities.checksAlwaysReport === true) {
171 changes.push({ kind: "no-checks", minutes: Math.round(awaitedMs / 60_000) });
172 }
173 checksAwaitedSince = null;
174 checksPending = false;
175 } else if (checksAwaitedSince !== null) {
176 checksPending = true;
177 }
178​
179 const own = (detail.viewer ?? detail.author?.login)?.toLowerCase();
180 const through = Date.parse(watch.remarksThrough);
181 const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt;
182 // Hosts can stamp remarks to the second, so remarks at the boundary time are told apart by ID.
183 const fresh = (remarks ?? []).filter((remark) => {
184 const at = Date.parse(activeAt(remark));
185 return (
186 (at > through || (at === through && !watch.remarkIds.includes(remark.id))) &&
187 remark.author?.login.toLowerCase() !== own
188 );
189 });
190 if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh });
191 const latest = Math.max(through, ...fresh.map((remark) => Date.parse(activeAt(remark))));
192 const atLatest = fresh.filter((remark) => Date.parse(activeAt(remark)) === latest);
193 const remarksThrough = latest === through ? watch.remarksThrough : activeAt(atLatest[0]!);
194 const remarkIds = [
195 ...(latest === through ? watch.remarkIds : []),
196 ...atLatest.map((remark) => remark.id),
197 ];
198​
199 // Like a failed check, a conflict a push did not resolve is news for the new head.
200 const toldConflict = !headMoved && watch.conflicting;
201 if (watch.awaitingBaseline !== "all" && detail.mergeability === "conflicting" && !toldConflict) {
202 changes.push({ kind: "conflicting" });
203 }
204 // "unknown" is a host still computing after a push; only a clean answer clears a conflict.
205 const conflicting =
206 detail.mergeability === "unknown" ? toldConflict : detail.mergeability === "conflicting";
207​
208 // The queue's checks run on a commit of its own, so its head can stay green after the queue
209 // drops it. After the first read, IDs avoid comparing the server clock with the host clock.
210 // The initial read uses whole seconds to exclude historical removals.
211 const removal = detail.mergeQueueRemoval;
212 if (
213 removal !== undefined &&
214 // Merging also records a removal, up to a second before the pull request reads as merged.
215 removal.reason !== "merged" &&
216 removal.id !== watch.mergeQueueRemovalId &&
217 (watch.mergeQueueRemovalId !== undefined ||
218 Date.parse(removal.removedAt) >= Math.floor(Date.parse(watch.startedAt) / 1000) * 1000)
219 ) {
220 changes.push({ kind: "merge-queue-removed", reason: removal.reason });
221 }
222 // Null records an empty baseline; historical IDs are saved without waking the watch.
223 const mergeQueueRemovalId = removal?.id ?? watch.mergeQueueRemovalId ?? null;
224​
225 const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks");
226 const progress = headMoved || (changes.length > 0 && !commentsOnly);
227 const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0);
228 return {
229 changes,
230 next: {
231 startedAt: watch.startedAt,
232 headSha,
233 failedChecks,
234 passed,
235 passedChecks,
236 owedChecks,
237 awaitingApproval,
238 approvalAnnounced,
239 remarksThrough,
240 remarkIds,
241 conflicting,
242 mergeQueueRemovalId,
243 wakes,
244 // Only a read that cannot be trusted to show the checks leaves the baseline to the next one.
245 ...(checksBaseline &&
246 (unread || (detail.headSha === undefined && checks.length === 0)) && {
247 awaitingBaseline: "checks" as const,
248 }),
249 checksAwaitedSince,
250 checksPending,
251 },
252 exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT,
253 };
255​
256function snippet(body: string): string {
257 const text = body
258 .replaceAll(/<!--[\s\S]*?-->/g, " ")
259 .replaceAll(/\s+/g, " ")
260 .trim();
261 if (text.length <= SNIPPET_LENGTH) return text;
262 // Half an emoji serializes to a lone surrogate escape, which provider JSON parsers reject.
263 const last = text.charCodeAt(SNIPPET_LENGTH - 4);
264 const end = last >= 0xd800 && last <= 0xdbff ? SNIPPET_LENGTH - 4 : SNIPPET_LENGTH - 3;
265 return `${text.slice(0, end)}...`;
267​
268function listed<T>(items: ReadonlyArray<T>, line: (item: T) => string): Array<string> {
269 const lines = items.slice(0, LISTED_ITEMS).map(line);
270 if (items.length > LISTED_ITEMS) lines.push(` - and ${items.length - LISTED_ITEMS} more`);
271 return lines;
273​
274function changeLines(
275 change: PullRequestWatchChange,
276 context: { readonly baseBranch: string; readonly commit: string },
277): Array<string> {
278 switch (change.kind) {
279 case "checks-failed":
280 return [
281 `- Checks failed${context.commit}:`,
282 ...listed(
283 change.failed,
284 (check) =>
285 ` - ${check.name}${check.status === "failure" ? "" : ` (${check.status})`}${check.url ? ` ${check.url}` : ""}`,
286 ),
287 ];
288 case "checks-awaiting-approval":
289 return [
290 `- Checks are waiting for approval or action from a maintainer${context.commit}:`,
291 ...listed(change.checks, (check) => ` - ${check.name}${check.url ? ` ${check.url}` : ""}`),
292 ];
293 case "checks-passed":
294 return [
295 `- All ${change.count} ${change.required ? "required " : ""}${change.count === 1 ? "check" : "checks"} passed${context.commit}.`,
296 ];
297 case "remarks":
298 return [
299 `- ${change.remarks.length} new ${change.remarks.length === 1 ? "comment" : "comments"}:`,
300 ...listed(change.remarks, (remark) => {
301 const where = remark.path === null ? "" : ` on ${remark.path}`;
302 const body = snippet(remark.body);
303 const said = body.length === 0 ? (remark.reviewState ?? "reviewed") : `"${body}"`;
304 return ` - ${remark.author?.login ?? "someone"}${where}: ${said}${remark.url ? ` ${remark.url}` : ""}`;
305 }),
306 ];
307 case "no-checks":
308 return [
309 `- No checks have reported${context.commit} after ${change.minutes} minutes. None may be configured to run on this change.`,
310 ];
311 case "conflicting":
312 return [`- The branch now conflicts with ${context.baseBranch}.`];
313 case "merge-queue-removed":
314 return [
315 `- It was removed from the merge queue without merging${change.reason === null ? "" : `: ${change.reason}`}.`,
316 ];
317 }
319​
320const SUMMARY: Record<PullRequestWatchChange["kind"], string> = {
321 "checks-failed": "checks failed",
322 "checks-passed": "checks passed",
323 "checks-awaiting-approval": "checks await approval",
324 remarks: "new comments",
325 "no-checks": "no checks reported",
326 conflicting: "merge conflict",
327 "merge-queue-removed": "removed from merge queue",
328};
329​
330const WATCHING_CLOSING =
331 "Look into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.";
332const WATCHING_CLOSING_PLURAL =
333 "Look into each item and act on it as your task requires. Each PR T3 Code still watches stays linked and watched, so do not link, watch, or list them again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request for each first so the thread returns to their inbox.";
334​
335export interface PullRequestWatchWake {
336 readonly text: string;
337 readonly notification: OrchestrationV2Notification;
339​
340/** One wake for news on several pull requests, with the closing instructions said once. */
341export function combinePullRequestWatchWakes(
342 wakes: readonly [PullRequestWatchWake, ...ReadonlyArray<PullRequestWatchWake>],
343): PullRequestWatchWake {
344 if (wakes.length === 1) return wakes[0];
345 const closing = `\n\n${WATCHING_CLOSING}`;
346 const watching = wakes.some((wake) => wake.text.endsWith(closing));
347 const outcomes = wakes.map((wake) => wake.notification.outcome);
348 return {
349 text: [
350 ...wakes.map((wake) =>
351 wake.text.endsWith(closing) ? wake.text.slice(0, -closing.length) : wake.text,
352 ),
353 ...(watching ? [WATCHING_CLOSING_PLURAL] : []),
354 ].join("\n\n"),
355 notification: {
356 source: { kind: "monitor" },
357 outcome: outcomes.includes("failed")
358 ? "failed"
359 : outcomes.every((outcome) => outcome === "completed")
360 ? "completed"
361 : "updated",
362 summary: wakes.map((wake) => wake.notification.summary).join("; "),
363 },
364 };
366​
367export function pullRequestWatchMessage(input: {
368 readonly number: number;
369 readonly url: string;
370 readonly baseBranch: string;
371 readonly headSha: string | null;
372 readonly report: PullRequestWatchReport;
373}): { readonly text: string; readonly notification: OrchestrationV2Notification } {
374 const { changes, exhausted } = input.report;
375 const context = {
376 baseBranch: input.baseBranch,
377 commit: input.headSha === null ? "" : ` on ${input.headSha.slice(0, 7)}`,
378 };
379 const text = [
380 `Update on pull request #${input.number} (${input.url}), which T3 Code is watching for you:`,
381 ...changes.flatMap((change) => changeLines(change, context)),
382 "",
383 exhausted
384 ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.`
385 : WATCHING_CLOSING,
386 ].join("\n");
387 const failed = changes.some(
388 (change) =>
389 change.kind === "checks-failed" ||
390 change.kind === "conflicting" ||
391 change.kind === "merge-queue-removed",
392 );
393 const summary = changes.map((change) => SUMMARY[change.kind]);
394 if (exhausted) summary.push("stopped watching");
395 return {
396 text,
397 notification: {
398 source: { kind: "monitor" },
399 outcome: failed
400 ? "failed"
401 : changes.every((change) => change.kind === "checks-passed")
402 ? "completed"
403 : "updated",
404 summary: `#${input.number}: ${summary.join(", ")}`,
405 },
406 };