diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 7cc252d3bf2..99f5d47d0c5 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -371,13 +371,22 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 ? args[headSelectorIndex + 1] : undefined; - const mappedQueue = + const mappedHeadSelector = typeof headSelector === "string" - ? prListQueueByHeadSelector.get(headSelector)?.shift() + ? ([ + ...prListQueueByHeadSelector.keys(), + ...Object.keys(scenario.prListByHeadSelector ?? {}), + ].find( + (candidate) => candidate === headSelector || candidate.endsWith(`:${headSelector}`), + ) ?? headSelector) + : undefined; + const mappedQueue = + typeof mappedHeadSelector === "string" + ? prListQueueByHeadSelector.get(mappedHeadSelector)?.shift() : undefined; const mappedStdout = - typeof headSelector === "string" - ? scenario.prListByHeadSelector?.[headSelector] + typeof mappedHeadSelector === "string" + ? scenario.prListByHeadSelector?.[mappedHeadSelector] : undefined; const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; return Effect.succeed(fakeGhOutput(stdout)); @@ -392,6 +401,18 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } if (args[0] === "pr" && args[1] === "view") { + const selector = args[2]; + const queuedPullRequests = + typeof selector === "string" + ? (prListQueueByHeadSelector.get(selector)?.shift() ?? + scenario.prListByHeadSelector?.[selector] ?? + prListQueue.shift()) + : undefined; + if (queuedPullRequests !== undefined) { + const parsed = JSON.parse(queuedPullRequests) as unknown; + const candidate = Array.isArray(parsed) ? parsed[0] : parsed; + return Effect.succeed(fakeGhOutput(candidate ? `${JSON.stringify(candidate)}\n` : "")); + } const pullRequest: FakePullRequest = scenario.pullRequest ?? { number: 101, title: "Pull request", @@ -493,29 +514,57 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return { service: { execute, - listOpenPullRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - "open", - "--limit", - String(input.limit ?? 1), - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as unknown[]), + listOpenPullRequests: (input) => { + const qualifiedHead = /^([^:/\s]+):(.+)$/u.exec(input.headSelector); + const requestedLimit = input.limit ?? 1; + const args = qualifiedHead + ? [ + "pr", + "view", + input.headSelector, + ...(input.repository ? ["--repo", input.repository] : []), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ] + : [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + "open", + "--limit", + String(requestedLimit), + ...(input.repository ? ["--repo", input.repository] : []), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ]; + ghCalls.push(args.join(" ")); + if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { + return Effect.fail(scenario.failWith); + } + const stdout = + prListQueueByHeadSelector.get(input.headSelector)?.shift() ?? + scenario.prListByHeadSelector?.[input.headSelector] ?? + prListQueue.shift() ?? + "[]"; + // @effect-diagnostics-next-line preferSchemaOverJson:off + return Effect.sync(() => JSON.parse(stdout) as unknown).pipe( Effect.map((raw) => - raw + (Array.isArray(raw) ? raw : [raw]) .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null), + .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null) + .filter( + (entry) => + !qualifiedHead || + (entry.headRefName === qualifiedHead[2] && + entry.headRepositoryOwnerLogin?.toLowerCase() === + qualifiedHead[1]?.toLowerCase()), + ) + .slice(0, requestedLimit), ), - ), + ); + }, createPullRequest: (input) => execute({ cwd: input.cwd, @@ -1007,6 +1056,62 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("uses the origin fork identity for a branch pushed without tracking", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "upstream", upstreamDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["push", "upstream", "main"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:octocat/codething-mvp.git", + originDir, + ); + yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "upstream", + "git@github.com:pingdotgg/codething-mvp.git", + upstreamDir, + ); + yield* runGit(repoDir, ["checkout", "-b", "feature/pushed-no-tracking"]); + yield* runGit(repoDir, ["push", "origin", "feature/pushed-no-tracking"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + // @effect-diagnostics-next-line preferSchemaOverJson:off + "octocat:feature/pushed-no-tracking": JSON.stringify([ + { + number: 215, + title: "Fork PR without tracking", + url: "https://github.com/pingdotgg/codething-mvp/pull/215", + baseRefName: "main", + headRefName: "feature/pushed-no-tracking", + state: "OPEN", + isCrossRepository: true, + headRepository: { nameWithOwner: "octocat/codething-mvp" }, + headRepositoryOwner: { login: "octocat" }, + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.number).toBe(215); + expect( + ghCalls.some((call) => call.startsWith("pr view octocat:feature/pushed-no-tracking ")), + ).toBe(true); + }), + ); + it("backs off repeated PR lookup failures past the healthy refresh cadence", () => { expect(Duration.toMillis(GitManager.prLookupFailureTtl(1))).toBe(20_000); expect(Duration.toMillis(GitManager.prLookupFailureTtl(2))).toBe(40_000); @@ -1121,7 +1226,71 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr view jasonLaster:statemachine --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), + 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 view contributor:feature/upstream-pr --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2305,8 +2474,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -2336,11 +2503,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - expect( - ghCalls.some((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), - ), - ).toBe(true); + expect(ghCalls.some((call) => call.startsWith("pr view octocat:statemachine "))).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), 12_000, @@ -2513,7 +2676,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.number).toBe(142); const ownerSelectorCallIndex = ghCalls.findIndex((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), + call.startsWith("pr view octocat:statemachine "), ); expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); @@ -2579,11 +2742,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); - expect(openLookupCalls).toHaveLength(1); - expect(openLookupCalls[0]).toContain( - "pr list --head octocat:statemachine --state open --limit 1", + const openLookupCalls = ghCalls.filter((call) => + call.startsWith("pr view octocat:statemachine "), ); + expect(openLookupCalls).toHaveLength(1); + expect(openLookupCalls[0]).toContain("pr view octocat:statemachine"); }), 12_000, ); @@ -2780,13 +2943,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("generates PR content against the remote base when the local base is stale", () => + it.effect("generates PR content against a non-origin remote base when local base is stale", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "primary", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "primary", "main"]); yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/main"]); const peerDir = yield* makeTempDir("t3code-git-peer-"); @@ -2798,18 +2961,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(peerDir, ["commit", "-m", "Remote base commit"]); yield* runGit(peerDir, ["push", "origin", "main"]); - yield* runGit(repoDir, ["fetch", "origin"]); + yield* runGit(repoDir, ["fetch", "primary"]); yield* runGit(repoDir, [ "checkout", "--no-track", "-b", "feature/remote-base", - "origin/main", + "primary/main", ]); NodeFS.writeFileSync(NodePath.join(repoDir, "feature.txt"), "feature\n"); yield* runGit(repoDir, ["add", "feature.txt"]); yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); + yield* runGit(repoDir, ["push", "-u", "primary", "feature/remote-base"]); yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); let generatedCommitSummary = ""; @@ -2926,7 +3089,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* configureVisibleRemoteUrlWithLocalRewrite( repoDir, "fork-seed", - "git@github.com:octocat/codething-mvp.git", + "https://github.com/octocat/codething-mvp.git/", forkDir, ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index b4d1240c6a2..51b78dd2541 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -176,6 +176,7 @@ interface BranchHeadContext { headSelectors: ReadonlyArray; preferredHeadSelector: string; remoteName: string | null; + targetRemoteName: string | null; headRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; @@ -228,12 +229,44 @@ function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): st return null; } - const match = - /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( - trimmed, - ); - const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; - return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; + const scpStyle = /^git@([^:/\s]+):([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(trimmed); + if (scpStyle?.[1] && scpStyle[2] && scpStyle[3]) { + return scpStyle[1].toLowerCase() === "github.com" + ? `${scpStyle[2]}/${scpStyle[3]}` + : `${scpStyle[1]}/${scpStyle[2]}/${scpStyle[3]}`; + } + try { + const parsed = new URL(trimmed); + const [owner, name, ...rest] = parsed.pathname + .replace(/\/+$/, "") + .replace(/\.git$/i, "") + .split("/") + .filter((part) => part.length > 0); + if (!owner || !name || rest.length > 0) return null; + return parsed.hostname.toLowerCase() === "github.com" + ? `${owner}/${name}` + : `${parsed.hostname}/${owner}/${name}`; + } catch { + return null; + } +} + +function repositoryCoordinatesMatchAsForks(left: string | null, right: string | null): boolean { + const parse = (value: string | null) => { + const parts = value?.split("/") ?? []; + if (parts.length === 2) return { host: "github.com", owner: parts[0], name: parts[1] }; + if (parts.length === 3) return { host: parts[0], owner: parts[1], name: parts[2] }; + return null; + }; + const leftCoordinate = parse(left); + const rightCoordinate = parse(right); + return Boolean( + leftCoordinate && + rightCoordinate && + leftCoordinate.host?.toLowerCase() === rightCoordinate.host?.toLowerCase() && + leftCoordinate.name?.toLowerCase() === rightCoordinate.name?.toLowerCase() && + leftCoordinate.owner?.toLowerCase() !== rightCoordinate.owner?.toLowerCase(), + ); } function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { @@ -241,7 +274,8 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null if (trimmed.length === 0) { return null; } - const [ownerLogin] = trimmed.split("/"); + const parts = trimmed.split("/"); + const ownerLogin = parts.length === 3 ? parts[1] : parts[0]; const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; } @@ -1150,26 +1184,37 @@ 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 forkRepository = + remoteRepository.repositoryNameWithOwner ?? originRepository.repositoryNameWithOwner; + const upstreamIsRelated = repositoryCoordinatesMatchAsForks( + forkRepository, + upstreamRepository.repositoryNameWithOwner, + ); + const targetRepository = upstreamIsRelated ? upstreamRepository : originRepository; + const targetRemoteName = upstreamIsRelated ? "upstream" : null; + const headRepository = + remoteRepository.repositoryNameWithOwner ?? + (remoteName === null ? originRepository.repositoryNameWithOwner : null); + const headRepositoryOwnerLogin = parseRepositoryOwnerLogin(headRepository); const isCrossRepository = - remoteRepository.repositoryNameWithOwner !== null && - originRepository.repositoryNameWithOwner !== null - ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== - originRepository.repositoryNameWithOwner.toLowerCase() + headRepository !== null && targetRepository.repositoryNameWithOwner !== null + ? headRepository.toLowerCase() !== targetRepository.repositoryNameWithOwner.toLowerCase() : remoteName !== null && remoteName !== "origin" && remoteRepository.repositoryNameWithOwner !== null; const ownerHeadSelector = - remoteRepository.ownerLogin && headBranch.length > 0 - ? `${remoteRepository.ownerLogin}:${headBranch}` + headRepositoryOwnerLogin && headBranch.length > 0 + ? `${headRepositoryOwnerLogin}:${headBranch}` : null; const remoteAliasHeadSelector = remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; @@ -1203,11 +1248,12 @@ export const make = Effect.gen(function* () { preferredHeadSelector: ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, remoteName, + targetRemoteName, headRemoteUrlKey: remoteRepository.remoteUrlKey ?? (remoteName === null ? originRepository.remoteUrlKey : null), - headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, - headRepositoryOwnerLogin: remoteRepository.ownerLogin, + headRepositoryNameWithOwner: headRepository, + headRepositoryOwnerLogin, isCrossRepository, } satisfies BranchHeadContext; }); @@ -1444,10 +1490,11 @@ export const make = Effect.gen(function* () { const resolveBaseRangeRef = Effect.fn("resolveBaseRangeRef")(function* ( cwd: string, baseBranch: string, + targetRemoteName: string | null, ) { - const remoteName = yield* gitCore - .resolvePrimaryRemoteName(cwd) - .pipe(Effect.orElseSucceed(() => null)); + const remoteName = + targetRemoteName ?? + (yield* gitCore.resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null))); if (!remoteName) return baseBranch; return yield* gitCore @@ -1673,7 +1720,7 @@ export const make = Effect.gen(function* () { phase: "pr", label: `Generating ${terms.shortLabel} content...`, }); - const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch); + const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseBranch, headContext.targetRemoteName); const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); const policy = yield* resolveStylePolicy(cwd, settings.style); const changeRequestTemplate = @@ -1718,6 +1765,17 @@ export const make = Effect.gen(function* () { cwd, baseRefName: baseBranch, headSelector: headContext.preferredHeadSelector, + ...(headContext.headRepositoryNameWithOwner + ? { + source: { + refName: headContext.headBranch, + ...(headContext.headRepositoryOwnerLogin + ? { owner: headContext.headRepositoryOwnerLogin } + : {}), + repository: headContext.headRepositoryNameWithOwner, + }, + } + : {}), title: generated.title, bodyFile, }) diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5daf7676d60..28b199674cc 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,199 @@ 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)), + ); + + it.effect("creates pull requests against the parent of an origin fork", () => + Effect.gen(function* () { + mockRun + .mockReturnValueOnce( + Effect.succeed(processOutput("github.com/pingdotgg/t3code\tgithub.com/octocat/t3code\n")), + ) + .mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/fork-pr", + headRepository: "octocat/t3code", + title: "Target upstream", + bodyFile: "/tmp/pr-body.md", + }); + + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.execute", + command: "gh", + args: [ + "repo", + "view", + "octocat/t3code", + "--json", + "nameWithOwner,parent,url", + "--jq", + '. as $repo | (.url | capture("^https?://(?[^/]+)").host) as $host | [if $repo.parent then "\\($host)/\\($repo.parent.owner.login)/\\($repo.parent.name)" else "\\($host)/\\($repo.nameWithOwner)" end, "\\($host)/\\($repo.nameWithOwner)"] | @tsv', + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + expect(mockRun).toHaveBeenNthCalledWith(2, { + operation: "GitHubCli.execute", + command: "gh", + args: [ + "api", + "--hostname", + "github.com", + "repos/pingdotgg/t3code/pulls", + "--method", + "POST", + "-f", + "title=Target upstream", + "-f", + "head=octocat:feature/fork-pr", + "-f", + "head_repo=t3code", + "-f", + "base=main", + "-F", + "body=@/tmp/pr-body.md", + "-F", + "maintainer_can_modify=true", + "--silent", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("filters qualified fork heads after listing the target repository", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 45, + title: "Requested fork", + url: "https://github.com/pingdotgg/t3code/pull/45", + baseRefName: "main", + headRefName: "feature/shared", + state: "OPEN", + headRepositoryOwner: { login: "octocat" }, + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const pullRequests = yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "octocat:feature/shared", + repository: "pingdotgg/t3code", + limit: 1, + }); + + assert.deepStrictEqual( + pullRequests.map((pullRequest) => pullRequest.number), + [45], + ); + expect(mockRun).toHaveBeenCalledWith( + expect.objectContaining({ + args: expect.arrayContaining(["pr", "view", "octocat:feature/shared"]), + }), + ); + expect(mockRun).not.toHaveBeenCalledWith( + expect.objectContaining({ args: expect.arrayContaining(["--limit"]) }), + ); + }).pipe(Effect.provide(layer)), + ); + + it.effect("creates pull requests from organization-owned forks through the API", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "acme:feature/fork-pr", + repository: "pingdotgg/t3code", + headRepository: "acme/t3code", + title: "Target upstream", + bodyFile: "/tmp/pr-body.md", + }); + + expect(mockRun).toHaveBeenCalledTimes(1); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: expect.arrayContaining([ + "repos/pingdotgg/t3code/pulls", + "head=acme:feature/fork-pr", + "head_repo=t3code", + ]), + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("uses the normal create command for same-repository pull requests", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + yield* gh.createPullRequest({ + cwd: "/repo", + baseBranch: "main", + headSelector: "feature/local-pr", + repository: "pingdotgg/t3code", + headRepository: "pingdotgg/t3code", + title: "Local pull request", + bodyFile: "/tmp/pr-body.md", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "create", + "--base", + "main", + "--head", + "feature/local-pr", + "--title", + "Local pull request", + "--body-file", + "/tmp/pr-body.md", + "--repo", + "github.com/pingdotgg/t3code", + ], + 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 a705b0fb0b3..1f6d18ab35e 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -19,6 +19,51 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; +interface PullRequestRepositoryContext { + readonly baseRepository: string; + readonly headRepository: string; +} + +interface GitHubRepositoryCoordinate { + readonly host: string; + readonly owner: string; + readonly name: string; +} + +function parseGitHubRepositoryCoordinate( + repository: string, +): GitHubRepositoryCoordinate | undefined { + const parts = repository.split("/").filter((part) => part.length > 0); + if (parts.length === 2) { + const [owner, name] = parts; + return owner && name ? { host: "github.com", owner, name } : undefined; + } + if (parts.length === 3) { + const [host, owner, name] = parts; + return host && owner && name ? { host, owner, name } : undefined; + } + return undefined; +} + +function formatGitHubRepositoryCoordinate(coordinate: GitHubRepositoryCoordinate): string { + return `${coordinate.host}/${coordinate.owner}/${coordinate.name}`; +} + +function qualifyPullRequestHead( + context: PullRequestRepositoryContext, + headSelector: string, +): string { + if ( + context.baseRepository.toLowerCase() === context.headRepository.toLowerCase() || + /^[^:/\s]+:.+$/u.test(headSelector) + ) { + return headSelector; + } + + const head = parseGitHubRepositoryCoordinate(context.headRepository); + return head ? `${head.owner}:${headSelector}` : headSelector; +} + const gitHubCliFailureFields = { command: Schema.Literal("gh"), cwd: Schema.String, @@ -77,6 +122,22 @@ export class GitHubCliCommandError extends Schema.TaggedErrorClass()( + "GitHubRepositoryContextDecodeError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + }, +) { + get detail(): string { + return "GitHub CLI returned invalid pull request repository context."; + } + + override get message(): string { + return `GitHub CLI failed in resolvePullRequestRepositoryContext: ${this.detail}`; + } +} + const gitHubCliDecodeFields = { command: Schema.Literal("gh"), cwd: Schema.String, @@ -140,6 +201,7 @@ export const GitHubCliError = Schema.Union([ GitHubCliAuthenticationError, GitHubPullRequestNotFoundError, GitHubCliCommandError, + GitHubRepositoryContextDecodeError, GitHubPullRequestListDecodeError, GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, @@ -212,11 +274,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: { @@ -236,16 +300,20 @@ export class GitHubCli extends Context.Service< readonly headSelector: string; readonly title: string; readonly bodyFile: string; + readonly repository?: string; + readonly headRepository?: 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") {} @@ -322,10 +390,106 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + const resolvePullRequestRepositoryContext = Effect.fn( + "GitHubCli.resolvePullRequestRepositoryContext", + )(function* (input: { + readonly cwd: string; + readonly repository?: string; + readonly headRepository?: string; + }) { + const explicitBase = input.repository + ? parseGitHubRepositoryCoordinate(input.repository) + : undefined; + const explicitHead = input.headRepository + ? parseGitHubRepositoryCoordinate(input.headRepository) + : undefined; + + if (explicitBase && explicitHead) { + return { + baseRepository: formatGitHubRepositoryCoordinate(explicitBase), + headRepository: formatGitHubRepositoryCoordinate(explicitHead), + }; + } + + const result = yield* execute({ + cwd: input.cwd, + args: [ + "repo", + "view", + ...(input.headRepository ? [input.headRepository] : []), + "--json", + "nameWithOwner,parent,url", + "--jq", + '. as $repo | (.url | capture("^https?://(?[^/]+)").host) as $host | [if $repo.parent then "\\($host)/\\($repo.parent.owner.login)/\\($repo.parent.name)" else "\\($host)/\\($repo.nameWithOwner)" end, "\\($host)/\\($repo.nameWithOwner)"] | @tsv', + ], + }); + const [resolvedBaseValue, resolvedHeadValue] = result.stdout.trim().split("\t"); + const resolvedBase = resolvedBaseValue + ? parseGitHubRepositoryCoordinate(resolvedBaseValue) + : undefined; + const resolvedHead = resolvedHeadValue + ? parseGitHubRepositoryCoordinate(resolvedHeadValue) + : undefined; + const base = explicitBase ?? resolvedBase; + const head = explicitHead ?? resolvedHead; + + if (!base || !head) { + return yield* new GitHubRepositoryContextDecodeError({ + command: "gh", + cwd: input.cwd, + }); + } + + return { + baseRepository: formatGitHubRepositoryCoordinate(base), + headRepository: formatGitHubRepositoryCoordinate(head), + }; + }); + return GitHubCli.of({ execute, - listOpenPullRequests: (input) => - execute({ + listOpenPullRequests: (input) => { + const qualifiedHead = /^([^:/\s]+):(.+)$/u.exec(input.headSelector); + const requestedLimit = input.limit ?? 1; + if (qualifiedHead) { + return execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + input.headSelector, + ...(input.repository ? ["--repo", input.repository] : []), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestDecodeError({ + command: "gh", + cwd: input.cwd, + cause: decoded.failure, + }), + ), + ), + ), + ), + Effect.map((pullRequest) => + pullRequest.state === "open" && + pullRequest.headRefName === qualifiedHead[2] && + pullRequest.headRepositoryOwnerLogin?.toLowerCase() === qualifiedHead[1]?.toLowerCase() + ? [pullRequest].map(({ updatedAt: _updatedAt, ...summary }) => summary) + : [], + ), + Effect.catchTags({ GitHubPullRequestNotFoundError: () => Effect.succeed([]) }), + ); + } + return execute({ cwd: input.cwd, args: [ "pr", @@ -335,7 +499,8 @@ export const make = Effect.gen(function* () { "--state", "open", "--limit", - String(input.limit ?? 1), + String(requestedLimit), + ...(input.repository ? ["--repo", input.repository] : []), "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], @@ -357,12 +522,15 @@ export const make = Effect.gen(function* () { } return Effect.succeed( - decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), + decoded.success + .slice(0, requestedLimit) + .map(({ updatedAt: _updatedAt, ...summary }) => summary), ); }), ), ), - ), + ); + }, getPullRequest: (input) => execute({ cwd: input.cwd, @@ -370,6 +538,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", ], @@ -425,25 +594,77 @@ export const make = Effect.gen(function* () { ), ), createPullRequest: (input) => + resolvePullRequestRepositoryContext(input).pipe( + Effect.flatMap((context) => { + const base = parseGitHubRepositoryCoordinate(context.baseRepository); + const head = parseGitHubRepositoryCoordinate(context.headRepository); + if ( + base && + head && + context.baseRepository.toLowerCase() !== context.headRepository.toLowerCase() + ) { + const qualifiedHead = qualifyPullRequestHead(context, input.headSelector); + const qualifiedHeadMatch = /^([^:/\s]+):(.+)$/u.exec(qualifiedHead); + const headOwner = qualifiedHeadMatch?.[1] ?? head.owner; + const headBranch = qualifiedHeadMatch?.[2] ?? qualifiedHead; + return execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + base.host, + `repos/${base.owner}/${base.name}/pulls`, + "--method", + "POST", + "-f", + `title=${input.title}`, + "-f", + `head=${headOwner}:${headBranch}`, + "-f", + `head_repo=${head.name}`, + "-f", + `base=${input.baseBranch}`, + "-F", + `body=@${input.bodyFile}`, + "-F", + "maintainer_can_modify=true", + "--silent", + ], + }); + } + + return execute({ + cwd: input.cwd, + args: [ + "pr", + "create", + "--base", + input.baseBranch, + "--head", + qualifyPullRequestHead(context, input.headSelector), + "--title", + input.title, + "--body-file", + input.bodyFile, + "--repo", + context.baseRepository, + ], + }); + }), + Effect.asVoid, + ), + getDefaultBranch: (input) => execute({ cwd: input.cwd, args: [ - "pr", - "create", - "--base", - input.baseBranch, - "--head", - input.headSelector, - "--title", - input.title, - "--body-file", - input.bodyFile, + "repo", + "view", + ...(input.repository ? [input.repository] : []), + "--json", + "defaultBranchRef", + "--jq", + ".defaultBranchRef.name", ], - }).pipe(Effect.asVoid), - getDefaultBranch: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], }).pipe( Effect.map((value) => { const trimmed = value.stdout.trim(); @@ -453,7 +674,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..2ff2e067ac1 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,114 @@ 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("normalizes a trailing slash after an upstream Git URL suffix", () => + 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("preserves a GitHub Enterprise host in upstream repository coordinates", () => + 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.example.com", + baseUrl: "https://github.example.com", + }, + remoteName: "upstream", + remoteUrl: "git@github.example.com:platform/t3code.git", + }, + headSelector: "contributor:feature/fork-pr", + state: "open", + }); + + assert.strictEqual(repository, "github.example.com/platform/t3code"); + }), +); + +it.effect("preserves a custom GitHub Enterprise port in upstream repository coordinates", () => + 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.example.com:8443", + baseUrl: "https://github.example.com:8443", + }, + remoteName: "upstream", + remoteUrl: "https://github.example.com:8443/platform/t3code.git", + }, + headSelector: "contributor:feature/fork-pr", + state: "open", + }); + + assert.strictEqual(repository, "github.example.com:8443/platform/t3code"); + }), +); + it.effect("treats empty non-open change request listing output as no results", () => Effect.gen(function* () { const provider = yield* makeProvider({ @@ -178,6 +293,45 @@ it.effect("treats empty non-open change request listing output as no results", ( }), ); +it.effect("looks up qualified fork heads directly for non-open listings", () => + Effect.gen(function* () { + let args: ReadonlyArray = []; + const provider = yield* makeProvider({ + execute: (input) => { + args = input.args; + return Effect.succeed( + processResult( + JSON.stringify({ + number: 52, + title: "Requested owner", + url: "https://github.com/pingdotgg/t3code/pull/52", + baseRefName: "main", + headRefName: "feature/shared", + state: "CLOSED", + updatedAt: "2026-01-02T00:00:00.000Z", + headRepositoryOwner: { login: "octocat" }, + }), + ), + ); + }, + }); + + const changeRequests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "octocat:feature/shared", + state: "all", + limit: 1, + }); + + assert.deepStrictEqual( + changeRequests.map((changeRequest) => changeRequest.number), + [52], + ); + assert.include(args.join(" "), "pr view octocat:feature/shared"); + assert.notInclude(args.join(" "), "--limit"); + }), +); + it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { let createInput: Parameters[0] | null = @@ -207,6 +361,47 @@ it.effect("creates GitHub PRs through provider-neutral input names", () => }), ); +it.effect("passes upstream and fork repositories to GitHub PR creation", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = + null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + context: { + provider: { kind: "github", name: "github.com", baseUrl: "https://github.com" }, + remoteName: "upstream", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + source: { + refName: "feature/provider", + owner: "octocat", + repository: "octocat/t3code", + }, + baseRefName: "main", + headSelector: "octocat:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "octocat:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + repository: "pingdotgg/t3code", + headRepository: "octocat/t3code", + }); + }), +); + it("accepts active authenticated GitHub accounts when another account fails", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( processResult( diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8..1059613f13a 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -10,7 +10,10 @@ import { import * as GitHubCli from "./GitHubCli.ts"; import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; -import { decodeGitHubPullRequestListJson } from "./gitHubPullRequests.ts"; +import { + decodeGitHubPullRequestJson, + decodeGitHubPullRequestListJson, +} from "./gitHubPullRequests.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import { combinedAuthOutput, @@ -96,15 +99,49 @@ export const discovery = { export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; + const repositoryFromContext = ( + context: SourceControlProvider.SourceControlProviderContext | undefined, + ) => { + if (context?.remoteName !== "upstream") return undefined; + const scpStyle = /^git@([^:/\s]+):([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i.exec(context.remoteUrl); + if (scpStyle?.[1] && scpStyle[2] && scpStyle[3]) { + return scpStyle[1].toLowerCase() === "github.com" + ? `${scpStyle[2]}/${scpStyle[3]}` + : `${scpStyle[1]}/${scpStyle[2]}/${scpStyle[3]}`; + } + try { + const parsed = new URL(context.remoteUrl); + const [owner, name, ...rest] = parsed.pathname + .replace(/\/+$/, "") + .replace(/\.git$/i, "") + .split("/") + .filter((part) => part.length > 0); + if (!owner || !name || rest.length > 0) return undefined; + return parsed.host.toLowerCase() === "github.com" + ? `${owner}/${name}` + : `${parsed.host}/${owner}/${name}`; + } catch { + return 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)), @@ -126,6 +163,68 @@ export const make = Effect.gen(function* () { } const stateArg: ChangeRequestState | "all" = input.state; + const qualifiedHead = /^([^:/\s]+):(.+)$/u.exec(input.headSelector); + const requestedLimit = input.limit ?? 20; + if (qualifiedHead) { + return github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + input.headSelector, + ...(repository ? ["--repo", repository] : []), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }) + .pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) return Effect.succeed([]); + return Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed( + decoded.success.headRefName === qualifiedHead[2] && + decoded.success.headRepositoryOwnerLogin?.toLowerCase() === + qualifiedHead[1]?.toLowerCase() && + (stateArg === "all" || decoded.success.state === stateArg) + ? [ + { + ...toChangeRequest(decoded.success), + updatedAt: decoded.success.updatedAt, + }, + ] + : [], + ) + : Effect.fail( + new GitHubCli.GitHubChangeRequestListDecodeError({ + command: "gh", + cwd: input.cwd, + cause: decoded.failure, + }), + ), + ), + ); + }), + Effect.catchTags({ GitHubPullRequestNotFoundError: () => Effect.succeed([]) }), + Effect.mapError( + (error) => + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + command: error.command, + cwd: input.cwd, + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + input.headSelector, + ), + detail: error.detail, + cause: error, + }), + ), + ); + } return github .execute({ cwd: input.cwd, @@ -137,7 +236,8 @@ export const make = Effect.gen(function* () { "--state", stateArg, "--limit", - String(input.limit ?? 20), + String(requestedLimit), + ...(repository ? ["--repo", repository] : []), "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], @@ -152,7 +252,7 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => Result.isSuccess(decoded) ? Effect.succeed( - decoded.success.map((item) => ({ + decoded.success.slice(0, requestedLimit).map((item) => ({ ...toChangeRequest(item), updatedAt: item.updatedAt, })), @@ -188,32 +288,48 @@ 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.source?.repository ? { headRepository: input.source.repository } : {}), + }, + input.context, + ), + ) .pipe( Effect.mapError( (error) => @@ -265,7 +381,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 +395,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..38f2bcdbdad 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,65 @@ 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"); + }), +); + +it.effect("does not target an unrelated GitHub upstream repository", () => + 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:someone/other-project.git" }, + ], + github: { + getDefaultBranch: (input) => { + repository = input.repository; + return Effect.succeed("main"); + }, + }, + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + yield* provider.getDefaultBranch({ cwd: "/repo" }); + + assert.strictEqual(repository, undefined); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e43..1ac7da01424 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -9,6 +9,7 @@ import { type SourceControlProviderDiscoveryItem, } from "@t3tools/contracts"; import type { SourceControlProviderKind } from "@t3tools/contracts"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlProvider.ts"; @@ -141,8 +142,33 @@ function selectProviderContext( } } + const origin = candidates.find((candidate) => candidate.remoteName === "origin"); + const repositoryCoordinate = (remoteUrl: string) => { + const [host, owner, name, ...rest] = normalizeGitRemoteUrl(remoteUrl).split("/"); + return host && owner && name && rest.length === 0 ? { host, owner, name } : null; + }; + const conventionalGitHubUpstream = candidates.find((candidate) => { + if ( + candidate.remoteName !== "upstream" || + candidate.provider.kind !== "github" || + origin?.provider.kind !== "github" + ) { + return false; + } + const originRepository = repositoryCoordinate(origin.remoteUrl); + const upstreamRepository = repositoryCoordinate(candidate.remoteUrl); + return ( + originRepository !== null && + upstreamRepository !== null && + originRepository.host === upstreamRepository.host && + originRepository.name === upstreamRepository.name && + originRepository.owner !== upstreamRepository.owner + ); + }); + return ( - candidates.find((candidate) => candidate.remoteName === "origin") ?? + conventionalGitHubUpstream ?? + origin ?? candidates.find((candidate) => candidate.provider.kind !== "unknown") ?? candidates[0] ?? null