import type { OrchestrationV2Notification, PullRequestCheck, PullRequestComment, PullRequestDetail, ThreadPullRequestWatch, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; /** Bounds consecutive comment-only wakes to stop bot reply loops; check, conflict, or push news resets it. */ export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; /** How long a head may go without any check before the agent is told none reported. */ export const PULL_REQUEST_WATCH_NO_CHECKS_MS = 15 * 60_000; const LISTED_ITEMS = 10; const SNIPPET_LENGTH = 200; export type PullRequestWatchChange = | { readonly kind: "checks-failed"; readonly failed: ReadonlyArray } | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean } | { readonly kind: "checks-awaiting-approval"; readonly checks: ReadonlyArray } | { readonly kind: "remarks"; readonly remarks: ReadonlyArray } | { readonly kind: "no-checks"; readonly minutes: number } | { readonly kind: "conflicting" }; export interface PullRequestWatchReport { readonly changes: ReadonlyArray; readonly next: ThreadPullRequestWatch; readonly exhausted: boolean; } const isFailedCheck = (check: PullRequestCheck) => check.status === "failure" || check.status === "cancelled"; // A GitHub Actions job's URL extends its workflow run's URL; their names need not match. const workflowRunOf = (url: string | null) => url?.match(/^.*\/actions\/runs\/\d+/)?.[0] ?? null; const approvalKey = (check: PullRequestCheck) => workflowRunOf(check.url) ?? check.name; export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, detail: Pick< PullRequestDetail, "capabilities" | "headSha" | "checks" | "mergeability" | "viewer" | "author" >, remarks: ReadonlyArray | null, now: number, ): PullRequestWatchReport { const changes: Array = []; // Azure DevOps omits the head when its iteration read fails; that is not a push. const headSha = detail.headSha ?? watch.headSha; const headMoved = headSha !== watch.headSha; // Hosts can return no checks on read failure; preserve prior state. let failedChecks = headMoved ? [] : watch.failedChecks; let passed = headMoved ? false : watch.passed; let passedChecks = headMoved ? [] : watch.passedChecks; let owedChecks = headMoved ? [] : (watch.owedChecks ?? []); let awaitingApproval = headMoved ? [] : (watch.awaitingApproval ?? []); let approvalAnnounced = headMoved ? false : (watch.approvalAnnounced ?? false); // An unavailable approval read cannot establish success or that no checks exist. const unread = detail.checks.some((check) => check.placeholder === true); const checks = detail.checks.filter((check) => check.placeholder !== true); const failed = checks.filter(isFailedCheck); const awaiting = checks.filter((check) => check.status === "action-required"); if (checks.length > 0) { const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); if (!approvalAnnounced && awaiting.length > 0) { changes.push({ kind: "checks-awaiting-approval", checks: awaiting }); approvalAnnounced = true; } failedChecks = failed.map((check) => check.name); // Approval reads can omit gated runs. Keep each hold until its jobs finish. const awaitingNow = [...new Set(awaiting.map(approvalKey))]; const nameUnfinished = (name: string) => { const matching = checks.filter((check) => check.name === name); return ( matching.length === 0 || matching.some((check) => check.status === "pending" || check.status === "action-required") ); }; // GitHub deduplicates jobs across runs, so a visible pending job may hide one in an awaited run. // A pending check without a URL could be such a job; one with another host's URL cannot. const jobsPending = checks.some( (check) => check.status === "pending" && (check.url === null || workflowRunOf(check.url) !== null), ); const finished = new Set( jobsPending ? [] : checks .filter((check) => check.status !== "action-required" && check.status !== "pending") .map((check) => workflowRunOf(check.url)), ); awaitingApproval = [ ...awaitingNow, ...awaitingApproval.filter( (key) => !awaitingNow.includes(key) && (workflowRunOf(key) === key ? !finished.has(key) : nameUnfinished(key)), ), ]; } const required = checks.filter((check) => check.required === true); const gate = required.length > 0 ? required : checks; const passedNow = awaitingApproval.length === 0 && gate.every((check) => check.status !== "pending" && !isFailedCheck(check)); if (checks.length > 0 && !(unread && passedNow)) { const gateNames = gate.map((check) => check.name); const told = passed && passedChecks.length === 0 ? gateNames : passedChecks; // Required checks can finish between sweeps; detect new names. With no required checks, ignore advisory gate growth. const gateGrew = required.length > 0 && gateNames.some((name) => !told.includes(name)); // Other checks still pending when required ones passed are owed one wake when all settle. // Only those are owed, so later advisory churn never wakes. One turned required is gate news. const owed = passedNow && passed ? checks .filter((check) => check.required !== true && owedChecks.includes(check.name)) .map((check) => check.name) : []; const settled = failed.length === 0 && awaitingApproval.length === 0 && checks.every((check) => check.status !== "pending"); owedChecks = owed; if (passedNow && (!passed || gateGrew)) { changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 }); owedChecks = required.length > 0 ? checks .filter((check) => check.required !== true && check.status === "pending") .map((check) => check.name) : []; } else if (settled && owed.length > 0) { changes.push({ kind: "checks-passed", count: checks.length, required: false }); owedChecks = []; } passed = passedNow; passedChecks = passedNow ? gateNames : []; } // Only a head never read with checks waits for them: an empty read after checks were seen is // the failed read above. A watch saved before this field may have seen pending checks on its // head, so its clock starts only at the next push. Where a host's empty list can mean the // repository runs no checks at all, nothing waits. let checksAwaitedSince = watch.checksAwaitedSince; if (detail.capabilities.checksAlwaysReport === true) { const awaitedBefore = headMoved ? DateTime.formatIso(DateTime.makeUnsafe(now)) : (watch.checksAwaitedSince ?? null); checksAwaitedSince = checks.length > 0 ? null : awaitedBefore; const awaitedMs = checksAwaitedSince === null ? 0 : now - Date.parse(checksAwaitedSince); if (!unread && awaitedMs >= PULL_REQUEST_WATCH_NO_CHECKS_MS) { changes.push({ kind: "no-checks", minutes: Math.round(awaitedMs / 60_000) }); checksAwaitedSince = null; } } const own = (detail.viewer ?? detail.author?.login)?.toLowerCase(); const through = Date.parse(watch.remarksThrough); const activeAt = (remark: PullRequestComment) => remark.editedAt ?? remark.createdAt; // Hosts can stamp remarks to the second, so remarks at the boundary time are told apart by ID. const fresh = (remarks ?? []).filter((remark) => { const at = Date.parse(activeAt(remark)); return ( (at > through || (at === through && !watch.remarkIds.includes(remark.id))) && remark.author?.login.toLowerCase() !== own ); }); if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh }); const latest = Math.max(through, ...fresh.map((remark) => Date.parse(activeAt(remark)))); const atLatest = fresh.filter((remark) => Date.parse(activeAt(remark)) === latest); const remarksThrough = latest === through ? watch.remarksThrough : activeAt(atLatest[0]!); const remarkIds = [ ...(latest === through ? watch.remarkIds : []), ...atLatest.map((remark) => remark.id), ]; // Like a failed check, a conflict a push did not resolve is news for the new head. const toldConflict = !headMoved && watch.conflicting; if (detail.mergeability === "conflicting" && !toldConflict) { changes.push({ kind: "conflicting" }); } // "unknown" is a host still computing after a push; only a clean answer clears a conflict. const conflicting = detail.mergeability === "unknown" ? toldConflict : detail.mergeability === "conflicting"; const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks"); const progress = headMoved || (changes.length > 0 && !commentsOnly); const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0); return { changes, next: { startedAt: watch.startedAt, headSha, failedChecks, passed, passedChecks, owedChecks, awaitingApproval, approvalAnnounced, remarksThrough, remarkIds, conflicting, wakes, checksAwaitedSince, }, exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT, }; } function snippet(body: string): string { const text = body .replaceAll(//g, " ") .replaceAll(/\s+/g, " ") .trim(); if (text.length <= SNIPPET_LENGTH) return text; // Half an emoji serializes to a lone surrogate escape, which provider JSON parsers reject. const last = text.charCodeAt(SNIPPET_LENGTH - 4); const end = last >= 0xd800 && last <= 0xdbff ? SNIPPET_LENGTH - 4 : SNIPPET_LENGTH - 3; return `${text.slice(0, end)}...`; } function listed(items: ReadonlyArray, line: (item: T) => string): Array { const lines = items.slice(0, LISTED_ITEMS).map(line); if (items.length > LISTED_ITEMS) lines.push(` - and ${items.length - LISTED_ITEMS} more`); return lines; } function changeLines( change: PullRequestWatchChange, context: { readonly baseBranch: string; readonly commit: string }, ): Array { switch (change.kind) { case "checks-failed": return [ `- Checks failed${context.commit}:`, ...listed( change.failed, (check) => ` - ${check.name}${check.status === "failure" ? "" : ` (${check.status})`}${check.url ? ` ${check.url}` : ""}`, ), ]; case "checks-awaiting-approval": return [ `- Checks are waiting for approval or action from a maintainer${context.commit}:`, ...listed(change.checks, (check) => ` - ${check.name}${check.url ? ` ${check.url}` : ""}`), ]; case "checks-passed": return [ `- All ${change.count} ${change.required ? "required " : ""}${change.count === 1 ? "check" : "checks"} passed${context.commit}.`, ]; case "remarks": return [ `- ${change.remarks.length} new ${change.remarks.length === 1 ? "comment" : "comments"}:`, ...listed(change.remarks, (remark) => { const where = remark.path === null ? "" : ` on ${remark.path}`; const body = snippet(remark.body); const said = body.length === 0 ? (remark.reviewState ?? "reviewed") : `"${body}"`; return ` - ${remark.author?.login ?? "someone"}${where}: ${said}${remark.url ? ` ${remark.url}` : ""}`; }), ]; case "no-checks": return [ `- No checks have reported${context.commit} after ${change.minutes} minutes. None may be configured to run on this change.`, ]; case "conflicting": return [`- The branch now conflicts with ${context.baseBranch}.`]; } } const SUMMARY: Record = { "checks-failed": "checks failed", "checks-passed": "checks passed", "checks-awaiting-approval": "checks await approval", remarks: "new comments", "no-checks": "no checks reported", conflicting: "merge conflict", }; export function pullRequestWatchMessage(input: { readonly number: number; readonly url: string; readonly baseBranch: string; readonly headSha: string | null; readonly report: PullRequestWatchReport; }): { readonly text: string; readonly notification: OrchestrationV2Notification } { const { changes, exhausted } = input.report; const context = { baseBranch: input.baseBranch, commit: input.headSha === null ? "" : ` on ${input.headSha.slice(0, 7)}`, }; const text = [ `Update on pull request #${input.number} (${input.url}), which T3 Code is watching for you:`, ...changes.flatMap((change) => changeLines(change, context)), "", exhausted ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.` : "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.", ].join("\n"); const failed = changes.some( (change) => change.kind === "checks-failed" || change.kind === "conflicting", ); const summary = changes.map((change) => SUMMARY[change.kind]); if (exhausted) summary.push("stopped watching"); return { text, notification: { source: { kind: "monitor" }, outcome: failed ? "failed" : changes.every((change) => change.kind === "checks-passed") ? "completed" : "updated", summary: `#${input.number}: ${summary.join(", ")}`, }, }; }