From 93cd39aedd4a3e82da21ae67915eeab097d1dff4 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Fri, 16 Oct 2026 13:40:19 +0200 Subject: [PATCH] fix(server): clear automatic PRs from other branches Discovery could restore a closed or merged PR after a thread switched to a branch without one. Only restore the saved automatic association when its source branch still matches the thread. Implemented and verified with gpt-6.1-sol through the Codex harness in T3 Code. --- .../ThreadPullRequestService.test.ts | 120 ++++++++++++++++++ .../ThreadPullRequestService.ts | 5 +- 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 25afb151e280..e47191c3fbc0 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -2,10 +2,12 @@ import { EventId, ProjectId, ProviderInstanceId, + SourceControlProviderKind, ThreadId, type OrchestrationProjectShell, type OrchestrationV2DomainEvent, type OrchestrationV2ThreadShell, + type OrchestrationV2ServerCommand, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; @@ -169,6 +171,124 @@ describe("ThreadPullRequestServiceV2 reads", () => { }; }; + it.effect.each([ + { state: "closed", sameBranch: false }, + { state: "merged", sameBranch: false }, + { state: "closed", sameBranch: true }, + { state: "merged", sameBranch: true }, + ] as const)( + "restores a $state automatic PR only for its own branch (sameBranch=$sameBranch)", + ({ state, sameBranch }) => + Effect.scoped( + Effect.gen(function* () { + const savedReference = { + projectId: ProjectId.make("project-1"), + repository: "acme/project", + number: 42, + url: "https://github.com/acme/project/pull/42", + }; + const thread = { + ...threadShell("branch-pr-thread"), + branch: "main", + branchPullRequest: savedReference, + linkedPullRequest: savedReference, + }; + const project: OrchestrationProjectShell = { + id: thread.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const sweepStarted = yield* Deferred.make(); + const commands: Array = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.never, + getShellSnapshot: () => + Deferred.succeed(sweepStarted, undefined).pipe( + Effect.as({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [thread], + archivedThreads: [], + }), + ), + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + return { sequence: 2, storedEvents: [] }; + }), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: () => Effect.succeed(null), + }), + Layer.mock(PullRequestService.PullRequestService)({ + summary: () => + Effect.succeed({ + ...savedReference, + provider: SourceControlProviderKind.make("github"), + title: "Previous branch work", + state, + headBranch: sameBranch ? thread.branch : "feature/previous-work", + baseBranch: "release", + updatedAt: "2026-09-19T00:00:00.000Z", + }), + }), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: "github" as const, + owner: "acme", + name: "project", + }), + }), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + FileSystem.layerNoop({}), + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(sweepStarted); + yield* service.drain; + }).pipe(Effect.provide(dependencies)); + + if (sameBranch) { + expect(commands).toEqual([]); + } else { + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ + type: "thread.pull-request.sync", + threadId: thread.id, + branchPullRequest: null, + }); + expect(commands[0]).not.toHaveProperty("linkedPullRequest"); + } + }), + ), + ); + it.effect("an event for one thread reads that thread's shell, not every thread's", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 21387243b46c..6382a448cf3d 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -223,7 +223,10 @@ export const make = Effect.gen(function* () { const previous = yield* pullRequests.summary(thread.branchPullRequest, { recoverTransientFailure: false, }); - if (previous.state === "merged" || previous.state === "closed") { + if ( + previous.headBranch === thread.branch && + (previous.state === "merged" || previous.state === "closed") + ) { branchPullRequest = thread.branchPullRequest; } }