| 1 | import 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 | */ |
| 13 | export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; |
| 14 | const LISTED_ITEMS = 10; |
| 15 | const SNIPPET_LENGTH = 200; |
| 16 | |
| 17 | export 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 | |
| 23 | export 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. |
| 33 | const 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 | */ |
| 44 | export 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 | |
| 128 | function 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 | |
| 136 | function 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 | |
| 142 | function 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 | |
| 175 | const 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. */ |
| 183 | export 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 | } |