From 043ad349330faa2e82a8e06d5b4d809058ed382c Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:27:16 +0200 Subject: [PATCH] feat(automations): the merge gate sees a pull request's branches and head repository pull_request_read returns headBranch, baseBranch and headRepository in the cloud and on servers, so a folder's customized gate can tell its own branches from a fork's (#687, for #5's upstream sync). Co-Authored-By: Claude Opus 5.5 --- .../src/automations/mergePullRequest.test.ts | 24 +++++++++ apps/cloud/src/github/PullRequestMerges.ts | 12 +++++ apps/cloud/src/github/githubTesting.ts | 5 +- .../src/workflows/builtinTools/layer.test.ts | 6 +++ .../builtinTools/pullRequestAction.ts | 8 ++- .../automations/src/defaults/mergeGate.ts | 4 ++ .../automations/src/defaults/precompiled.ts | 50 +++++++++---------- 7 files changed, 82 insertions(+), 27 deletions(-) diff --git a/apps/cloud/src/automations/mergePullRequest.test.ts b/apps/cloud/src/automations/mergePullRequest.test.ts index 28a6dec027..bece5fc970 100644 --- a/apps/cloud/src/automations/mergePullRequest.test.ts +++ b/apps/cloud/src/automations/mergePullRequest.test.ts @@ -57,6 +57,30 @@ describe("a cloud thread merges its pull request through the Merge gate", () => ); }); + it.effect("the gate sees where a fork's pull request comes from and goes (#5)", () => { + const { cloud, github, open, tool, getRun, run } = makePullRequestThread(); + return run( + Effect.gen(function* () { + yield* open; + github.update(1, { checks: [...PASSED], files: ["app.ts"], headRepository: "fork/repo" }); + yield* cloud.syncPullRequests(threadId); + + const { waitId } = (yield* tool("merge_pull_request", { url: URL })) as { + readonly waitId: string; + }; + yield* cloud.settle; + const read = (yield* getRun(waitId)).steps.find( + (step) => step.label === "Read the pull request", + ); + expect(read?.result).toMatchObject({ + headBranch: github.pulls[0]!.head, + baseBranch: "main", + headRepository: "fork/repo", + }); + }), + ); + }); + it.effect("an unready one goes back to the agent, and nothing merges", () => { const { cloud, github, open, tool, messages, run } = makePullRequestThread(); return run( diff --git a/apps/cloud/src/github/PullRequestMerges.ts b/apps/cloud/src/github/PullRequestMerges.ts index f307cb57ba..b378fcd1c4 100644 --- a/apps/cloud/src/github/PullRequestMerges.ts +++ b/apps/cloud/src/github/PullRequestMerges.ts @@ -40,6 +40,11 @@ export interface MergeEvidence { readonly title: string; readonly state: PullRequestState; readonly headSha: string; + /** Where it comes from and goes: a folder's gate can treat its own branches differently. */ + readonly headBranch: string; + readonly baseBranch: string; + /** The head branch's repository; another one's means a fork. Null when GitHub lost it (deleted fork). */ + readonly headRepository: string | null; readonly isDraft: boolean; readonly mergeability: PullRequestMergeability; /** GitHub's own decision; approvals by apps alone don't count here as they do in upstream's summary. */ @@ -83,6 +88,7 @@ const EVIDENCE_QUERY = `query PullRequestMergeEvidence($owner: String!, $name: S repository(owner: $owner, name: $name) { pullRequest(number: $number) { title state isDraft mergeable reviewDecision headRefOid + headRefName baseRefName headRepository { nameWithOwner } reviewThreads(first: 100) { nodes { isResolved } pageInfo { hasNextPage } } files(first: 100) { nodes { path } pageInfo { hasNextPage endCursor } } } @@ -142,6 +148,9 @@ const decodeEvidence = answerOf( Schema.Literals(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"]), ), headRefOid: Schema.String, + headRefName: Schema.String, + baseRefName: Schema.String, + headRepository: Schema.NullOr(Schema.Struct({ nameWithOwner: Schema.String })), reviewThreads: Schema.Struct({ nodes: Schema.Array(Schema.Struct({ isResolved: Schema.Boolean })), pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean }), @@ -295,6 +304,9 @@ const make = Effect.gen(function* () { title: pullRequest.title, state: STATES[pullRequest.state] ?? "open", headSha: pullRequest.headRefOid, + headBranch: pullRequest.headRefName, + baseBranch: pullRequest.baseRefName, + headRepository: pullRequest.headRepository?.nameWithOwner ?? null, isDraft: pullRequest.isDraft, mergeability: MERGEABILITY[pullRequest.mergeable], reviewDecision: diff --git a/apps/cloud/src/github/githubTesting.ts b/apps/cloud/src/github/githubTesting.ts index 96541fd04c..26df425c52 100644 --- a/apps/cloud/src/github/githubTesting.ts +++ b/apps/cloud/src/github/githubTesting.ts @@ -71,6 +71,8 @@ export const makeFakeGitHub = (options: { openThreads?: number; /** The head commit when it merged, as GitHub keeps it after the branch moves or goes. */ headOid?: string; + /** The head branch's repository when it isn't this one: a fork. */ + headRepository?: string; }> = []; const calls: Array = []; /** Changes made through `update`, so each moves `updatedAt` a minute on. */ @@ -168,7 +170,8 @@ export const makeFakeGitHub = (options: { reviewDecision: null, headRefOid: head(pull.head) ?? "0".repeat(40), headRefName: pull.head, - headRepository: { nameWithOwner: options.repository }, + baseRefName: pull.base, + headRepository: { nameWithOwner: pull.headRepository ?? options.repository }, reviewThreads: { nodes: Array.from({ length: pull.openThreads ?? 0 }, () => ({ isResolved: false })), pageInfo: { hasNextPage: false }, diff --git a/apps/server/src/workflows/builtinTools/layer.test.ts b/apps/server/src/workflows/builtinTools/layer.test.ts index ee529b6cc9..c9d65fb5b6 100644 --- a/apps/server/src/workflows/builtinTools/layer.test.ts +++ b/apps/server/src/workflows/builtinTools/layer.test.ts @@ -192,6 +192,9 @@ it.effect( state: "merged", title: "Fix login", headSha: "abc", + headBranch: "fix/login", + baseBranch: "main", + headRepositoryNameWithOwner: "fork/app", isDraft: false, mergeability: "unknown", checks: [{ name: "test", status: "success", description: null, url: null }], @@ -280,6 +283,9 @@ it.effect( title: "Fix login", state: "merged", headSha: "abc", + headBranch: "fix/login", + baseBranch: "main", + headRepository: "fork/app", isDraft: false, mergeability: "unknown", reviewDecision: null, diff --git a/apps/server/src/workflows/builtinTools/pullRequestAction.ts b/apps/server/src/workflows/builtinTools/pullRequestAction.ts index 949f97f7a8..6ea4ecc5d8 100644 --- a/apps/server/src/workflows/builtinTools/pullRequestAction.ts +++ b/apps/server/src/workflows/builtinTools/pullRequestAction.ts @@ -59,7 +59,7 @@ const Target = { const PullRequestReadTool = Tool.make("pull_request_read", { description: - "Read a pull request linked to a thread, fresh from the host: where it lives, its title, state and head commit, and the merge gate's evidence at that commit. Parts the host can't answer here are null: review threads, the review decision and the changed files are read only in the cloud so far.", + "Read a pull request linked to a thread, fresh from the host: where it lives, its title, state, head commit, head and base branches and the head's repository (another one's is a fork), and the merge gate's evidence at that commit. Parts the host can't answer here are null: review threads, the review decision and the changed files are read only in the cloud so far.", parameters: Schema.Struct(Target), success: Schema.Struct({ repository: Schema.String, @@ -67,6 +67,9 @@ const PullRequestReadTool = Tool.make("pull_request_read", { title: Schema.String, state: PullRequestState, headSha: Schema.NullOr(Schema.String), + headBranch: Schema.String, + baseBranch: Schema.String, + headRepository: Schema.NullOr(Schema.String), isDraft: Schema.Boolean, mergeability: PullRequestMergeability, reviewDecision: Schema.Null, @@ -173,6 +176,9 @@ export const layer = McpToolAccess.toLayer( title: current.title, state: current.state, headSha: current.headSha ?? null, + headBranch: current.headBranch, + baseBranch: current.baseBranch, + headRepository: current.headRepositoryNameWithOwner ?? null, isDraft: current.isDraft, mergeability: current.mergeability, reviewDecision: null, diff --git a/packages/automations/src/defaults/mergeGate.ts b/packages/automations/src/defaults/mergeGate.ts index c0f69ee94a..11d78c2fc5 100644 --- a/packages/automations/src/defaults/mergeGate.ts +++ b/packages/automations/src/defaults/mergeGate.ts @@ -59,6 +59,10 @@ type PullRequest = { title: string; state: string; headSha: string | null; + /** Where it comes from and goes. A headRepository other than repository is a fork. */ + headBranch?: string; + baseBranch?: string; + headRepository?: string | null; isDraft?: boolean | null; mergeability?: string | null; reviewDecision?: string | null; diff --git a/packages/automations/src/defaults/precompiled.ts b/packages/automations/src/defaults/precompiled.ts index 5114609d21..2769ed81a7 100644 --- a/packages/automations/src/defaults/precompiled.ts +++ b/packages/automations/src/defaults/precompiled.ts @@ -638,7 +638,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s1", - line: 119, + line: 123, verb: "call", label: { text: "Read the pull request", @@ -657,7 +657,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "branch", id: "s2", - line: 122, + line: 126, source: "condition", label: { text: 'pr.state !== "open"', @@ -670,7 +670,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s3/s4", - line: 63, + line: 67, verb: "call", label: { text: "Tell the agent", @@ -689,7 +689,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "end", id: "s5", - line: 124, + line: 128, exit: "workflow", }, ], @@ -703,7 +703,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "branch", id: "s6", - line: 128, + line: 132, source: "condition", label: { text: "notReady.length > 0", @@ -716,7 +716,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s7/s4", - line: 63, + line: 67, verb: "call", label: { text: "Tell the agent", @@ -735,7 +735,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "end", id: "s8", - line: 130, + line: 134, exit: "workflow", }, ], @@ -749,7 +749,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "branch", id: "s9", - line: 134, + line: 138, source: "condition", label: { text: "reasons.length > 0", @@ -762,7 +762,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s10", - line: 136, + line: 140, verb: "ask", label: { text: "Merge …#…?", @@ -787,7 +787,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "branch", id: "s13", - line: 147, + line: 151, source: "outcome", label: { text: "Merge …#…?", @@ -801,7 +801,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s11/s4", - line: 63, + line: 67, verb: "call", label: { text: "Tell the agent", @@ -820,7 +820,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "end", id: "s12", - line: 152, + line: 156, exit: "workflow", }, ], @@ -842,7 +842,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "try", id: "s14", - line: 157, + line: 161, label: { text: "If something fails", dynamic: false, @@ -851,7 +851,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s15", - line: 158, + line: 162, verb: "call", label: { text: "Merge it", @@ -873,7 +873,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s16/s4", - line: 63, + line: 67, verb: "call", label: { text: "Tell the agent", @@ -892,7 +892,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "end", id: "s17", - line: 166, + line: 170, exit: "workflow", }, ], @@ -900,7 +900,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "branch", id: "s18", - line: 168, + line: 172, source: "condition", label: { text: "merged.moved", @@ -913,7 +913,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s19/s4", - line: 63, + line: 67, verb: "call", label: { text: "Tell the agent", @@ -932,7 +932,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "end", id: "s20", - line: 170, + line: 174, exit: "workflow", }, ], @@ -946,7 +946,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "branch", id: "s21", - line: 173, + line: 177, source: "condition", label: { text: "A host with a merge queue takes it in instead of merging; the branch stays until it lands.", @@ -959,7 +959,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s22/s4", - line: 63, + line: 67, verb: "call", label: { text: "Tell the agent", @@ -978,7 +978,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "end", id: "s23", - line: 175, + line: 179, exit: "workflow", }, ], @@ -992,7 +992,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "try", id: "s24", - line: 178, + line: 182, label: { text: "If something fails", dynamic: false, @@ -1001,7 +1001,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s25", - line: 179, + line: 183, verb: "call", label: { text: "Delete its branch", @@ -1023,7 +1023,7 @@ export const PRECOMPILED_BUILT_INS: Readonly> = { type: "step", id: "s26/s4", - line: 63, + line: 67, verb: "call", label: { text: "Tell the agent",