1import type {
2 OrchestrationV2Notification,
3 PullRequestCheck,
4 PullRequestComment,
5 PullRequestDetail,
6 ThreadPullRequestWatch,
7} from "@t3tools/contracts";
8
9/**
10 * Wakes in a row that bring only comments. Check, conflict, or push news resets the count, so
11 * this only stops a chatty bot looping an agent that is replying to it.
12 */
13export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10;
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: "remarks"; readonly remarks: ReadonlyArray<PullRequestComment> }
21 | { readonly kind: "conflicting" };
22
23export interface PullRequestWatchReport {
24 /** What the agent has not been told yet. Empty means no wake. */
25 readonly changes: ReadonlyArray<PullRequestWatchChange>;
26 /** The watch to record, whether or not anything is reported. */
27 readonly next: ThreadPullRequestWatch;
28 /** This report spends the last wake before the limit, so watching stops after it. */
29 readonly exhausted: boolean;
30}
31
32// "action-required" is a finished check that needs someone, so the agent hears about it.
33const isFailedCheck = (check: PullRequestCheck) =>
34 check.status === "failure" || check.status === "cancelled" || check.status === "action-required";
35
36/**
37 * Compares a watched pull request with what its agent was last told. Each check is reported as
38 * soon as it fails, so a check that never finishes (an advisory review bot) cannot hold the
39 * news back. "Passed" is reported once the checks the base branch requires all passed, or all
40 * checks where the host marks none required. Remarks count when someone other than the agent's
41 * own account wrote them, so its own replies never wake it. `remarks` is null when the
42 * conversation could not be read; remarks then wait for a later pass.
43 */
44export function evaluatePullRequestWatch(
45 watch: ThreadPullRequestWatch,
46 detail: Pick<PullRequestDetail, "headSha" | "checks" | "mergeability" | "viewer" | "author">,
47 remarks: ReadonlyArray<PullRequestComment> | null,
48): PullRequestWatchReport {
49 const changes: Array<PullRequestWatchChange> = [];
50 const headSha = detail.headSha ?? null;
51 const headMoved = headSha !== watch.headSha;
52
53 // An empty list keeps the last state: a host can answer with one when its check read fails.
54 let failedChecks = headMoved ? [] : watch.failedChecks;
55 let passed = headMoved ? false : watch.passed;
56 let passedChecks = headMoved ? [] : watch.passedChecks;
57 if (detail.checks.length > 0) {
58 const failed = detail.checks.filter(isFailedCheck);
59 const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name));
60 if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed });
61 // A check that runs again leaves the list, so a rerun that fails again is reported.
62 failedChecks = failed.map((check) => check.name);
63
64 const required = detail.checks.filter((check) => check.required === true);
65 const gate = required.length > 0 ? required : detail.checks;
66 const passedNow = gate.every((check) => check.status !== "pending" && !isFailedCheck(check));
67 const gateNames = gate.map((check) => check.name);
68 // A watch saved before passedChecks existed takes the current names, so it does not wake.
69 const told = passed && passedChecks.length === 0 ? gateNames : passedChecks;
70 // A required job created and finished between two passes is never seen pending. Without
71 // required checks, any check counts, and advisory bots keep adding passed ones: no wake.
72 const gateGrew = required.length > 0 && gateNames.some((name) => !told.includes(name));
73 if (passedNow && (!passed || gateGrew)) {
74 changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 });
75 }
76 passed = passedNow;
77 passedChecks = passedNow ? gateNames : [];
78 }
79
80 const own = (detail.viewer ?? detail.author?.login)?.toLowerCase();
81 const through = Date.parse(watch.remarksThrough);
82 // An edit counts as new activity, so bots that rewrite one summary comment still wake the agent.
83 const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt;
84 // GitHub times are per second, so remarks at the boundary time are told apart by ID.
85 const fresh = (remarks ?? []).filter((remark) => {
86 const at = Date.parse(activeAt(remark));
87 return (
88 (at > through || (at === through && !watch.remarkIds.includes(remark.id))) &&
89 remark.author?.login.toLowerCase() !== own
90 );
91 });
92 if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh });
93 const latest = Math.max(through, ...fresh.map((remark) => Date.parse(activeAt(remark))));
94 const atLatest = fresh.filter((remark) => Date.parse(activeAt(remark)) === latest);
95 const remarksThrough = latest === through ? watch.remarksThrough : activeAt(atLatest[0]!);
96 const remarkIds = [
97 ...(latest === through ? watch.remarkIds : []),
98 ...atLatest.map((remark) => remark.id),
99 ];
100
101 if (detail.mergeability === "conflicting" && !watch.conflicting) {
102 changes.push({ kind: "conflicting" });
103 }
104 // "unknown" is GitHub still computing after a push; only a clean answer clears a conflict.
105 const conflicting =
106 detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting";
107
108 const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks");
109 const progress = headMoved || (changes.length > 0 && !commentsOnly);
110 const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0);
111 return {
112 changes,
113 next: {
114 startedAt: watch.startedAt,
115 headSha,
116 failedChecks,
117 passed,
118 passedChecks,
119 remarksThrough,
120 remarkIds,
121 conflicting,
122 wakes,
123 },
124 exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT,
125 };
126}
127
128function snippet(body: string): string {
129 const text = body
130 .replaceAll(/<!--[\s\S]*?-->/g, " ")
131 .replaceAll(/\s+/g, " ")
132 .trim();
133 return text.length <= SNIPPET_LENGTH ? text : `${text.slice(0, SNIPPET_LENGTH - 3)}...`;
134}
135
136function listed<T>(items: ReadonlyArray<T>, line: (item: T) => string): Array<string> {
137 const lines = items.slice(0, LISTED_ITEMS).map(line);
138 if (items.length > LISTED_ITEMS) lines.push(` - and ${items.length - LISTED_ITEMS} more`);
139 return lines;
140}
141
142function changeLines(
143 change: PullRequestWatchChange,
144 context: { readonly baseBranch: string; readonly commit: string },
145): Array<string> {
146 switch (change.kind) {
147 case "checks-failed":
148 return [
149 `- Checks failed${context.commit}:`,
150 ...listed(
151 change.failed,
152 (check) =>
153 ` - ${check.name}${check.status === "failure" ? "" : ` (${check.status})`}${check.url ? ` ${check.url}` : ""}`,
154 ),
155 ];
156 case "checks-passed":
157 return [
158 `- All ${change.count} ${change.required ? "required " : ""}${change.count === 1 ? "check" : "checks"} passed${context.commit}.`,
159 ];
160 case "remarks":
161 return [
162 `- ${change.remarks.length} new ${change.remarks.length === 1 ? "comment" : "comments"}:`,
163 ...listed(change.remarks, (remark) => {
164 const where = remark.path === null ? "" : ` on ${remark.path}`;
165 const body = snippet(remark.body);
166 const said = body.length === 0 ? (remark.reviewState ?? "reviewed") : `"${body}"`;
167 return ` - ${remark.author?.login ?? "someone"}${where}: ${said}${remark.url ? ` ${remark.url}` : ""}`;
168 }),
169 ];
170 case "conflicting":
171 return [`- The branch now conflicts with ${context.baseBranch}.`];
172 }
173}
174
175const SUMMARY: Record<PullRequestWatchChange["kind"], string> = {
176 "checks-failed": "checks failed",
177 "checks-passed": "checks passed",
178 remarks: "new comments",
179 conflicting: "merge conflict",
180};
181
182/** The wake the agent reads and the timeline notification the user sees. */
183export function pullRequestWatchMessage(input: {
184 readonly number: number;
185 readonly url: string;
186 readonly baseBranch: string;
187 readonly headSha: string | null;
188 readonly report: PullRequestWatchReport;
189}): { readonly text: string; readonly notification: OrchestrationV2Notification } {
190 const { changes, exhausted } = input.report;
191 const context = {
192 baseBranch: input.baseBranch,
193 commit: input.headSha === null ? "" : ` on ${input.headSha.slice(0, 7)}`,
194 };
195 const text = [
196 `Update on pull request #${input.number} (${input.url}), which T3 Code is watching for you:`,
197 ...changes.flatMap((change) => changeLines(change, context)),
198 "",
199 exhausted
200 ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.`
201 : "Look into each item and act on it as your task requires. T3 Code keeps watching and wakes you on the next change, so end your turn when you are done. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.",
202 ].join("\n");
203 const failed = changes.some(
204 (change) => change.kind === "checks-failed" || change.kind === "conflicting",
205 );
206 const summary = changes.map((change) => SUMMARY[change.kind]);
207 if (exhausted) summary.push("stopped watching");
208 return {
209 text,
210 notification: {
211 source: { kind: "monitor" },
212 outcome: failed
213 ? "failed"
214 : changes.every((change) => change.kind === "checks-passed")
215 ? "completed"
216 : "updated",
217 summary: `#${input.number}: ${summary.join(", ")}`,
218 },
219 };
220}