Skip to content
Source
Sign inSign up

pullRequestWatch.ts

356 lines15.2 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​
25export interface PullRequestWatchReport {
26 readonly changes: ReadonlyArray<PullRequestWatchChange>;
27 readonly next: ThreadPullRequestWatch;
28 readonly exhausted: boolean;
30​
31const isFailedCheck = (check: PullRequestCheck) =>
32 check.status === "failure" || check.status === "cancelled";
33​
34// A GitHub Actions job's URL extends its workflow run's URL; their names need not match.
35const workflowRunOf = (url: string | null) => url?.match(/^.*\/actions\/runs\/\d+/)?.[0] ?? null;
36const approvalKey = (check: PullRequestCheck) => workflowRunOf(check.url) ?? check.name;
37​
38export function evaluatePullRequestWatch(
39 watch: ThreadPullRequestWatch,
40 detail: Pick<
41 PullRequestDetail,
42 "capabilities" | "headSha" | "checks" | "mergeability" | "viewer" | "author"
43 >,
44 remarks: ReadonlyArray<PullRequestComment> | null,
45 now: number,
46): PullRequestWatchReport {
47 const changes: Array<PullRequestWatchChange> = [];
48 // Azure DevOps omits the head when its iteration read fails; that is not a push.
49 const headSha = detail.headSha ?? watch.headSha;
50 const headMoved = headSha !== watch.headSha;
51​
52 // Hosts can return no checks on read failure; preserve prior state.
53 let failedChecks = headMoved ? [] : watch.failedChecks;
54 let passed = headMoved ? false : watch.passed;
55 let passedChecks = headMoved ? [] : watch.passedChecks;
56 let owedChecks = headMoved ? [] : (watch.owedChecks ?? []);
57 let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []);
58 let approvalAnnounced = headMoved ? false : (watch.approvalAnnounced ?? false);
59 // An unavailable approval read cannot establish success or that no checks exist.
60 const unread = detail.checks.some((check) => check.placeholder === true);
61 const checks = detail.checks.filter((check) => check.placeholder !== true);
62 const failed = checks.filter(isFailedCheck);
63 const awaiting = checks.filter((check) => check.status === "action-required");
64 if (checks.length > 0) {
65 const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name));
66 if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed });
67 if (!approvalAnnounced && awaiting.length > 0) {
68 changes.push({ kind: "checks-awaiting-approval", checks: awaiting });
69 approvalAnnounced = true;
70 }
71 failedChecks = failed.map((check) => check.name);
72​
73 // Approval reads can omit gated runs. Keep each hold until its jobs finish.
74 const awaitingNow = [...new Set(awaiting.map(approvalKey))];
75 const nameUnfinished = (name: string) => {
76 const matching = checks.filter((check) => check.name === name);
77 return (
78 matching.length === 0 ||
79 matching.some((check) => check.status === "pending" || check.status === "action-required")
80 );
81 };
82 // GitHub deduplicates jobs across runs, so a visible pending job may hide one in an awaited run.
83 // A pending check without a URL could be such a job; one with another host's URL cannot.
84 const jobsPending = checks.some(
85 (check) =>
86 check.status === "pending" && (check.url === null || workflowRunOf(check.url) !== null),
87 );
88 const finished = new Set(
89 jobsPending
90 ? []
91 : checks
92 .filter((check) => check.status !== "action-required" && check.status !== "pending")
93 .map((check) => workflowRunOf(check.url)),
94 );
95 awaitingApproval = [
96 ...awaitingNow,
97 ...awaitingApproval.filter(
98 (key) =>
99 !awaitingNow.includes(key) &&
100 (workflowRunOf(key) === key ? !finished.has(key) : nameUnfinished(key)),
101 ),
102 ];
103 }
104 const required = checks.filter((check) => check.required === true);
105 const gate = required.length > 0 ? required : checks;
106 const passedNow =
107 awaitingApproval.length === 0 &&
108 gate.every((check) => check.status !== "pending" && !isFailedCheck(check));
109 if (checks.length > 0 && !(unread && passedNow)) {
110 const gateNames = gate.map((check) => check.name);
111 const told = passed && passedChecks.length === 0 ? gateNames : passedChecks;
112 // Required checks can finish between sweeps; detect new names. With no required checks, ignore advisory gate growth.
113 const gateGrew = required.length > 0 && gateNames.some((name) => !told.includes(name));
114 // Other checks still pending when required ones passed are owed one wake when all settle.
115 // Only those are owed, so later advisory churn never wakes. One turned required is gate news.
116 const owed =
117 passedNow && passed
118 ? checks
119 .filter((check) => check.required !== true && owedChecks.includes(check.name))
120 .map((check) => check.name)
121 : [];
122 const settled =
123 failed.length === 0 &&
124 awaitingApproval.length === 0 &&
125 checks.every((check) => check.status !== "pending");
126 owedChecks = owed;
127 if (passedNow && (!passed || gateGrew)) {
128 changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 });
129 owedChecks =
130 required.length > 0
131 ? checks
132 .filter((check) => check.required !== true && check.status === "pending")
133 .map((check) => check.name)
134 : [];
135 } else if (settled && owed.length > 0) {
136 changes.push({ kind: "checks-passed", count: checks.length, required: false });
137 owedChecks = [];
138 }
139 passed = passedNow;
140 passedChecks = passedNow ? gateNames : [];
141 }
142​
143 // Only a head never read with checks waits for them: an empty read after checks were seen is
144 // the failed read above. A watch saved before this field may have seen pending checks on its
145 // head, so its clock starts only at the next push. Where a host's empty list can mean the
146 // repository runs no checks at all, nothing waits.
147 let checksAwaitedSince = watch.checksAwaitedSince;
148 if (detail.capabilities.checksAlwaysReport === true) {
149 const awaitedBefore = headMoved
150 ? DateTime.formatIso(DateTime.makeUnsafe(now))
151 : (watch.checksAwaitedSince ?? null);
152 checksAwaitedSince = checks.length > 0 ? null : awaitedBefore;
153 const awaitedMs = checksAwaitedSince === null ? 0 : now - Date.parse(checksAwaitedSince);
154 if (!unread && awaitedMs >= PULL_REQUEST_WATCH_NO_CHECKS_MS) {
155 changes.push({ kind: "no-checks", minutes: Math.round(awaitedMs / 60_000) });
156 checksAwaitedSince = null;
157 }
158 }
159​
160 const own = (detail.viewer ?? detail.author?.login)?.toLowerCase();
161 const through = Date.parse(watch.remarksThrough);
162 const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt;
163 // Hosts can stamp remarks to the second, so remarks at the boundary time are told apart by ID.
164 const fresh = (remarks ?? []).filter((remark) => {
165 const at = Date.parse(activeAt(remark));
166 return (
167 (at > through || (at === through && !watch.remarkIds.includes(remark.id))) &&
168 remark.author?.login.toLowerCase() !== own
169 );
170 });
171 if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh });
172 const latest = Math.max(through, ...fresh.map((remark) => Date.parse(activeAt(remark))));
173 const atLatest = fresh.filter((remark) => Date.parse(activeAt(remark)) === latest);
174 const remarksThrough = latest === through ? watch.remarksThrough : activeAt(atLatest[0]!);
175 const remarkIds = [
176 ...(latest === through ? watch.remarkIds : []),
177 ...atLatest.map((remark) => remark.id),
178 ];
179​
180 // Like a failed check, a conflict a push did not resolve is news for the new head.
181 const toldConflict = !headMoved && watch.conflicting;
182 if (detail.mergeability === "conflicting" && !toldConflict) {
183 changes.push({ kind: "conflicting" });
184 }
185 // "unknown" is a host still computing after a push; only a clean answer clears a conflict.
186 const conflicting =
187 detail.mergeability === "unknown" ? toldConflict : detail.mergeability === "conflicting";
188​
189 const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks");
190 const progress = headMoved || (changes.length > 0 && !commentsOnly);
191 const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0);
192 return {
193 changes,
194 next: {
195 startedAt: watch.startedAt,
196 headSha,
197 failedChecks,
198 passed,
199 passedChecks,
200 owedChecks,
201 awaitingApproval,
202 approvalAnnounced,
203 remarksThrough,
204 remarkIds,
205 conflicting,
206 wakes,
207 checksAwaitedSince,
208 },
209 exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT,
210 };
212​
213function snippet(body: string): string {
214 const text = body
215 .replaceAll(/<!--[\s\S]*?-->/g, " ")
216 .replaceAll(/\s+/g, " ")
217 .trim();
218 if (text.length <= SNIPPET_LENGTH) return text;
219 // Half an emoji serializes to a lone surrogate escape, which provider JSON parsers reject.
220 const last = text.charCodeAt(SNIPPET_LENGTH - 4);
221 const end = last >= 0xd800 && last <= 0xdbff ? SNIPPET_LENGTH - 4 : SNIPPET_LENGTH - 3;
222 return `${text.slice(0, end)}...`;
224​
225function listed<T>(items: ReadonlyArray<T>, line: (item: T) => string): Array<string> {
226 const lines = items.slice(0, LISTED_ITEMS).map(line);
227 if (items.length > LISTED_ITEMS) lines.push(` - and ${items.length - LISTED_ITEMS} more`);
228 return lines;
230​
231function changeLines(
232 change: PullRequestWatchChange,
233 context: { readonly baseBranch: string; readonly commit: string },
234): Array<string> {
235 switch (change.kind) {
236 case "checks-failed":
237 return [
238 `- Checks failed${context.commit}:`,
239 ...listed(
240 change.failed,
241 (check) =>
242 ` - ${check.name}${check.status === "failure" ? "" : ` (${check.status})`}${check.url ? ` ${check.url}` : ""}`,
243 ),
244 ];
245 case "checks-awaiting-approval":
246 return [
247 `- Checks are waiting for approval or action from a maintainer${context.commit}:`,
248 ...listed(change.checks, (check) => ` - ${check.name}${check.url ? ` ${check.url}` : ""}`),
249 ];
250 case "checks-passed":
251 return [
252 `- All ${change.count} ${change.required ? "required " : ""}${change.count === 1 ? "check" : "checks"} passed${context.commit}.`,
253 ];
254 case "remarks":
255 return [
256 `- ${change.remarks.length} new ${change.remarks.length === 1 ? "comment" : "comments"}:`,
257 ...listed(change.remarks, (remark) => {
258 const where = remark.path === null ? "" : ` on ${remark.path}`;
259 const body = snippet(remark.body);
260 const said = body.length === 0 ? (remark.reviewState ?? "reviewed") : `"${body}"`;
261 return ` - ${remark.author?.login ?? "someone"}${where}: ${said}${remark.url ? ` ${remark.url}` : ""}`;
262 }),
263 ];
264 case "no-checks":
265 return [
266 `- No checks have reported${context.commit} after ${change.minutes} minutes. None may be configured to run on this change.`,
267 ];
268 case "conflicting":
269 return [`- The branch now conflicts with ${context.baseBranch}.`];
270 }
272​
273const SUMMARY: Record<PullRequestWatchChange["kind"], string> = {
274 "checks-failed": "checks failed",
275 "checks-passed": "checks passed",
276 "checks-awaiting-approval": "checks await approval",
277 remarks: "new comments",
278 "no-checks": "no checks reported",
279 conflicting: "merge conflict",
280};
281​
282const WATCHING_CLOSING =
283 "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.";
284const WATCHING_CLOSING_PLURAL =
285 "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.";
286​
287export interface PullRequestWatchWake {
288 readonly text: string;
289 readonly notification: OrchestrationV2Notification;
291​
292/** One wake for news on several pull requests, with the closing instructions said once. */
293export function combinePullRequestWatchWakes(
294 wakes: readonly [PullRequestWatchWake, ...ReadonlyArray<PullRequestWatchWake>],
295): PullRequestWatchWake {
296 if (wakes.length === 1) return wakes[0];
297 const closing = `\n\n${WATCHING_CLOSING}`;
298 const watching = wakes.some((wake) => wake.text.endsWith(closing));
299 const outcomes = wakes.map((wake) => wake.notification.outcome);
300 return {
301 text: [
302 ...wakes.map((wake) =>
303 wake.text.endsWith(closing) ? wake.text.slice(0, -closing.length) : wake.text,
304 ),
305 ...(watching ? [WATCHING_CLOSING_PLURAL] : []),
306 ].join("\n\n"),
307 notification: {
308 source: { kind: "monitor" },
309 outcome: outcomes.includes("failed")
310 ? "failed"
311 : outcomes.every((outcome) => outcome === "completed")
312 ? "completed"
313 : "updated",
314 summary: wakes.map((wake) => wake.notification.summary).join("; "),
315 },
316 };
318​
319export function pullRequestWatchMessage(input: {
320 readonly number: number;
321 readonly url: string;
322 readonly baseBranch: string;
323 readonly headSha: string | null;
324 readonly report: PullRequestWatchReport;
325}): { readonly text: string; readonly notification: OrchestrationV2Notification } {
326 const { changes, exhausted } = input.report;
327 const context = {
328 baseBranch: input.baseBranch,
329 commit: input.headSha === null ? "" : ` on ${input.headSha.slice(0, 7)}`,
330 };
331 const text = [
332 `Update on pull request #${input.number} (${input.url}), which T3 Code is watching for you:`,
333 ...changes.flatMap((change) => changeLines(change, context)),
334 "",
335 exhausted
336 ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.`
337 : WATCHING_CLOSING,
338 ].join("\n");
339 const failed = changes.some(
340 (change) => change.kind === "checks-failed" || change.kind === "conflicting",
341 );
342 const summary = changes.map((change) => SUMMARY[change.kind]);
343 if (exhausted) summary.push("stopped watching");
344 return {
345 text,
346 notification: {
347 source: { kind: "monitor" },
348 outcome: failed
349 ? "failed"
350 : changes.every((change) => change.kind === "checks-passed")
351 ? "completed"
352 : "updated",
353 summary: `#${input.number}: ${summary.join(", ")}`,
354 },
355 };