From df9ee3373616241d2d13c5de0e9e18ab58b4d28e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 21 Oct 2026 01:39:28 -0700 Subject: [PATCH 1/5] feat: PR failures that also fail on main stop waking agents vp run ci:failed prints a PR's failed CI jobs, each tagged [new] or [also on main], with failing tests, lint errors, and TS errors. watch_pull_request compares a PR's failed checks with its base branch (GitHub). A check the base branch also fails waits quietly, and the agent wakes when the base branch passes it. The watch records these checks in failingOnBase, shown in the MCP output, web tooltip, and mobile label. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../threads/LinkedPullRequestList.tsx | 2 +- .../toolkits/pullRequests/handlers.test.ts | 7 + .../src/mcp/toolkits/pullRequests/handlers.ts | 2 + .../src/mcp/toolkits/pullRequests/tools.ts | 9 +- .../src/orchestration-v2/Orchestrator.ts | 2 + .../orchestration-v2/ProjectionStore.test.ts | 1 + .../PullRequestWatchReactor.ts | 32 +++- .../ThreadForkService.test.ts | 1 + .../pullRequestWatch.forgejoComments.test.ts | 1 + .../orchestration-v2/pullRequestWatch.test.ts | 48 ++++- .../src/orchestration-v2/pullRequestWatch.ts | 38 +++- .../src/orchestration-v2/runtimeLayer.test.ts | 85 +++++++++ .../src/pullRequest/PullRequestService.ts | 26 +++ .../components/chat/ThreadDetailsPrRow.tsx | 5 + .../components/chat/ThreadDetailsPrRows.tsx | 9 +- .../pullRequest/ThreadPullRequestsPanel.tsx | 3 + docs/user/source-control.md | 5 +- package.json | 1 + packages/contracts/src/threadPullRequest.ts | 7 + .../src/server/PullRequestProvider.ts | 14 ++ .../src/server/GitHubPullRequestApi.ts | 16 ++ .../src/server/GitHubPullRequestProvider.ts | 3 + .../src/server/gitHubPullRequestJson.test.ts | 40 ++++ .../src/server/gitHubPullRequestJson.ts | 23 +++ .../src/server/pullRequestReads.graphql | 34 ++++ .../src/server/pullRequestReads.graphql.ts | 133 ++++++++++++- scripts/ci-failed.ts | 175 ++++++++++++++++++ 28 files changed, 702 insertions(+), 22 deletions(-) create mode 100644 scripts/ci-failed.ts diff --git a/AGENTS.md b/AGENTS.md index 6f29441b98511..11ef681fc698e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,7 +142,7 @@ For authorized mobile verification, a missing or outdated native client is a bui - UI changes need before/after images. Motion or timing needs a short video. - Upload PR evidence to GitHub. Never commit PR-only screenshots or assets such as `.github/pr-assets/`. - One request is one PR. Split it only when the maintainer asks. Outside contributions follow the stricter [one problem per PR](CONTRIBUTING.md#one-problem) rule. -- When babysitting: call `watch_pull_request` and end your turn. T3 Code wakes you on checks, comments, or conflicts. Do not poll or sleep. Verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. +- When babysitting: call `watch_pull_request` and end your turn. T3 Code wakes you on checks, comments, or conflicts, but not for checks main also fails. Do not poll or sleep. On a CI failure, run `vp run ci:failed` and fix only the jobs tagged [new]. Verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. ## Documentation diff --git a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx index 903e7fd574bb4..12e52c08d8a57 100644 --- a/apps/mobile/src/features/threads/LinkedPullRequestList.tsx +++ b/apps/mobile/src/features/threads/LinkedPullRequestList.tsx @@ -80,7 +80,7 @@ export const LinkedPullRequestRow = memo(function LinkedPullRequestRow(props: { const row = ( { void props.openPullRequest(link.url).then((opened) => { diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index e9ee5a05ad943..8c95dc842be37 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -291,6 +291,7 @@ describe("pull request toolkit handlers", () => { failedChecks: [], passed: false, passedChecks: [], + failingOnBase: ["lint"], remarksThrough: "2026-08-20T00:00:00.000Z", remarkIds: [], conflicting: false, @@ -308,6 +309,11 @@ describe("pull request toolkit handlers", () => { .call("watch_pull_request", { repository: "t3tools/t3code", number: 1 }) .pipe(Effect.flip); expect(error).toMatchObject({ _tag: "PullRequestNotOpenError", state: "merged" }); + // The agent can read why a failed check did not wake it. + const listed = yield* harness.call("list_thread_pull_requests", {}); + expect(listed.pullRequests.find((entry) => entry.number === 3)?.failingOnBase).toEqual([ + "lint", + ]); expect( yield* harness.call("unwatch_pull_request", { repository: "t3tools/t3code", number: 3 }), ).toMatchObject({ wasWatching: true }); @@ -522,6 +528,7 @@ describe("pull request toolkit handlers", () => { url: "https://github.com/t3tools/t3code/pull/3", source: "agent", watching: false, + failingOnBase: [], state: "open", title: "PR 3", headBranch: "feat-c", diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index c777214aad5c6..dae30f07f3b27 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -135,6 +135,7 @@ function entryOf( url: link.url, source: link.source, watching: link.watch !== undefined, + failingOnBase: link.watch?.failingOnBase ?? [], state: link.snapshot?.state ?? null, title: link.snapshot?.title ?? null, headBranch: link.snapshot?.headBranch ?? null, @@ -296,6 +297,7 @@ const make = Effect.gen(function* () { url: target.url, watching: watchedLink(after)?.watch !== undefined, wasWatching: before?.watch !== undefined, + failingOnBase: watchedLink(after)?.watch?.failingOnBase ?? [], }; }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index a3a75eabaa385..d2b998e5658a8 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -227,6 +227,11 @@ export const UnlinkPullRequestResult = Schema.Struct({ }); export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; +const FailingOnBase = Schema.Array(Schema.String).annotate({ + description: + "Failed checks that the base branch fails too. They are not from this pull request, so T3 Code does not wake you for them. It wakes you if the base branch passes them while they still fail here.", +}); + export const WatchPullRequestResult = Schema.Struct({ ...PullRequestIdentity, watching: Schema.Boolean.annotate({ @@ -235,6 +240,7 @@ export const WatchPullRequestResult = Schema.Struct({ wasWatching: Schema.Boolean.annotate({ description: "Whether it was already watched before the call.", }), + failingOnBase: FailingOnBase, }); export type WatchPullRequestResult = typeof WatchPullRequestResult.Type; @@ -242,6 +248,7 @@ export const ThreadPullRequestEntry = Schema.Struct({ ...PullRequestIdentity, source: ThreadPullRequestLinkSource, watching: Schema.Boolean, + failingOnBase: FailingOnBase, state: Schema.NullOr(PullRequestState), title: Schema.NullOr(Schema.String), headBranch: Schema.NullOr(Schema.String), @@ -316,7 +323,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.", + "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. A failed check that the base branch fails too does not wake you, because it is not from this pull request; T3 Code wakes you if the base branch passes it while it still fails here. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.", parameters: PullRequestTargetInput, success: WatchPullRequestResult, failure: PullRequestToolError, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 213a1a31ab74c..ab1009830a692 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3375,6 +3375,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio failedChecks: [], passed: false, passedChecks: [], + failingOnBase: [], conflicting: false, // Watching again must not repeat check or conflict news the last watch told. ...(existing.endedWatch && { @@ -3382,6 +3383,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio failedChecks: existing.endedWatch.failedChecks, passed: existing.endedWatch.passed, passedChecks: existing.endedWatch.passedChecks, + failingOnBase: existing.endedWatch.failingOnBase, conflicting: existing.endedWatch.conflicting, }), remarksThrough: startedAt, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 83f33269f3992..1ead275bbac12 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -5478,6 +5478,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { failedChecks: [], passed: false, passedChecks: [], + failingOnBase: [], remarksThrough: DateTime.formatIso(at), remarkIds: [], conflicting: false, diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 3e4896d8a5cb1..27091e3ace90e 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -34,7 +34,11 @@ import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts"; +import { + evaluatePullRequestWatch, + isFailedCheck, + pullRequestWatchMessage, +} from "./pullRequestWatch.ts"; /** * Checks take minutes, so a faster pass mostly spends the host's rate limit, @@ -171,6 +175,7 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc left.failedChecks.join("\n") === right.failedChecks.join("\n") && left.passed === right.passed && left.passedChecks.join("\n") === right.passedChecks.join("\n") && + left.failingOnBase.join("\n") === right.failingOnBase.join("\n") && left.remarksThrough === right.remarksThrough && left.remarkIds.join("\n") === right.remarkIds.join("\n") && left.conflicting === right.conflicting && @@ -524,22 +529,43 @@ export const make = Effect.gen(function* () { fingerprint === null ? QUIET_REREAD_MS : FINGERPRINT_REREAD_MS, ) : null; + // Only failures are compared with the base branch, so a green pull request costs no read. + const base = + detail.checks.some(isFailedCheck) || + group.targets.some((target) => target.watch.failingOnBase.length > 0) + ? yield* pullRequests.baseChecks({ ...reference, branch: detail.baseBranch }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logDebug("pull request watch base checks failed", { + pullRequest: group.key, + cause, + }).pipe(Effect.as(null)), + ), + ) + : null; + const waitsOnBase = + group.targets.some((target) => target.watch.failingOnBase.length > 0) || + detail.checks.some((check) => isFailedCheck(check) && base?.failing.includes(check.name)); lastReads.set(group.key, { at: plan.activity || last === undefined ? (threadTails.get(group.key)?.fetchedAt ?? now) : last.at, fingerprint: group.fingerprint, + // A failure the base branch shares waits on news a host fingerprint of this pull request + // cannot show, so it is read on every pass, like a pending check. inFlight: detail.mergeability === "unknown" || - detail.checks.some((check) => check.status === "pending"), + detail.checks.some((check) => check.status === "pending") || + waitsOnBase, remarksComplete: plan.activity ? remarks !== null : (last?.remarksComplete ?? false), watches: new Set(group.targets.map(watchKey)), status: fingerprint?.status ?? null, remarks: fingerprint?.remarks ?? null, }); yield* eachTarget(group, (target) => { - const report = evaluatePullRequestWatch(target.watch, detail, remarks); + const report = evaluatePullRequestWatch(target.watch, detail, remarks, base); if (report.changes.length > 0) { const wake = pullRequestWatchMessage({ number: target.link.number, diff --git a/apps/server/src/orchestration-v2/ThreadForkService.test.ts b/apps/server/src/orchestration-v2/ThreadForkService.test.ts index b6b89758f7b5d..5635ec6025e47 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.test.ts @@ -168,6 +168,7 @@ it.effect("preserves pull request context without inheriting any watches", () => failedChecks: ["lint"], passed: false, passedChecks: [], + failingOnBase: [], remarksThrough: DateTime.formatIso(snoozedAt), remarkIds: ["review-1"], conflicting: true, diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts index 53a77e178c561..4678dd4750730 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.forgejoComments.test.ts @@ -19,6 +19,7 @@ describe("Forgejo comment watch boundary", () => { failedChecks: [], passed: false, passedChecks: [], + failingOnBase: [], remarksThrough: "2026-10-10T19:52:40.000Z", remarkIds: ["29061"], conflicting: false, diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 4ab9e8b6d67b4..a40fb3cb6df77 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -5,7 +5,11 @@ import type { } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; -import { PULL_REQUEST_WATCH_WAKE_LIMIT, evaluatePullRequestWatch } from "./pullRequestWatch.ts"; +import { + PULL_REQUEST_WATCH_WAKE_LIMIT, + evaluatePullRequestWatch, + pullRequestWatchMessage, +} from "./pullRequestWatch.ts"; const STARTED = "2026-10-02T12:00:00.000Z"; @@ -15,6 +19,7 @@ const watch = (overrides: Partial = {}): ThreadPullReque failedChecks: [], passed: false, passedChecks: [], + failingOnBase: [], remarksThrough: STARTED, remarkIds: [], conflicting: false, @@ -76,6 +81,47 @@ describe("evaluatePullRequestWatch", () => { assert.equal(evaluatePullRequestWatch(test.next, pushed, noRemarks).changes.length, 1); }); + it("waits quietly on a failure the base branch has too, until the base branch passes it", () => { + const red = detail({ checks: [check("lint", "failure"), check("test", "pending")] }); + const quiet = evaluatePullRequestWatch(watch(), red, noRemarks, { + failing: ["lint"], + passing: [], + }); + assert.deepEqual(quiet.changes, []); + assert.deepEqual(quiet.next.failingOnBase, ["lint"]); + + // An unread base branch, or one whose lint is still running, says nothing new. + assert.deepEqual(evaluatePullRequestWatch(quiet.next, red, noRemarks).changes, []); + const running = { failing: [], passing: [] }; + assert.deepEqual(evaluatePullRequestWatch(quiet.next, red, noRemarks, running).changes, []); + + const green = { failing: [], passing: ["lint"] }; + const fixed = evaluatePullRequestWatch(quiet.next, red, noRemarks, green); + assert.deepEqual(fixed.changes, [{ kind: "base-passes", failed: [check("lint", "failure")] }]); + assert.deepEqual(fixed.next.failingOnBase, []); + assert.deepEqual(evaluatePullRequestWatch(fixed.next, red, noRemarks, green).changes, []); + }); + + it("wakes for a new failure next to one the base branch has, and names the shared one", () => { + const red = detail({ checks: [check("lint", "failure"), check("test", "failure")] }); + const report = evaluatePullRequestWatch(watch(), red, noRemarks, { + failing: ["lint"], + passing: ["test"], + }); + assert.deepEqual(report.changes, [ + { kind: "checks-failed", failed: [check("test", "failure")] }, + ]); + const { text } = pullRequestWatchMessage({ + number: 7, + url: "https://github.com/o/r/pull/7", + baseBranch: "main", + headSha: "aaaaaaaaaa", + report, + }); + assert.include(text, "Also failing on main, so not from this change"); + assert.include(text, "does not wake you for them: lint."); + }); + it("reports passed once the required checks pass, whatever the others do", () => { const required = (name: string, status: PullRequestCheck["status"]) => ({ ...check(name, status), diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index c2d7d3f6e47ef..d2e40c2f6bfea 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -1,3 +1,4 @@ +import type { ProviderBranchChecks } from "@t3tools/source-control-core/server/PullRequestProvider"; import type { OrchestrationV2Notification, PullRequestCheck, @@ -16,6 +17,8 @@ const SNIPPET_LENGTH = 200; export type PullRequestWatchChange = | { readonly kind: "checks-failed"; readonly failed: ReadonlyArray } + /** Checks that failed on the base branch too, which it passes now while they still fail here. */ + | { readonly kind: "base-passes"; readonly failed: ReadonlyArray } | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean } | { readonly kind: "remarks"; readonly remarks: ReadonlyArray } | { readonly kind: "conflicting" }; @@ -27,13 +30,15 @@ export interface PullRequestWatchReport { } // "action-required" is a finished check that needs someone, so the agent hears about it. -const isFailedCheck = (check: PullRequestCheck) => +export const isFailedCheck = (check: PullRequestCheck) => check.status === "failure" || check.status === "cancelled" || check.status === "action-required"; export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, detail: Pick, remarks: ReadonlyArray | null, + /** Null where the host cannot read them: every new failure wakes. */ + base: ProviderBranchChecks | null = null, ): PullRequestWatchReport { const changes: Array = []; const headSha = detail.headSha ?? null; @@ -43,11 +48,26 @@ export function evaluatePullRequestWatch( let failedChecks = headMoved ? [] : watch.failedChecks; let passed = headMoved ? false : watch.passed; let passedChecks = headMoved ? [] : watch.passedChecks; + let failingOnBase = headMoved ? [] : watch.failingOnBase; if (detail.checks.length > 0) { const failed = detail.checks.filter(isFailedCheck); - const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); + // A failure the base branch has too is not this change's to fix, so it waits quietly until + // the base branch passes that check. Only a clear answer from the base branch moves it. + const quiet = failed.filter((check) => + failingOnBase.includes(check.name) + ? !base?.passing.includes(check.name) + : !failedChecks.includes(check.name) && base?.failing.includes(check.name) === true, + ); + const newlyFailed = failed.filter( + (check) => !failedChecks.includes(check.name) && !quiet.includes(check), + ); if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); + const basePasses = failed.filter( + (check) => failingOnBase.includes(check.name) && !quiet.includes(check), + ); + if (basePasses.length > 0) changes.push({ kind: "base-passes", failed: basePasses }); failedChecks = failed.map((check) => check.name); + failingOnBase = quiet.map((check) => check.name); const required = detail.checks.filter((check) => check.required === true); const gate = required.length > 0 ? required : detail.checks; @@ -102,6 +122,7 @@ export function evaluatePullRequestWatch( failedChecks, passed, passedChecks, + failingOnBase, remarksThrough, remarkIds, conflicting, @@ -153,6 +174,11 @@ function changeLines( return ` - ${remark.author?.login ?? "someone"}${where}: ${said}${remark.url ? ` ${remark.url}` : ""}`; }), ]; + case "base-passes": + return [ + `- These checks fail here but pass on ${context.baseBranch} now${context.commit}. Update the branch or rerun them:`, + ...listed(change.failed, (check) => ` - ${check.name}${check.url ? ` ${check.url}` : ""}`), + ]; case "conflicting": return [`- The branch now conflicts with ${context.baseBranch}.`]; } @@ -160,6 +186,7 @@ function changeLines( const SUMMARY: Record = { "checks-failed": "checks failed", + "base-passes": "base branch passes failed checks", "checks-passed": "checks passed", remarks: "new comments", conflicting: "merge conflict", @@ -180,13 +207,18 @@ export function pullRequestWatchMessage(input: { const text = [ `Update on pull request #${input.number} (${input.url}), which T3 Code is watching for you:`, ...changes.flatMap((change) => changeLines(change, context)), + ...(input.report.next.failingOnBase.length === 0 + ? [] + : [ + `- Also failing on ${input.baseBranch}, so not from this change, and T3 Code does not wake you for them: ${input.report.next.failingOnBase.join(", ")}.`, + ]), "", 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. 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.", ].join("\n"); const failed = changes.some( - (change) => change.kind === "checks-failed" || change.kind === "conflicting", + (change) => change.kind !== "checks-passed" && change.kind !== "remarks", ); const summary = changes.map((change) => SUMMARY[change.kind]); if (exhausted) summary.push("stopped watching"); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9db0564cced01..e7c6e692b08c2 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2746,6 +2746,91 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("a watch waits quietly on a failure its base branch has too", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-base"); + const projectId = ProjectId.make("pr-watch-base-project"); + yield* seedProject({ + projectId, + title: "Watch base", + workspaceRoot: "/workspace/watch-base", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-base-create"), + threadId, + projectId, + title: "Watch base", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 14 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-base-start"), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/14", source: "agent" }, + }); + const watchOf = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch, + ); + const startedAt = (yield* watchOf)?.startedAt ?? ""; + + let base = { failing: ["lint"], passing: [] as Array }; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + // An unchanged fingerprint: only the wait on the base branch brings the second read. + watchFingerprint: () => Effect.succeed({ status: "failing", remarks: "none" }), + invalidate: () => Effect.void, + detail: () => + Effect.succeed(watchedPullRequestDetail({ projectId, number: 14, at: startedAt })), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + baseChecks: () => Effect.sync(() => base), + }), + ); + const summaries = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + assert.deepEqual(yield* summaries, []); + assert.deepEqual((yield* watchOf)?.failingOnBase, ["lint"]); + + base = { failing: [], passing: ["lint"] }; + yield* reactor.sweep; + assert.deepEqual(yield* summaries, ["#14: base branch passes failed checks"]); + assert.deepEqual((yield* watchOf)?.failingOnBase, []); + + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-base-end"), + threadId, + ...key, + watching: false, + }); + }), + ); + // regression: #21155 watching a pull request again repeated news the thread already got it.effect("watching a dismissed stack layer again keeps what its last watch told", () => Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index f0c2b580064ad..ff0d2a40d8fbb 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -89,6 +89,7 @@ import * as SourceControlRateLimit from "@t3tools/source-control-core/server/Sou import { type ProviderChangeRequest, type ProviderListCursor, + type ProviderBranchChecks, type ProviderChangeRequestWatchFingerprint, type PullRequestProviderApi, PullRequestProviderError, @@ -214,6 +215,10 @@ export class PullRequestService extends Context.Service< readonly watchFingerprint: ( input: PullRequestRef, ) => Effect.Effect; + /** The checks of the pull request's base branch. Null where the host cannot read them. */ + readonly baseChecks: ( + input: PullRequestRef & { readonly branch: string }, + ) => Effect.Effect; readonly activity: ( input: PullRequestRef, ) => Effect.Effect; @@ -518,6 +523,9 @@ function withRateLimitBackoff( ...(api.getChangeRequestStack === undefined ? {} : { getChangeRequestStack: wrap("getChangeRequestStack", api.getChangeRequestStack) }), + ...(api.getBranchChecks === undefined + ? {} + : { getBranchChecks: wrap("getBranchChecks", api.getBranchChecks) }), ...(api.getChangeRequestWatchFingerprint === undefined ? {} : { @@ -2821,6 +2829,23 @@ export const make = Effect.gen(function* () { }, ); + // Uncached: the watch reads it at most once per pull request per pass, and only on failures. + const baseChecks = (input: PullRequestRef & { readonly branch: string }) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.getBranchChecks === undefined + ? Effect.succeed(null) + : project.api + .getBranchChecks({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + branch: input.branch, + }) + .pipe(Effect.mapError(toPullRequestError("baseChecks"))), + ), + ); + const detailCache = yield* Cache.makeWith( (key: string) => { const statsKey = statsCacheKey(key); @@ -3393,6 +3418,7 @@ export const make = Effect.gen(function* () { watchFingerprint: credentialCached((input) => Cache.get(watchFingerprintCache, refCacheKey(input)), ), + baseChecks: credentialCached(baseChecks), activity: credentialCached(activity), preview: credentialCached(preview), threadComments, diff --git a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx index c419beb89d39f..f6cc40d376e2f 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRow.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRow.tsx @@ -87,6 +87,7 @@ export function ThreadDetailsPrRow({ onOpen, onActed, onStopWatching, + failingOnBase, }: { environmentId: EnvironmentId; pr: ThreadPr; @@ -99,6 +100,7 @@ export function ThreadDetailsPrRow({ onOpen: (event: ReactMouseEvent) => void; onActed?: () => void; onStopWatching?: (() => void) | undefined; + failingOnBase?: ReadonlyArray | undefined; }) { const serverConfigs = useServerConfigs(); const supportsPullRequests = @@ -380,6 +382,9 @@ export function ThreadDetailsPrRow({ Watching: the agent wakes when checks finish, someone comments, or the branch conflicts. + {failingOnBase?.length + ? ` Not for checks the base branch fails too: ${failingOnBase.join(", ")}.` + : ""}{" "} Click to stop. diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index 8dfb4623801f4..ac99da7150385 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -56,6 +56,7 @@ function ThreadDetailsPrLinkRow({ openAriaLabel={link.url} onOpen={onOpen} onStopWatching={onStopWatching} + failingOnBase={link.watch?.failingOnBase} {...(onActed ? { onActed } : {})} /> ); @@ -89,7 +90,13 @@ export function ThreadDetailsPrRows({ watching: false, }, }); - const currentRow = ; + const currentRow = ( + + ); const rest = currentLink === null ? [] diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index f52e5b7c43121..7f993dca5ac24 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -199,6 +199,9 @@ function LinkRowImpl({ Watching: the agent wakes when checks finish, someone comments, or the branch conflicts + {link.watch?.failingOnBase.length + ? `. Not for checks the base branch fails too: ${link.watch.failingOnBase.join(", ")}` + : ""} ) : null} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index bd7715c044a3a..56a8b9a110e42 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -285,7 +285,10 @@ the thread is active, the server checks the pull request every two minutes and w check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict. Threads in a project that watch the same pull request share one check. On GitHub, a check first asks whether anything changed and reads the pull request only when it did, which keeps -watching inside GitHub's rate limit. Comments from your own account do not wake it. Watching ends +watching inside GitHub's rate limit. Comments from your own account do not wake it. On GitHub, a +failed check that the base branch's latest finished run also fails does not wake the agent, since +the pull request did not cause it. The agent wakes if the base branch passes that check while it +still fails on the pull request. The eye's tooltip lists these checks. Watching ends when the pull request merges or closes, after 10 wakes in a row that bring only comments, after 8 failed reads in a row, or when you press Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all its watches. Unsettle the thread before starting a new diff --git a/package.json b/package.json index 363a9261163c5..09838de4131c9 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "typecheck": "vp run -r --cache --concurrency-limit 4 typecheck", "tc": "vp run -r --cache --concurrency-limit 4 typecheck", "lint": "vp lint --report-unused-disable-directives", + "ci:failed": "node scripts/ci-failed.ts", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-azure-devops --workspace packages/source-control-bitbucket --workspace packages/source-control-forgejo --workspace packages/source-control-gitcafe --workspace packages/source-control-github --workspace packages/source-control-gitlab --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 6d3bf6df08741..7f9cc668b0da4 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -89,6 +89,13 @@ export const ThreadPullRequestWatch = Schema.Struct({ passedChecks: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), + /** + * Failed checks that the base branch fails too. They do not wake the agent until the base + * branch passes them while they still fail here. + */ + failingOnBase: Schema.Array(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), remarksThrough: IsoDateTime, /** Remarks created exactly at `remarksThrough` that were reported, so a late one still counts. */ remarkIds: Schema.Array(TrimmedNonEmptyString), diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 3bb7a757f7734..f79f6bb83f31d 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -66,6 +66,12 @@ export interface ProviderChangeRequestWatchFingerprint { readonly remarks: string; } +/** Check names by the newest finished result each has on a branch. */ +export interface ProviderBranchChecks { + readonly failing: ReadonlyArray; + readonly passing: ReadonlyArray; +} + export interface PullRequestProviderFailure { readonly reason: PullRequestProviderError["reason"]; readonly retryAt?: number | undefined; @@ -367,6 +373,14 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; + /** + * Null when the host has no such branch. Optional: without it, a watch wakes for every failed + * check, including ones the base branch fails too. + */ + readonly getBranchChecks?: ( + input: ProviderRepositoryRef & { readonly branch: string }, + ) => Effect.Effect; + readonly getChangeRequestStack?: ( input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number }, ) => Effect.Effect; diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index c25699714d147..9bd745ef3a1b7 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -79,6 +79,7 @@ import { toPullRequestRows, toPullRequestSummary, toPullRequestWatchFingerprint, + toBranchChecks, toReviewDismissalsPage, toReviewerCandidates, toReviewThreadCommentsPage, @@ -107,6 +108,7 @@ import { IMMEDIATELY_MERGEABLE, } from "./gitHubPullRequestJson.ts"; import type { + ProviderBranchChecks, ProviderChangeRequestSummary, ProviderListCursor, } from "@t3tools/source-control-core/server/PullRequestProvider"; @@ -496,6 +498,14 @@ export class GitHubPullRequestApi extends Context.Service< readonly revalidateChecks: Effect.Success; + /** Null for a branch GitHub did not find. */ + readonly getBranchChecks: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly branch: string; + }) => Effect.Effect; + readonly getPullRequestDetail: (input: { readonly cwd: string; readonly repository: string; @@ -1995,6 +2005,12 @@ export const make = Effect.gen(function* () { getPullRequestWatchFingerprint, getPullRequestDetail, + getBranchChecks: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return client + .BranchCheckHistory({ owner, name, ref: `refs/heads/${input.branch}` }) + .pipe(graphql({ ...input, operation: "getBranchChecks" }), Effect.map(toBranchChecks)); + }, getPullRequestPreview: (input) => { const { owner, name } = parseRepositorySelector(input.repository); const operation = "getPullRequestPreview"; diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index dd710dffcfd66..7f5e8148aea91 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -346,6 +346,9 @@ export const make = Effect.gen(function* () { .getPullRequestWatchFingerprint(input) .pipe(Effect.mapError(fail("getChangeRequestWatchFingerprint"))), + getBranchChecks: (input) => + cli.getBranchChecks(input).pipe(Effect.mapError(fail("getBranchChecks"))), + getChangeRequestPreview: (input) => cli.getPullRequestPreview(input).pipe(Effect.mapError(fail("getChangeRequestPreview"))), diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index 5a408c98044ba..df5f432aae024 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -6,6 +6,7 @@ import { buildReviewSubmission, buildReviewerRequest, pullRequestLookup, + toBranchChecks, toPullRequestWatchFingerprint, decodePullRequestDetailJson, decodePullRequestFilesJson, @@ -25,6 +26,7 @@ import { } from "./gitHubPullRequestJson.ts"; import { PullRequestWatchFingerprint } from "./pullRequestBatches.graphql.ts"; import { + BranchCheckHistory, LabelCandidates, PullRequestActivity, PullRequestCore, @@ -1670,3 +1672,41 @@ describe("pull request watch fingerprints", () => { expect(replied.remarks).not.toBe(before.remarks); }); }); + +describe("branch check history", () => { + it("takes each check's newest finished run, past commits CI did not run on", () => { + const run = (name: string, status: string, conclusion: string | null) => ({ + __typename: "CheckRun", + name, + status, + conclusion, + }); + const commit = (contexts: ReadonlyArray | null) => ({ + statusCheckRollup: contexts === null ? null : { contexts: { nodes: contexts } }, + }); + const result = Schema.decodeUnknownSync(BranchCheckHistory.result)({ + repository: { + ref: { + target: { + __typename: "Commit", + history: { + nodes: [ + commit([run("lint", "IN_PROGRESS", null), run("test", "COMPLETED", "SUCCESS")]), + commit(null), + commit([ + run("lint", "COMPLETED", "FAILURE"), + run("test", "COMPLETED", "FAILURE"), + run("deploy", "COMPLETED", "CANCELLED"), + ]), + ], + }, + }, + }, + }, + }); + expect(toBranchChecks(result)).toEqual({ failing: ["lint"], passing: ["test"] }); + expect( + toBranchChecks(Schema.decodeUnknownSync(BranchCheckHistory.result)({ repository: null })), + ).toBeNull(); + }); +}); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 318ba41468c3e..43ef62718bffb 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -36,6 +36,7 @@ import type { import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks"; +import type { ProviderBranchChecks } from "@t3tools/source-control-core/server/PullRequestProvider"; import type * as GitHubSchema from "../../schema/github.graphql.ts"; import type { @@ -50,6 +51,7 @@ import { } from "./pullRequestListings.graphql.ts"; import type { ActorAvatars, + BranchCheckHistory, CheckContext, LabelCandidates, PullRequestActivity, @@ -1266,6 +1268,27 @@ export function toPullRequestCheckContextsPage(result: PullRequestCheckContexts. }; } +/** Null for a branch GitHub did not find. A check's newest finished run decides its side. */ +export function toBranchChecks(result: BranchCheckHistory.Result): ProviderBranchChecks | null { + const target = result.repository?.ref?.target; + if (target?.__typename !== "Commit") return null; + const verdicts = new Map(); + for (const commit of nodesOf(target.history)) { + const contexts = nodesOf(commit.statusCheckRollup?.contexts).filter( + (context) => context.__typename === "CheckRun" || context.__typename === "StatusContext", + ); + for (const check of toChecks(contexts)) { + if (verdicts.has(check.name)) continue; + if (check.status === "failure" || check.status === "success") { + verdicts.set(check.name, check.status === "failure"); + } + } + } + const named = (failing: boolean) => + [...verdicts].flatMap(([name, failed]) => (failed === failing ? [name] : [])); + return { failing: named(true), passing: named(false) }; +} + export function pullRequestChecksFromContexts(contexts: ReadonlyArray): { readonly checks: ReadonlyArray; readonly checksState: PullRequestChecksState | null; diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql b/packages/source-control-github/src/server/pullRequestReads.graphql index 7281af8c898aa..63af5c1e2d5fa 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql +++ b/packages/source-control-github/src/server/pullRequestReads.graphql @@ -559,3 +559,37 @@ query PullRequestSourceBranch($repositoryId: ID!, $ref: String!) { } } } + +# The checks of a branch's newest commits, newest first, so a check still running on the head is +# answered by the last commit that finished it. CI often runs only on the tip of each push, so +# this reads past the commits a push brings. A watch compares a pull request's failed checks with +# these, so failures its base branch already has do not wake its agent. +query BranchCheckHistory($owner: String!, $name: String!, $ref: String!) { + repository(owner: $owner, name: $name) { + ref(qualifiedName: $ref) { + target { + ... on Commit { + history(first: 20) { + nodes { + statusCheckRollup { + contexts(first: 100) { + nodes { + ... on StatusContext { + context + state + } + ... on CheckRun { + name + status + conclusion + } + } + } + } + } + } + } + } + } + } +} diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index fd0fef547ea9e..956cb9cdaca6f 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -4,7 +4,7 @@ import * as Schema from "effect/Schema" import * as Shared from "../../schema/github.graphql.ts" /** Represents an individual commit status context */ -export const StatusContext = Schema.Struct({ +export const CheckContextStatusContext = Schema.Struct({ __typename: Schema.Literal("StatusContext"), /** The name of this status context. */ context: Schema.String, @@ -19,7 +19,7 @@ export const StatusContext = Schema.Struct({ /** Whether this is required to pass before merging for a specific pull request. */ isRequired: Schema.optionalKey(Schema.Boolean) }) -export interface StatusContext extends Schema.Struct.Type {} +export interface CheckContextStatusContext extends Schema.Struct.Type {} /** A workflow contains meta information about an Actions workflow file. */ export const Workflow = Schema.Struct({ @@ -43,7 +43,7 @@ export const CheckSuite = Schema.Struct({ export interface CheckSuite extends Schema.Struct.Type {} /** A check run. */ -export const CheckRun = Schema.Struct({ +export const CheckContextCheckRun = Schema.Struct({ __typename: Schema.Literal("CheckRun"), /** The name of the check for this check run. */ name: Schema.String, @@ -62,7 +62,7 @@ export const CheckRun = Schema.Struct({ /** The check suite that this run is a part of. */ checkSuite: CheckSuite }) -export interface CheckRun extends Schema.Struct.Type {} +export interface CheckContextCheckRun extends Schema.Struct.Type {} /** Types that can be inside a StatusCheckRollup context. */ export const StatusCheckRollupContextOther = Schema.Struct({ @@ -76,8 +76,8 @@ export interface StatusCheckRollupContextOther extends Schema.Struct.Type {} /** Represents a Git reference. */ -export const RefRef = Schema.Struct({ +export const NodeRefRef = Schema.Struct({ /** The object the ref points to. Returns null when object does not exist. */ target: Schema.NullOr(GitObject) }) -export interface RefRef extends Schema.Struct.Type {} +export interface NodeRefRef extends Schema.Struct.Type {} /** A repository contains the content for a project. */ export const NodeRepository = Schema.Struct({ @@ -1547,7 +1547,7 @@ export const NodeRepository = Schema.Struct({ /** The Ref associated with the repository's default branch. */ defaultBranchRef: Schema.NullOr(DefaultBranchRefRef), /** Fetch a given ref from the repository */ - ref: Schema.NullOr(RefRef) + ref: Schema.NullOr(NodeRefRef) }) export interface NodeRepository extends Schema.Struct.Type {} @@ -1586,5 +1586,118 @@ export declare namespace PullRequestSourceBranch { export type Result = typeof PullRequestSourceBranch.result.Type } +/** Represents an individual commit status context */ +export const NodesStatusContext = Schema.Struct({ + __typename: Schema.Literal("StatusContext"), + /** The name of this status context. */ + context: Schema.String, + /** The state of this status context. */ + state: GraphQL.enumLiterals(Shared.StatusState.literals) +}) +export interface NodesStatusContext extends Schema.Struct.Type {} + +/** A check run. */ +export const NodesCheckRun = Schema.Struct({ + __typename: Schema.Literal("CheckRun"), + /** The name of the check for this check run. */ + name: Schema.String, + /** The current status of the check run. */ + status: GraphQL.enumLiterals(Shared.CheckStatusState.literals), + /** The conclusion of the check run. */ + conclusion: Schema.NullOr(GraphQL.enumLiterals(Shared.CheckConclusionState.literals)) +}) +export interface NodesCheckRun extends Schema.Struct.Type {} + +/** The connection type for StatusCheckRollupContext. */ +export const NodesStatusCheckRollupContextsStatusCheckRollupContextConnection = Schema.Struct({ + /** A list of nodes. */ + nodes: Schema.NullOr(Schema.Array(Schema.NullOr(Schema.Union([ + NodesStatusContext, + NodesCheckRun, + StatusCheckRollupContextOther + ])))) +}) +export interface NodesStatusCheckRollupContextsStatusCheckRollupContextConnection extends Schema.Struct.Type {} + +/** Represents the rollup for both the check runs and status for a commit. */ +export const NodesStatusCheckRollupStatusCheckRollup = Schema.Struct({ + /** A list of status contexts and check runs for this commit. */ + contexts: NodesStatusCheckRollupContextsStatusCheckRollupContextConnection +}) +export interface NodesStatusCheckRollupStatusCheckRollup extends Schema.Struct.Type {} + +/** Represents a Git commit. */ +export const NodesCommit = Schema.Struct({ + /** Check and Status rollup information for this commit. */ + statusCheckRollup: Schema.NullOr(NodesStatusCheckRollupStatusCheckRollup) +}) +export interface NodesCommit extends Schema.Struct.Type {} + +/** The connection type for Commit. */ +export const CommitHistoryConnection = Schema.Struct({ + /** A list of nodes. */ + nodes: Schema.NullOr(Schema.Array(Schema.NullOr(NodesCommit))) +}) +export interface CommitHistoryConnection extends Schema.Struct.Type {} + +/** Represents a Git commit. */ +export const TargetCommit = Schema.Struct({ + __typename: Schema.Literal("Commit"), + /** The linear commit history starting from (and including) this commit, in the same order as `git log`. */ + history: CommitHistoryConnection +}) +export interface TargetCommit extends Schema.Struct.Type {} + +/** Represents a Git object. */ +export const GitObjectOther = Schema.Struct({ + __typename: GraphQL.otherTypename()(["Commit"]) +}) +export interface GitObjectOther extends Schema.Struct.Type {} + +/** Represents a Git reference. */ +export const RepositoryRefRef = Schema.Struct({ + /** The object the ref points to. Returns null when object does not exist. */ + target: Schema.NullOr(Schema.Union([ + TargetCommit, + GitObjectOther + ])) +}) +export interface RepositoryRefRef extends Schema.Struct.Type {} + +/** A repository contains the content for a project. */ +export const BranchCheckHistoryRepositoryRepository = Schema.Struct({ + /** Fetch a given ref from the repository */ + ref: Schema.NullOr(RepositoryRefRef) +}) +export interface BranchCheckHistoryRepositoryRepository extends Schema.Struct.Type {} + +/** `query BranchCheckHistory` */ +export const BranchCheckHistory = GraphQL.query("BranchCheckHistory", { + document: { + variables: [ + { name: "owner", body: "String!" }, + { name: "name", body: "String!" }, + { name: "ref", body: "String!" } + ], + selections: [ + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){ref(qualifiedName:$", "ref", "){target{__typename...on Commit{history(first:20){nodes{statusCheckRollup{contexts(first:100){nodes{__typename...on StatusContext{context state}...on CheckRun{name status conclusion}}}}}}}}}}"] } + ], + fragments: [] + }, + variables: { + owner: Schema.String, + name: Schema.String, + ref: Schema.String + }, + result: Schema.Struct({ + /** Lookup a given repository by the owner and repository name. */ + repository: Schema.NullOr(BranchCheckHistoryRepositoryRepository) + }) +}) +export declare namespace BranchCheckHistory { + export type Variables = typeof BranchCheckHistory.variables.Type + export type Result = typeof BranchCheckHistory.result.Type +} + /** Every operation in pullRequestReads.graphql. */ -export const PullRequestReadsGraphQLGroup = GraphQLGroup.make(RoutingIdentityViewer, ActorAvatars, PullRequestCore, PullRequestCheckContexts, PullRequestPreview, PullRequestActivity, PullRequestHeads, PullRequestNodeId, PullRequestSubject, ViewerPermissions, ReviewerCandidates, LabelCandidates, PullRequestFilesViewed, PullRequestFileStats, PullRequestMergeMessage, PullRequestActionState, PullRequestBranchToDelete, PullRequestSourceBranch) +export const PullRequestReadsGraphQLGroup = GraphQLGroup.make(RoutingIdentityViewer, ActorAvatars, PullRequestCore, PullRequestCheckContexts, PullRequestPreview, PullRequestActivity, PullRequestHeads, PullRequestNodeId, PullRequestSubject, ViewerPermissions, ReviewerCandidates, LabelCandidates, PullRequestFilesViewed, PullRequestFileStats, PullRequestMergeMessage, PullRequestActionState, PullRequestBranchToDelete, PullRequestSourceBranch, BranchCheckHistory) diff --git a/scripts/ci-failed.ts b/scripts/ci-failed.ts new file mode 100644 index 0000000000000..7fef53cd589ff --- /dev/null +++ b/scripts/ci-failed.ts @@ -0,0 +1,175 @@ +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off - a small `gh` wrapper that prints for agents; plain Node keeps it short. +/** + * Prints the failed CI jobs of a pull request, each tagged [new] or [also on main], with a short + * summary: failing tests, lint errors (not warnings), and TS errors as file:line. + * + * vp run ci:failed the current branch's pull request + * vp run ci:failed 123 pull request #123 + * vp run ci:failed --run a specific CI run + * vp run ci:failed --main-run compare against a specific main run, not the latest one + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeUtil from "node:util"; + +const WORKFLOW = "ci.yml"; +const MAX_ITEMS = 12; + +const gh = (command: string) => + NodeChildProcess.execFileSync("gh", command.split(" ").filter(Boolean), { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + }); +const ghJson = (command: string): T => JSON.parse(gh(command)) as T; + +interface Run { + readonly databaseId: number; + readonly status: string; + readonly conclusion: string; + readonly headSha: string; +} +interface Job { + readonly databaseId: number; + readonly name: string; + readonly conclusion: string; + readonly url: string; +} + +const RUN_FIELDS = "databaseId,status,conclusion,headSha"; +const isFailed = (job: Job) => job.conclusion === "failure" || job.conclusion === "timed_out"; + +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); +const clean = (line: string) => + line + .replace(ANSI, "") + .replace(/^\d{4}-\d\d-\d\dT[\d:.]+Z ?/, "") + .trimEnd(); + +/** The lines an agent needs from a job log. Falls back to the end of the failed step. */ +export function summarize(log: string): Array { + const lines = log.split("\n").map(clean); + const items = new Set(); + for (const [index, line] of lines.entries()) { + const test = /^\s*FAIL\s+(.*)$/.exec(line)?.[1]; + if (test !== undefined) { + // Drop the project label ("browser (chromium)", "|server|") before the test file. + const at = test.search(/\S+\.(test|spec)\.[cm]?[jt]sx?\b/); + items.add( + (at === -1 ? test : test.slice(at)) + .replace(/\s+\[ .* \]$/, "") + .replace(/\s+\d+(\.\d+)?m?s$/, "") + .replaceAll(/\s+/g, " "), + ); + continue; + } + const ts = /(\S+\.[cm]?tsx?)\((\d+),\d+\): error (TS\d+): (.*)$/.exec(line); + if (ts !== null) { + items.add(`${ts[1]}:${ts[2]} ${ts[3]} ${ts[4]!.slice(0, 100)}`); + continue; + } + // oxlint: "× rule(name): message", then "╭─[file:line:col]" a line or two later. "⚠" is a warning. + const lint = /^\s*× (\S+?\(\S+?\))/.exec(line); + if (lint !== null) { + const where = lines + .slice(index + 1, index + 6) + .map((next) => /╭─\[(.+?):(\d+):\d+\]/.exec(next)) + .find((match) => match !== null); + items.add(where ? `${where[1]}:${where[2]} ${lint[1]}` : `lint ${lint[1]}`); + } + } + if (items.size > 0) return [...items]; + // Nothing recognized (knip, a crash, an aggregate job): the step's last useful lines. + const found = lines.findIndex((line) => line.startsWith("##[error]Process completed")); + const end = found === -1 ? lines.length : found; + const start = lines.findLastIndex((line, index) => index < end && line === "##[endgroup]"); + return lines + .slice(start + 1, end) + .filter( + (line) => + !/^\s*(---)?$/.test(line) && + !/^(##\[|\$ |vp run: )/.test(line) && + // JSON dumps and the passing jobs an aggregate job lists. + !/^\s*["{}[\]]/.test(line) && + !/: (success|skipped)$/.test(line), + ) + .slice(-6) + .map((line) => line.trim().slice(0, 160)); +} + +// The pull request may move the same error to another line. +const withoutLine = (item: string) => item.replace(/:\d+\b/g, ""); + +const jobsOf = (runId: number) => + ghJson<{ jobs: Array }>(`run view ${runId} --json jobs`).jobs; +const jobLog = (job: Job) => + gh(`api --allow-escape-sequences repos/{owner}/{repo}/actions/jobs/${job.databaseId}/logs`); + +function main() { + const { values, positionals } = NodeUtil.parseArgs({ + // `vp run ci:failed -- --run 1` passes the `--` through. + args: process.argv.slice(2).filter((arg) => arg !== "--"), + allowPositionals: true, + options: { run: { type: "string" }, "main-run": { type: "string" } }, + }); + let run: Run; + if (values.run !== undefined) { + run = ghJson(`run view ${values.run} --json ${RUN_FIELDS}`); + } else { + const pr = ghJson<{ number: number; headRefOid: string }>( + `pr view ${positionals[0] ?? ""} --json number,headRefOid`, + ); + const runs = ghJson>( + `run list --workflow ${WORKFLOW} --commit ${pr.headRefOid} --limit 1 --json ${RUN_FIELDS}`, + ); + if (runs[0] === undefined) { + console.log(`No ${WORKFLOW} run for #${pr.number} at ${pr.headRefOid.slice(0, 7)} yet.`); + return; + } + run = runs[0]; + } + // A cancelled main run was superseded, so it says nothing about main. + const mainRun = + values["main-run"] === undefined + ? ghJson>( + `run list --branch main --workflow ${WORKFLOW} --event push --status completed --limit 10 --json ${RUN_FIELDS}`, + ).find((candidate) => candidate.conclusion !== "cancelled") + : ghJson(`run view ${values["main-run"]} --json ${RUN_FIELDS}`); + + const failed = jobsOf(run.databaseId).filter(isFailed); + const mainFailed = new Map( + (mainRun === undefined ? [] : jobsOf(mainRun.databaseId).filter(isFailed)).map((job) => [ + job.name, + job, + ]), + ); + console.log( + `Run ${run.databaseId} (${run.headSha.slice(0, 7)}, ${run.status === "completed" ? run.conclusion : run.status})` + + (mainRun === undefined + ? ", no completed main run to compare." + : ` vs main run ${mainRun.databaseId} (${mainRun.headSha.slice(0, 7)}, ${mainRun.conclusion}).`), + ); + if (failed.length === 0) return console.log("No failed jobs."); + + let fresh = 0; + for (const job of failed) { + const items = summarize(jobLog(job)); + const onMain = mainFailed.get(job.name); + // A job failing on main may still fail here for a new reason: compare what each one reports. + const mainItems = new Set( + onMain === undefined ? [] : summarize(jobLog(onMain)).map(withoutLine), + ); + const newItems = items.filter((item) => !mainItems.has(withoutLine(item))); + const isNew = onMain === undefined || newItems.length > 0; + if (isNew) fresh += 1; + const shown = isNew ? newItems : items; + const note = + onMain !== undefined && isNew + ? ` (job also fails on main; ${newItems.length} of ${items.length} items are new)` + : ""; + console.log(`\n[${isNew ? "new" : "also on main"}] ${job.name}${note} ${job.url}`); + for (const item of shown.slice(0, MAX_ITEMS)) console.log(` ${item}`); + if (shown.length > MAX_ITEMS) console.log(` ... and ${shown.length - MAX_ITEMS} more`); + } + console.log(`\n${failed.length} failed: ${fresh} new, ${failed.length - fresh} also on main.`); +} + +if (import.meta.main) main(); From 56696ea4214dce88599979084b06abf7e30bd924 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 21 Oct 2026 01:44:36 -0700 Subject: [PATCH 2/5] fix: a running rerun no longer hides a base check's finished result Also adds failingOnBase to the last watch fixtures and reads tsc's pretty error format in ci:failed. Co-Authored-By: Claude Opus 5.5 --- .../src/operations/commands.test.ts | 1 + .../orchestrationV2PendingBackgroundWork.test.ts | 1 + .../src/server/gitHubPullRequestJson.test.ts | 7 +++++-- .../src/server/gitHubPullRequestJson.ts | 16 ++++++++-------- scripts/ci-failed.ts | 5 +++-- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index d559e6216c339..fe4fbf5b0cb52 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -358,6 +358,7 @@ describe("V2 environment commands", () => { failedChecks: [], passed: false, passedChecks: [], + failingOnBase: [], remarksThrough: "2026-10-05T00:00:00.000Z", remarkIds: [], conflicting: false, diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 934d7f8175776..3f94b81872ecc 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -354,6 +354,7 @@ describe("derivePendingBackgroundWork kinds", () => { failedChecks: [], passed: false, passedChecks: [], + failingOnBase: [], remarksThrough: "2026-10-05T00:00:00.000Z", remarkIds: [], conflicting: false, diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index df5f432aae024..c32f8ea74088f 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -1674,7 +1674,7 @@ describe("pull request watch fingerprints", () => { }); describe("branch check history", () => { - it("takes each check's newest finished run, past commits CI did not run on", () => { + it("takes each check's newest finished run, past reruns and commits CI did not run on", () => { const run = (name: string, status: string, conclusion: string | null) => ({ __typename: "CheckRun", name, @@ -1693,9 +1693,12 @@ describe("branch check history", () => { nodes: [ commit([run("lint", "IN_PROGRESS", null), run("test", "COMPLETED", "SUCCESS")]), commit(null), + // A rerun still going must not hide the run that finished on the same commit. + commit([run("build", "IN_PROGRESS", null), run("build", "COMPLETED", "SUCCESS")]), commit([ run("lint", "COMPLETED", "FAILURE"), run("test", "COMPLETED", "FAILURE"), + run("build", "COMPLETED", "FAILURE"), run("deploy", "COMPLETED", "CANCELLED"), ]), ], @@ -1704,7 +1707,7 @@ describe("branch check history", () => { }, }, }); - expect(toBranchChecks(result)).toEqual({ failing: ["lint"], passing: ["test"] }); + expect(toBranchChecks(result)).toEqual({ failing: ["lint"], passing: ["test", "build"] }); expect( toBranchChecks(Schema.decodeUnknownSync(BranchCheckHistory.result)({ repository: null })), ).toBeNull(); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 43ef62718bffb..e0dc81de05464 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -1274,14 +1274,14 @@ export function toBranchChecks(result: BranchCheckHistory.Result): ProviderBranc if (target?.__typename !== "Commit") return null; const verdicts = new Map(); for (const commit of nodesOf(target.history)) { - const contexts = nodesOf(commit.statusCheckRollup?.contexts).filter( - (context) => context.__typename === "CheckRun" || context.__typename === "StatusContext", - ); - for (const check of toChecks(contexts)) { - if (verdicts.has(check.name)) continue; - if (check.status === "failure" || check.status === "success") { - verdicts.set(check.name, check.status === "failure"); - } + // Only finished runs answer, so a rerun still going on a commit cannot hide its last result. + const finished = nodesOf(commit.statusCheckRollup?.contexts) + .filter( + (context) => context.__typename === "CheckRun" || context.__typename === "StatusContext", + ) + .filter((context) => ["failure", "success"].includes(toCheckStatus(context))); + for (const check of toChecks(finished)) { + if (!verdicts.has(check.name)) verdicts.set(check.name, check.status === "failure"); } } const named = (failing: boolean) => diff --git a/scripts/ci-failed.ts b/scripts/ci-failed.ts index 7fef53cd589ff..4288619562405 100644 --- a/scripts/ci-failed.ts +++ b/scripts/ci-failed.ts @@ -61,9 +61,10 @@ export function summarize(log: string): Array { ); continue; } - const ts = /(\S+\.[cm]?tsx?)\((\d+),\d+\): error (TS\d+): (.*)$/.exec(line); + // tsc prints "file(line,col): error" plain and "file:line:col - error" when pretty. + const ts = /(\S+\.[cm]?tsx?)(?:\((\d+),\d+\):|:(\d+):\d+ -) error (TS\d+): (.*)$/.exec(line); if (ts !== null) { - items.add(`${ts[1]}:${ts[2]} ${ts[3]} ${ts[4]!.slice(0, 100)}`); + items.add(`${ts[1]}:${ts[2] ?? ts[3]} ${ts[4]} ${ts[5]!.slice(0, 100)}`); continue; } // oxlint: "× rule(name): message", then "╭─[file:line:col]" a line or two later. "⚠" is a warning. From a7f486af90b4d7af680aa23711660509221ce784 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 21 Oct 2026 01:48:02 -0700 Subject: [PATCH 3/5] fix(github): base branch checks keep their workflow-qualified names Co-Authored-By: Claude Opus 5.5 --- .../src/server/gitHubPullRequestJson.test.ts | 11 +++++++++-- .../src/server/gitHubPullRequestJson.ts | 8 +++++++- .../src/server/pullRequestReads.graphql | 7 +++++++ .../src/server/pullRequestReads.graphql.ts | 6 ++++-- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index c32f8ea74088f..9b8e9d6f32270 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -1675,11 +1675,12 @@ describe("pull request watch fingerprints", () => { describe("branch check history", () => { it("takes each check's newest finished run, past reruns and commits CI did not run on", () => { - const run = (name: string, status: string, conclusion: string | null) => ({ + const run = (name: string, status: string, conclusion: string | null, workflow = "CI") => ({ __typename: "CheckRun", name, status, conclusion, + checkSuite: { workflowRun: { workflow: { name: workflow } } }, }); const commit = (contexts: ReadonlyArray | null) => ({ statusCheckRollup: contexts === null ? null : { contexts: { nodes: contexts } }, @@ -1700,6 +1701,9 @@ describe("branch check history", () => { run("test", "COMPLETED", "FAILURE"), run("build", "COMPLETED", "FAILURE"), run("deploy", "COMPLETED", "CANCELLED"), + // Two workflows' "pack" jobs keep the names a pull request's checks give them. + run("pack", "COMPLETED", "FAILURE"), + run("pack", "COMPLETED", "SUCCESS", "Release"), ]), ], }, @@ -1707,7 +1711,10 @@ describe("branch check history", () => { }, }, }); - expect(toBranchChecks(result)).toEqual({ failing: ["lint"], passing: ["test", "build"] }); + expect(toBranchChecks(result)).toEqual({ + failing: ["lint", "CI / pack"], + passing: ["test", "build", "Release / pack"], + }); expect( toBranchChecks(Schema.decodeUnknownSync(BranchCheckHistory.result)({ repository: null })), ).toBeNull(); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index e0dc81de05464..654abbff549a2 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -1279,7 +1279,13 @@ export function toBranchChecks(result: BranchCheckHistory.Result): ProviderBranc .filter( (context) => context.__typename === "CheckRun" || context.__typename === "StatusContext", ) - .filter((context) => ["failure", "success"].includes(toCheckStatus(context))); + .filter((context) => ["failure", "success"].includes(toCheckStatus(context))) + // Names a job "Workflow / job" where two workflows share it, as a pull request's checks do. + .map((context) => + context.__typename === "CheckRun" + ? { ...context, workflowName: context.checkSuite.workflowRun?.workflow.name ?? null } + : context, + ); for (const check of toChecks(finished)) { if (!verdicts.has(check.name)) verdicts.set(check.name, check.status === "failure"); } diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql b/packages/source-control-github/src/server/pullRequestReads.graphql index 63af5c1e2d5fa..2aaada3fc85e2 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql +++ b/packages/source-control-github/src/server/pullRequestReads.graphql @@ -582,6 +582,13 @@ query BranchCheckHistory($owner: String!, $name: String!, $ref: String!) { name status conclusion + checkSuite { + workflowRun { + workflow { + name + } + } + } } } } diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index 956cb9cdaca6f..c49d25b46a789 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -1604,7 +1604,9 @@ export const NodesCheckRun = Schema.Struct({ /** The current status of the check run. */ status: GraphQL.enumLiterals(Shared.CheckStatusState.literals), /** The conclusion of the check run. */ - conclusion: Schema.NullOr(GraphQL.enumLiterals(Shared.CheckConclusionState.literals)) + conclusion: Schema.NullOr(GraphQL.enumLiterals(Shared.CheckConclusionState.literals)), + /** The check suite that this run is a part of. */ + checkSuite: CheckSuite }) export interface NodesCheckRun extends Schema.Struct.Type {} @@ -1680,7 +1682,7 @@ export const BranchCheckHistory = GraphQL.query("BranchCheckHistory", { { name: "ref", body: "String!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){ref(qualifiedName:$", "ref", "){target{__typename...on Commit{history(first:20){nodes{statusCheckRollup{contexts(first:100){nodes{__typename...on StatusContext{context state}...on CheckRun{name status conclusion}}}}}}}}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){ref(qualifiedName:$", "ref", "){target{__typename...on Commit{history(first:20){nodes{statusCheckRollup{contexts(first:100){nodes{__typename...on StatusContext{context state}...on CheckRun{name status conclusion checkSuite{workflowRun{workflow{name}}}}}}}}}}}}}"] } ], fragments: [] }, From 415397fd21895d1649739222f4f6dae11a813f63 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 21 Oct 2026 01:52:01 -0700 Subject: [PATCH 4/5] fix(github): base branch checks keep the newest of two finished runs Co-Authored-By: Claude Opus 5.5 --- .../src/server/gitHubPullRequestJson.test.ts | 17 +++++++++++++++-- .../src/server/pullRequestReads.graphql | 2 ++ .../src/server/pullRequestReads.graphql.ts | 6 +++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index 9b8e9d6f32270..fe4482552ffea 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -1675,11 +1675,19 @@ describe("pull request watch fingerprints", () => { describe("branch check history", () => { it("takes each check's newest finished run, past reruns and commits CI did not run on", () => { - const run = (name: string, status: string, conclusion: string | null, workflow = "CI") => ({ + const run = ( + name: string, + status: string, + conclusion: string | null, + workflow = "CI", + completedAt: string | null = null, + ) => ({ __typename: "CheckRun", name, status, conclusion, + startedAt: null, + completedAt, checkSuite: { workflowRun: { workflow: { name: workflow } } }, }); const commit = (contexts: ReadonlyArray | null) => ({ @@ -1696,6 +1704,11 @@ describe("branch check history", () => { commit(null), // A rerun still going must not hide the run that finished on the same commit. commit([run("build", "IN_PROGRESS", null), run("build", "COMPLETED", "SUCCESS")]), + // Of two finished runs on one commit, the later one answers, whatever the order. + commit([ + run("e2e", "COMPLETED", "SUCCESS", "CI", "2026-10-21T02:00:00Z"), + run("e2e", "COMPLETED", "FAILURE", "CI", "2026-10-21T01:00:00Z"), + ]), commit([ run("lint", "COMPLETED", "FAILURE"), run("test", "COMPLETED", "FAILURE"), @@ -1713,7 +1726,7 @@ describe("branch check history", () => { }); expect(toBranchChecks(result)).toEqual({ failing: ["lint", "CI / pack"], - passing: ["test", "build", "Release / pack"], + passing: ["test", "build", "e2e", "Release / pack"], }); expect( toBranchChecks(Schema.decodeUnknownSync(BranchCheckHistory.result)({ repository: null })), diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql b/packages/source-control-github/src/server/pullRequestReads.graphql index 2aaada3fc85e2..0eeb43d17f6b7 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql +++ b/packages/source-control-github/src/server/pullRequestReads.graphql @@ -582,6 +582,8 @@ query BranchCheckHistory($owner: String!, $name: String!, $ref: String!) { name status conclusion + startedAt + completedAt checkSuite { workflowRun { workflow { diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index c49d25b46a789..578e6a3e3b118 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -1605,6 +1605,10 @@ export const NodesCheckRun = Schema.Struct({ status: GraphQL.enumLiterals(Shared.CheckStatusState.literals), /** The conclusion of the check run. */ conclusion: Schema.NullOr(GraphQL.enumLiterals(Shared.CheckConclusionState.literals)), + /** Identifies the date and time when the check run was started. */ + startedAt: Schema.NullOr(Shared.DateTime), + /** Identifies the date and time when the check run was completed. */ + completedAt: Schema.NullOr(Shared.DateTime), /** The check suite that this run is a part of. */ checkSuite: CheckSuite }) @@ -1682,7 +1686,7 @@ export const BranchCheckHistory = GraphQL.query("BranchCheckHistory", { { name: "ref", body: "String!" } ], selections: [ - { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){ref(qualifiedName:$", "ref", "){target{__typename...on Commit{history(first:20){nodes{statusCheckRollup{contexts(first:100){nodes{__typename...on StatusContext{context state}...on CheckRun{name status conclusion checkSuite{workflowRun{workflow{name}}}}}}}}}}}}}"] } + { name: "repository", body: ["(owner:$", "owner", " name:$", "name", "){ref(qualifiedName:$", "ref", "){target{__typename...on Commit{history(first:20){nodes{statusCheckRollup{contexts(first:100){nodes{__typename...on StatusContext{context state}...on CheckRun{name status conclusion startedAt completedAt checkSuite{workflowRun{workflow{name}}}}}}}}}}}}}"] } ], fragments: [] }, From eb8e758a329a9376d379e5e300d152c382d3416c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 21 Oct 2026 01:54:03 -0700 Subject: [PATCH 5/5] fix(github): base branch checks stop at a commit with a cut-off check page Co-Authored-By: Claude Opus 5.5 --- .../src/server/gitHubPullRequestJson.test.ts | 23 +++++++++++++++++-- .../src/server/gitHubPullRequestJson.ts | 2 ++ .../src/server/pullRequestReads.graphql | 3 +++ .../src/server/pullRequestReads.graphql.ts | 4 +++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index fe4482552ffea..00a4284d337a0 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -1690,8 +1690,9 @@ describe("branch check history", () => { completedAt, checkSuite: { workflowRun: { workflow: { name: workflow } } }, }); - const commit = (contexts: ReadonlyArray | null) => ({ - statusCheckRollup: contexts === null ? null : { contexts: { nodes: contexts } }, + const commit = (contexts: ReadonlyArray | null, hasNextPage = false) => ({ + statusCheckRollup: + contexts === null ? null : { contexts: { pageInfo: { hasNextPage }, nodes: contexts } }, }); const result = Schema.decodeUnknownSync(BranchCheckHistory.result)({ repository: { @@ -1731,5 +1732,23 @@ describe("branch check history", () => { expect( toBranchChecks(Schema.decodeUnknownSync(BranchCheckHistory.result)({ repository: null })), ).toBeNull(); + + // A commit with more checks than one page ends the walk: "deep" may be on its next page. + const cut = Schema.decodeUnknownSync(BranchCheckHistory.result)({ + repository: { + ref: { + target: { + __typename: "Commit", + history: { + nodes: [ + commit([run("lint", "COMPLETED", "SUCCESS")], true), + commit([run("deep", "COMPLETED", "FAILURE")]), + ], + }, + }, + }, + }, + }); + expect(toBranchChecks(cut)).toEqual({ failing: [], passing: ["lint"] }); }); }); diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.ts index 654abbff549a2..10d2ff3156a7d 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.ts @@ -1289,6 +1289,8 @@ export function toBranchChecks(result: BranchCheckHistory.Result): ProviderBranc for (const check of toChecks(finished)) { if (!verdicts.has(check.name)) verdicts.set(check.name, check.status === "failure"); } + // Past a cut-off page, an older commit's result could stand in for a newer one never read. + if (commit.statusCheckRollup?.contexts.pageInfo.hasNextPage) break; } const named = (failing: boolean) => [...verdicts].flatMap(([name, failed]) => (failed === failing ? [name] : [])); diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql b/packages/source-control-github/src/server/pullRequestReads.graphql index 0eeb43d17f6b7..195f1c3db101f 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql +++ b/packages/source-control-github/src/server/pullRequestReads.graphql @@ -573,6 +573,9 @@ query BranchCheckHistory($owner: String!, $name: String!, $ref: String!) { nodes { statusCheckRollup { contexts(first: 100) { + pageInfo { + hasNextPage + } nodes { ... on StatusContext { context diff --git a/packages/source-control-github/src/server/pullRequestReads.graphql.ts b/packages/source-control-github/src/server/pullRequestReads.graphql.ts index 578e6a3e3b118..c28b9d9bd6e88 100644 --- a/packages/source-control-github/src/server/pullRequestReads.graphql.ts +++ b/packages/source-control-github/src/server/pullRequestReads.graphql.ts @@ -1616,6 +1616,8 @@ export interface NodesCheckRun extends Schema.Struct.Type