From 5e512ba2d4ab86472c9cd36527db5c3bf2a503ce Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Sat, 11 Jul 2026 23:43:06 -0400 Subject: [PATCH] Detect pull requests against upstream repositories --- apps/server/src/git/GitManager.test.ts | 65 ++++++++++ apps/server/src/git/GitManager.ts | 9 +- .../src/sourceControl/GitHubCli.test.ts | 21 ++++ apps/server/src/sourceControl/GitHubCli.ts | 26 +++- .../GitHubSourceControlProvider.test.ts | 32 +++++ .../GitHubSourceControlProvider.ts | 117 ++++++++++++------ .../SourceControlProviderRegistry.test.ts | 42 ++++++- .../SourceControlProviderRegistry.ts | 11 +- 8 files changed, 279 insertions(+), 44 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index e1924c03ade..375ec642efd 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -526,6 +526,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "open", "--limit", String(input.limit ?? 1), + ...(input.repository ? ["--repo", input.repository] : []), "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], @@ -1077,6 +1078,70 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 20_000, ); + it.effect( + "status detects fork PRs opened against the conventional upstream remote", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", forkDir]); + yield* runGit(repoDir, ["remote", "add", "upstream", upstreamDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/upstream-pr"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/upstream-pr"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:contributor/t3code.git", + forkDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "upstream", + "git@github.com:T3Tools/t3code.git", + upstreamDir, + ); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 1701, + title: "Fix fork PR detection", + url: "https://github.com/T3Tools/t3code/pull/1701", + baseRefName: "main", + headRefName: "feature/upstream-pr", + state: "OPEN", + updatedAt: "2026-07-11T12:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "contributor/t3code" }, + headRepositoryOwner: { login: "contributor" }, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 1701, + title: "Fix fork PR detection", + url: "https://github.com/T3Tools/t3code/pull/1701", + baseRef: "main", + headRef: "feature/upstream-pr", + state: "open", + }); + expect(ghCalls).toContain( + "pr list --head contributor:feature/upstream-pr --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), + 20_000, + ); + it.effect( "status ignores synthetic local branch aliases when the upstream remote name contains slashes", () => diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f1fb03e7e45..40525918c31 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -862,19 +862,22 @@ export const make = Effect.gen(function* () { const shouldProbeLocalBranchSelector = headBranchFromUpstream.length === 0 || headBranch === details.branch; - const [remoteRepository, originRepository] = yield* Effect.all( + const [remoteRepository, upstreamRepository, originRepository] = yield* Effect.all( [ resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "upstream"), resolveRemoteRepositoryContext(cwd, "origin"), ], { concurrency: "unbounded" }, ); + const targetRepository = + upstreamRepository.repositoryNameWithOwner !== null ? upstreamRepository : originRepository; const isCrossRepository = remoteRepository.repositoryNameWithOwner !== null && - originRepository.repositoryNameWithOwner !== null + targetRepository.repositoryNameWithOwner !== null ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== - originRepository.repositoryNameWithOwner.toLowerCase() + targetRepository.repositoryNameWithOwner.toLowerCase() : remoteName !== null && remoteName !== "origin" && remoteRepository.repositoryNameWithOwner !== null; diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..4687d53029a 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -193,6 +193,7 @@ describe("GitHubCli.layer", () => { const result = yield* gh.listOpenPullRequests({ cwd: "/repo", headSelector: "feature/pr-list", + repository: "T3Tools/t3code", }); assert.deepStrictEqual(result, [ @@ -205,6 +206,26 @@ describe("GitHubCli.layer", () => { state: "open", }, ]); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "list", + "--head", + "feature/pr-list", + "--state", + "open", + "--limit", + "1", + "--repo", + "T3Tools/t3code", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); }).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..a411c68942e 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -209,11 +209,13 @@ export class GitHubCli extends Context.Service< readonly cwd: string; readonly headSelector: string; readonly limit?: number; + readonly repository?: string; }) => Effect.Effect, GitHubCliError>; readonly getPullRequest: (input: { readonly cwd: string; readonly reference: string; + readonly repository?: string; }) => Effect.Effect; readonly getRepositoryCloneUrls: (input: { @@ -233,16 +235,19 @@ export class GitHubCli extends Context.Service< readonly headSelector: string; readonly title: string; readonly bodyFile: string; + readonly repository?: string; }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; + readonly repository?: string; }) => Effect.Effect; readonly checkoutPullRequest: (input: { readonly cwd: string; readonly reference: string; readonly force?: boolean; + readonly repository?: string; }) => Effect.Effect; } >()("t3/sourceControl/GitHubCli") {} @@ -331,6 +336,7 @@ export const make = Effect.gen(function* () { "open", "--limit", String(input.limit ?? 1), + ...(input.repository ? ["--repo", input.repository] : []), "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], @@ -365,6 +371,7 @@ export const make = Effect.gen(function* () { "pr", "view", input.reference, + ...(input.repository ? ["--repo", input.repository] : []), "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], @@ -433,12 +440,21 @@ export const make = Effect.gen(function* () { input.title, "--body-file", input.bodyFile, + ...(input.repository ? ["--repo", input.repository] : []), ], }).pipe(Effect.asVoid), getDefaultBranch: (input) => execute({ cwd: input.cwd, - args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + args: [ + "repo", + "view", + ...(input.repository ? [input.repository] : []), + "--json", + "defaultBranchRef", + "--jq", + ".defaultBranchRef.name", + ], }).pipe( Effect.map((value) => { const trimmed = value.stdout.trim(); @@ -448,7 +464,13 @@ export const make = Effect.gen(function* () { checkoutPullRequest: (input) => execute({ cwd: input.cwd, - args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], + args: [ + "pr", + "checkout", + input.reference, + ...(input.force ? ["--force"] : []), + ...(input.repository ? ["--repo", input.repository] : []), + ], }).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a6829566..c76f99bef5b 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -135,6 +135,11 @@ it.effect("uses gh json listing for non-open change request state queries", () = const changeRequests = yield* provider.listChangeRequests({ cwd: "/repo", + context: { + provider: { kind: "github", name: "github.com", baseUrl: "https://github.com" }, + remoteName: "upstream", + remoteUrl: "git@github.com:T3Tools/t3code.git", + }, headSelector: "feature/merged", state: "all", limit: 10, @@ -149,6 +154,8 @@ it.effect("uses gh json listing for non-open change request state queries", () = "all", "--limit", "10", + "--repo", + "T3Tools/t3code", "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ]); @@ -161,6 +168,31 @@ it.effect("uses gh json listing for non-open change request state queries", () = }), ); +it.effect("targets the upstream repository when listing open pull requests for a fork", () => + Effect.gen(function* () { + let repository: string | undefined; + const provider = yield* makeProvider({ + listOpenPullRequests: (input) => { + repository = input.repository; + return Effect.succeed([]); + }, + }); + + yield* provider.listChangeRequests({ + cwd: "/repo", + context: { + provider: { kind: "github", name: "github.com", baseUrl: "https://github.com" }, + remoteName: "upstream", + remoteUrl: "https://github.com/T3Tools/t3code.git", + }, + headSelector: "contributor:feature/fork-pr", + state: "open", + }); + + assert.strictEqual(repository, "T3Tools/t3code"); + }), +); + it.effect("treats empty non-open change request listing output as no results", () => Effect.gen(function* () { const provider = yield* makeProvider({ diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..b016f3396c4 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -7,6 +7,7 @@ import { type ChangeRequest, type ChangeRequestState, } from "@t3tools/contracts"; +import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@t3tools/shared/git"; import * as GitHubCli from "./GitHubCli.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; @@ -96,15 +97,30 @@ export const discovery = { export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; + const repositoryFromContext = ( + context: SourceControlProvider.SourceControlProviderContext | undefined, + ) => + context?.remoteName === "upstream" + ? (parseGitHubRepositoryNameWithOwnerFromRemoteUrl(context.remoteUrl) ?? undefined) + : undefined; + const withRepositoryFromContext = ( + input: Input, + context: SourceControlProvider.SourceControlProviderContext | undefined, + ): Input | (Input & { readonly repository: string }) => { + const repository = repositoryFromContext(context); + return repository ? { ...input, repository } : input; + }; const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = (input) => { + const repository = repositoryFromContext(input.context); if (input.state === "open") { return github .listOpenPullRequests({ cwd: input.cwd, headSelector: input.headSelector, ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(repository ? { repository } : {}), }) .pipe( Effect.map((items) => items.map(toChangeRequest)), @@ -138,6 +154,7 @@ export const make = Effect.gen(function* () { stateArg, "--limit", String(input.limit ?? 20), + ...(repository ? ["--repo", repository] : []), "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], @@ -188,32 +205,47 @@ export const make = Effect.gen(function* () { kind: "github", listChangeRequests, getChangeRequest: (input) => - github.getPullRequest(input).pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getChangeRequest", - command: error.command, + github + .getPullRequest( + withRepositoryFromContext( + { cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + reference: input.reference, + }, + input.context, + ), + ) + .pipe( + Effect.map(toChangeRequest), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "getChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), - ), createChangeRequest: (input) => github - .createPullRequest({ - cwd: input.cwd, - baseBranch: input.baseRefName, - headSelector: input.headSelector, - title: input.title, - bodyFile: input.bodyFile, - }) + .createPullRequest( + withRepositoryFromContext( + { + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + title: input.title, + bodyFile: input.bodyFile, + }, + input.context, + ), + ) .pipe( Effect.mapError( (error) => @@ -265,7 +297,7 @@ export const make = Effect.gen(function* () { ), ), getDefaultBranch: (input) => - github.getDefaultBranch(input).pipe( + github.getDefaultBranch(withRepositoryFromContext({ cwd: input.cwd }, input.context)).pipe( Effect.mapError( (error) => new SourceControlProviderError({ @@ -279,22 +311,33 @@ export const make = Effect.gen(function* () { ), ), checkoutChangeRequest: (input) => - github.checkoutPullRequest(input).pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "checkoutChangeRequest", - command: error.command, + github + .checkoutPullRequest( + withRepositoryFromContext( + { cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.detail, - cause: error, - }), + reference: input.reference, + ...(input.force !== undefined ? { force: input.force } : {}), + }, + input.context, + ), + ) + .pipe( + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "checkoutChangeRequest", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.reference, + ), + detail: error.detail, + cause: error, + }), + ), ), - ), }); }); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 5c4d27e46f9..63db984098a 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -40,6 +40,7 @@ function makeRegistry(input: { }>; readonly process?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; + readonly github?: Partial; }) { const driver = { listRemotes: () => @@ -90,7 +91,7 @@ function makeRegistry(input: { processLayer, Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), - Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}), Layer.mock(GitLabCli.GitLabCli)({}), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", @@ -261,3 +262,42 @@ it.effect("falls back to a non-origin remote when origin is not configured", () assert.strictEqual(provider.kind, "azure-devops"); }), ); + +it.effect("prefers the upstream repository context for conventional GitHub forks", () => + Effect.gen(function* () { + let repository: string | undefined; + const registry = yield* makeRegistry({ + remotes: [ + { name: "origin", url: "git@github.com:contributor/t3code.git" }, + { name: "upstream", url: "git@github.com:T3Tools/t3code.git" }, + ], + github: { + getDefaultBranch: (input) => { + repository = input.repository; + return Effect.succeed("main"); + }, + }, + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + const defaultBranch = yield* provider.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(defaultBranch, "main"); + assert.strictEqual(repository, "T3Tools/t3code"); + }), +); + +it.effect("does not let an unrelated upstream remote override origin", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [ + { name: "origin", url: "git@github.com:T3Tools/t3code.git" }, + { name: "upstream", url: "https://dev.azure.com/acme/project/_git/repo" }, + ], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "github"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e43..c78042bb4fe 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -141,8 +141,17 @@ function selectProviderContext( } } + const origin = candidates.find((candidate) => candidate.remoteName === "origin"); + const conventionalGitHubUpstream = candidates.find( + (candidate) => + candidate.remoteName === "upstream" && + candidate.provider.kind === "github" && + origin?.provider.kind === "github", + ); + return ( - candidates.find((candidate) => candidate.remoteName === "origin") ?? + conventionalGitHubUpstream ?? + origin ?? candidates.find((candidate) => candidate.provider.kind !== "unknown") ?? candidates[0] ?? null