diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 999a0c202216..cb44c9795d96 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -20,6 +20,7 @@ import { expect } from "vite-plus/test"; import type { GitActionProgressEvent, GitPreparePullRequestThreadInput, + SourceControlProviderKind, ThreadId, } from "@t3tools/contracts"; @@ -36,8 +37,8 @@ import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; +import * as SourceControlProvider from "../sourceControl/SourceControlProvider.ts"; import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; -import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; @@ -624,13 +625,37 @@ function preparePullRequestThread( return manager.preparePullRequestThread(input); } +/** + * Reports the GitHub-backed fake provider as another host. The fake `gh` still answers the + * change-request lookups, so the only thing that changes is the host a change request is + * reported to come from — which is what decides the ref namespace its head is fetched from. + */ +function withProviderKind( + provider: SourceControlProvider.SourceControlProvider["Service"], + kind: SourceControlProviderKind | undefined, +): SourceControlProvider.SourceControlProvider["Service"] { + if (kind === undefined) { + return provider; + } + return { + ...provider, + kind, + getChangeRequest: (getInput) => + provider + .getChangeRequest(getInput) + .pipe(Effect.map((changeRequest) => ({ ...changeRequest, provider: kind }))), + }; +} + function makeManager(input?: { ghScenario?: FakeGhScenario; - sourceControlProvider?: SourceControlProvider["Service"]; + providerKind?: SourceControlProviderKind; + sourceControlProvider?: SourceControlProvider.SourceControlProvider["Service"]; textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; gitConfigReads?: string[]; + pullRequestFetches?: string[]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -640,36 +665,42 @@ function makeManager(input?: { const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); - const vcsDriverLayer = input?.gitConfigReads - ? Layer.effect( - GitVcsDriver.GitVcsDriver, - GitVcsDriver.make.pipe( - Effect.map((service) => - GitVcsDriver.GitVcsDriver.of({ - ...service, - readConfigValue: (cwd, key) => - Effect.sync(() => input.gitConfigReads?.push(key)).pipe( - Effect.andThen(service.readConfigValue(cwd, key)), - ), - }), + const vcsDriverLayer = + input?.gitConfigReads || input?.pullRequestFetches + ? Layer.effect( + GitVcsDriver.GitVcsDriver, + GitVcsDriver.make.pipe( + Effect.map((service) => + GitVcsDriver.GitVcsDriver.of({ + ...service, + fetchPullRequestBranch: (fetchInput) => + Effect.sync(() => input.pullRequestFetches?.push(fetchInput.headRef)).pipe( + Effect.andThen(service.fetchPullRequestBranch(fetchInput)), + ), + readConfigValue: (cwd, key) => + Effect.sync(() => input.gitConfigReads?.push(key)).pipe( + Effect.andThen(service.readConfigValue(cwd, key)), + ), + }), + ), ), - ), - ).pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(serverConfigLayer), - ) - : GitVcsDriver.layer.pipe( - Layer.provideMerge(VcsProcess.layer), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(serverConfigLayer), - ); + ).pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ) + : GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(serverConfigLayer), + ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, (input?.sourceControlProvider === undefined ? GitHubSourceControlProvider.make : Effect.succeed(input.sourceControlProvider) ).pipe( + Effect.map((provider) => withProviderKind(provider, input?.providerKind)), Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), @@ -4504,6 +4535,166 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("prepares GitLab merge request worktrees from the merge request ref", () => + 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, ["checkout", "-b", "feature/mr-worktree"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "merge-request.txt"), "merge request\n"); + yield* runGit(repoDir, ["add", "merge-request.txt"]); + yield* runGit(repoDir, ["commit", "-m", "MR worktree branch"]); + // GitLab publishes the head at refs/merge-requests//head, never under refs/pull, and + // here that ref is the only thing on the remote that names it. + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/merge-requests/533/head"]); + const mergeRequestHead = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/mr-worktree"]); + + const { manager } = yield* makeManager({ + providerKind: "gitlab", + ghScenario: { + pullRequest: { + number: 533, + title: "Worktree MR", + url: "https://gitlab.example.test/group/repo/-/merge_requests/533", + baseRefName: "main", + headRefName: "feature/mr-worktree", + state: "open", + }, + }, + }); + + const created = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "533", + mode: "worktree", + }); + + expect(created.branch).toBe("feature/mr-worktree"); + const worktreePath = created.worktreePath as string; + expect(NodeFS.existsSync(worktreePath)).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( + mergeRequestHead, + ); + + // And the same ref again when the reused worktree is refreshed onto a moved head. + yield* runGit(repoDir, ["fetch", "origin", "refs/merge-requests/533/head"]); + yield* runGit(repoDir, ["checkout", "-b", "mr-author", "FETCH_HEAD"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "merge-request.txt"), "merge request again\n"); + yield* runGit(repoDir, ["add", "merge-request.txt"]); + yield* runGit(repoDir, ["commit", "-m", "New merge request head"]); + yield* runGit(repoDir, ["push", "origin", "mr-author:refs/merge-requests/533/head"]); + const updatedHead = (yield* runGit(repoDir, ["rev-parse", "mr-author"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + + const refreshed = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "533", + mode: "worktree", + }); + + expect(refreshed.worktreePath && NodeFS.realpathSync.native(refreshed.worktreePath)).toBe( + NodeFS.realpathSync.native(worktreePath), + ); + expect(refreshed.isOnPullRequestHead).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe(updatedHead); + }), + ); + + it.effect("prepares worktrees from the head branch on hosts that publish no head ref", () => + 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, ["checkout", "-b", "feature/bitbucket-worktree"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "bitbucket.txt"), "bitbucket\n"); + yield* runGit(repoDir, ["add", "bitbucket.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Bitbucket PR branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/bitbucket-worktree"]); + const pullRequestHead = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + // Nothing local names the head, and Bitbucket publishes no ref for it either: the branch + // on the remote is all there is. + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/bitbucket-worktree"]); + yield* runGit(repoDir, [ + "update-ref", + "-d", + "refs/remotes/origin/feature/bitbucket-worktree", + ]); + + const { manager } = yield* makeManager({ + providerKind: "bitbucket", + ghScenario: { + pullRequest: { + number: 612, + title: "Worktree Bitbucket PR", + url: "https://bitbucket.org/workspace/repo/pull-requests/612", + baseRefName: "main", + headRefName: "feature/bitbucket-worktree", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "612", + mode: "worktree", + }); + + expect(result.branch).toBe("feature/bitbucket-worktree"); + const worktreePath = result.worktreePath as string; + expect(NodeFS.existsSync(worktreePath)).toBe(true); + expect((yield* runGit(worktreePath, ["rev-parse", "HEAD"])).stdout.trim()).toBe( + pullRequestHead, + ); + }), + ); + + it.effect("does not retry a failed head ref fetch without a resolved head repository", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const pullRequestFetches: string[] = []; + const { manager } = yield* makeManager({ + pullRequestFetches, + providerKind: "gitlab", + ghScenario: { + pullRequest: { + number: 533, + title: "Missing merge request ref", + url: "https://gitlab.example.test/group/repo/-/merge_requests/533", + baseRefName: "main", + headRefName: "feature/missing-ref", + state: "open", + }, + }, + }); + + const error = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "533", + mode: "worktree", + }).pipe(Effect.flip); + + expect(pullRequestFetches).toEqual(["refs/merge-requests/533/head"]); + expect(error).toMatchObject({ + _tag: "GitPullRequestMaterializationError", + headRepository: null, + cause: { _tag: "GitCommandError" }, + }); + }), + ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -4563,6 +4754,46 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "surfaces the unresolvable cross-repository head explanation on hosts with no head ref", + () => + 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"]); + + // A cross-repository head on a host that publishes no change-request ref, where the + // repository it was opened from cannot be resolved either: nothing names the head. + const { manager } = yield* makeManager({ + providerKind: "bitbucket", + ghScenario: { + pullRequest: { + number: 613, + title: "Cross-repository Bitbucket PR", + url: "https://bitbucket.org/workspace/repo/pull-requests/613", + baseRefName: "main", + headRefName: "feature/unresolvable-head", + state: "open", + isCrossRepository: true, + }, + }, + }); + + const error = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "613", + mode: "worktree", + }).pipe(Effect.flip); + + if (error._tag !== "GitManagerError") { + return yield* Effect.die(error); + } + expect(error.message).toContain("This host publishes no ref for the change request head"); + }), + ); + it.effect("launches setup when creating a new PR worktree", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f60eb2781872..b1ac1ede54a5 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -59,6 +59,7 @@ import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as SourceControlProvider from "../sourceControl/SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; import type { ChangeRequest } from "@t3tools/contracts"; @@ -140,6 +141,7 @@ const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; const isSourceControlProviderError = Schema.is(SourceControlProviderError); +const isGitManagerError = Schema.is(GitManagerError); /** * How long a failed PR lookup is cached, given the number of consecutive @@ -854,15 +856,39 @@ export const make = Effect.gen(function* () { function* ( cwd: string, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + headRef: string | null, localBranch = pullRequest.headBranch, ) { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { - yield* gitCore.fetchPullRequestBranch({ + if (headRef !== null) { + yield* gitCore.fetchPullRequestBranch({ + cwd, + headRef, + branch: localBranch, + }); + return; + } + + // A host that publishes no change-request ref leaves the head branch on the primary + // remote as the only thing that names the head. A cross-repository head is not on it, + // and a branch there that merely shares its name is somebody else's work. + if (pullRequest.isCrossRepository === true) { + return yield* new GitManagerError({ + operation: "materializePullRequestHeadBranch", + cwd, + detail: + "This host publishes no ref for the change request head, and the repository it was opened from could not be resolved.", + }); + } + + const primaryRemoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + yield* gitCore.fetchRemoteBranch({ cwd, - prNumber: pullRequest.number, - branch: localBranch, + remoteName: primaryRemoteName, + remoteBranch: pullRequest.headBranch, + localBranch, }); return; } @@ -901,35 +927,45 @@ export const make = Effect.gen(function* () { const materializePullRequestHeadBranch = ( cwd: string, pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + headRef: string | null, localBranch = pullRequest.headBranch, - ) => - materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( + ) => { + const materializationError = (cause: unknown) => + new GitPullRequestMaterializationError({ + cwd, + pullRequestNumber: pullRequest.number, + headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), + headBranch: pullRequest.headBranch, + localBranch, + cause, + }); + + return materializePullRequestHeadBranchBase(cwd, pullRequest, headRef, localBranch).pipe( Effect.catch((primaryCause) => - gitCore - .fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }) - .pipe( - Effect.mapError( - (fallbackCause) => - new GitPullRequestMaterializationError({ - cwd, - pullRequestNumber: pullRequest.number, - headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), - headBranch: pullRequest.headBranch, - localBranch, - cause: new AggregateError( - [primaryCause, fallbackCause], - `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, - { cause: primaryCause }, + headRef === null || resolveHeadRepositoryNameWithOwner(pullRequest) === null + ? // Fallback needs both a host ref and a distinct repository-specific primary + // attempt; otherwise it would repeat the same fetch or have no ref to use. + // Preserve deliberate GitManagerError explanations for unresolvable heads. + Effect.fail( + isGitManagerError(primaryCause) ? primaryCause : materializationError(primaryCause), + ) + : gitCore + .fetchPullRequestBranch({ cwd, headRef, branch: localBranch }) + .pipe( + Effect.mapError((fallbackCause) => + materializationError( + new AggregateError( + [primaryCause, fallbackCause], + `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, + { cause: primaryCause }, + ), ), - }), - ), - ), + ), + ), ), ); + }; + const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const canonicalizeExistingPath = (value: string) => fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); @@ -2264,6 +2300,12 @@ export const make = Effect.gen(function* () { reference: normalizedReference, }); const pullRequest = toResolvedPullRequest(pullRequestSummary); + // Which ref names this head is the host's business, and each host has its own namespace + // for it — GitHub's `refs/pull//head` is not GitLab's `refs/merge-requests//head`. + const pullRequestHeadRef = SourceControlProvider.changeRequestHeadRef( + pullRequestSummary.provider, + pullRequest.number, + ); if (input.mode === "local") { yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ @@ -2343,53 +2385,57 @@ export const make = Effect.gen(function* () { yield* ensureExistingWorktreeUpstream(worktreePath); - const refreshed = yield* gitCore - // The pull request's own ref, because it is the only thing that certainly names its - // head. The branch's upstream does not: configuring it is best-effort, so a branch cut - // from `origin/main` whose head branch has since been deleted still resolves — and - // following it would move the checkout onto main and call that the pull request. - .fetchPullRequestHeadCommit({ cwd: worktreePath, prNumber: pullRequest.number }) - .pipe( - // A host that publishes no `refs/pull//head` leaves the remote-tracking branch, - // taken only where it is the head branch's own rather than whatever the checkout - // happened to be cut from. - Effect.catch(() => - Effect.gen(function* () { - const details = yield* gitCore.statusDetails(worktreePath); - if ( - details.upstreamRef === null || - !details.upstreamRef.endsWith(`/${pullRequest.headBranch}`) - ) { - return yield* new GitManagerError({ - operation: "preparePullRequestThread", - cwd: worktreePath, - detail: "The pull request head could not be resolved for this checkout.", - }); - } - return yield* gitCore.resolveCommit({ - cwd: worktreePath, - revision: details.upstreamRef, - }); - }), - ), - Effect.flatMap((target) => - gitCore.refreshCheckedOutBranch({ - cwd: worktreePath, - targetCommit: target.commitSha, - resetWhenHeadCommit: upstreamCommitBeforeFetch, - }), - ), - Effect.catch((error) => - Effect.logWarning( - "GitManager.preparePullRequestThread reused worktree refresh failed", - { - worktreePath, - localBranch: localPullRequestBranch, - cause: error, - }, - ).pipe(Effect.as({ moved: false, onTarget: false })), - ), - ); + // A host that publishes no change-request ref leaves the remote-tracking branch, taken + // only where it is the head branch's own rather than whatever the checkout happened to + // be cut from. + const headCommitFromUpstream = Effect.gen(function* () { + const details = yield* gitCore.statusDetails(worktreePath); + if ( + details.upstreamRef === null || + !details.upstreamRef.endsWith(`/${pullRequest.headBranch}`) + ) { + return yield* new GitManagerError({ + operation: "preparePullRequestThread", + cwd: worktreePath, + detail: "The pull request head could not be resolved for this checkout.", + }); + } + return yield* gitCore.resolveCommit({ + cwd: worktreePath, + revision: details.upstreamRef, + }); + }); + + const refreshed = yield* ( + pullRequestHeadRef === null + ? headCommitFromUpstream + : gitCore + // The pull request's own ref, because it is the only thing that certainly names + // its head. The branch's upstream does not: configuring it is best-effort, so a + // branch cut from `origin/main` whose head branch has since been deleted still + // resolves — and following it would move the checkout onto main and call that + // the pull request. + .fetchPullRequestHeadCommit({ cwd: worktreePath, headRef: pullRequestHeadRef }) + .pipe(Effect.catch(() => headCommitFromUpstream)) + ).pipe( + Effect.flatMap((target) => + gitCore.refreshCheckedOutBranch({ + cwd: worktreePath, + targetCommit: target.commitSha, + resetWhenHeadCommit: upstreamCommitBeforeFetch, + }), + ), + Effect.catch((error) => + Effect.logWarning( + "GitManager.preparePullRequestThread reused worktree refresh failed", + { + worktreePath, + localBranch: localPullRequestBranch, + cause: error, + }, + ).pipe(Effect.as({ moved: false, onTarget: false })), + ), + ); // Only when the checkout actually moved: another thread may be running in this worktree, // and re-running the setup script under it buys nothing when the code did not change. @@ -2456,6 +2502,7 @@ export const make = Effect.gen(function* () { yield* materializePullRequestHeadBranch( input.cwd, pullRequestWithRemoteInfo, + pullRequestHeadRef, localPullRequestBranch, ); diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index 24b28af13fe4..6acc2c92d92f 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -92,6 +92,115 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("preserves fork provenance from pull request view output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 43, + title: "Add Azure fork support", + repository: { + name: "repo", + project: { name: "target-project" }, + }, + sourceRefName: "refs/pull/43/source", + targetRefName: "refs/heads/main", + status: "active", + forkSource: { + name: "refs/heads/feature/from-fork", + repository: { + name: "repo-fork", + project: { name: "fork-project" }, + }, + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ + cwd: "/repo", + reference: "43", + }); + + expect(result).toMatchObject({ + headRefName: "feature/from-fork", + isCrossRepository: true, + headRepositoryNameWithOwner: "fork-project/repo-fork", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("marks incomplete Azure fork metadata as cross-repository", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 44, + title: "Add Azure fork support", + sourceRefName: "refs/pull/44/source", + targetRefName: "refs/heads/main", + status: "active", + forkSource: { + name: null, + repository: { + name: null, + project: null, + }, + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ cwd: "/repo", reference: "44" }); + + expect(result).toMatchObject({ + headRefName: "refs/pull/44/source", + isCrossRepository: true, + }); + expect(result).not.toHaveProperty("headRepositoryNameWithOwner"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("accepts a null Azure fork repository", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + pullRequestId: 45, + title: "Add Azure fork support", + sourceRefName: "refs/pull/45/source", + targetRefName: "refs/heads/main", + status: "active", + forkSource: { + name: "refs/heads/feature/from-fork", + repository: null, + }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ cwd: "/repo", reference: "45" }); + + expect(result).toMatchObject({ + headRefName: "feature/from-fork", + isCrossRepository: true, + }); + expect(result).not.toHaveProperty("headRepositoryNameWithOwner"); + }).pipe(Effect.provide(layer)), + ); + it.effect("builds a web URL when Azure returns only the pull request REST URL", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( @@ -223,6 +332,51 @@ describe("AzureDevOpsCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("looks up a fork repository in its Azure project", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + name: "repo-fork", + webUrl: "https://dev.azure.com/acme/fork-project/_git/repo-fork", + remoteUrl: "https://dev.azure.com/acme/fork-project/_git/repo-fork", + sshUrl: "git@ssh.dev.azure.com:v3/acme/fork-project/repo-fork", + project: { name: "fork-project" }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + yield* az.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "fork-project/repo-fork", + }); + + expect(mockRun).toHaveBeenCalledWith({ + operation: "AzureDevOpsCli.execute", + command: "az", + args: [ + "repos", + "show", + "--detect", + "true", + "--repository", + "repo-fork", + "--project", + "fork-project", + "--only-show-errors", + "--output", + "json", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("creates repositories through Azure Repos", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index f5e436ef6c56..1c79b1241f16 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -460,10 +460,19 @@ export const make = Effect.gen(function* () { ), ), ), - getRepositoryCloneUrls: (input) => - executeJson({ + getRepositoryCloneUrls: (input) => { + const repository = parseRepositorySpecifier(input.repository); + return executeJson({ cwd: input.cwd, - args: ["repos", "show", "--detect", "true", "--repository", input.repository], + args: [ + "repos", + "show", + "--detect", + "true", + "--repository", + repository.name, + ...(repository.project ? ["--project", repository.project] : []), + ], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => @@ -475,7 +484,8 @@ export const make = Effect.gen(function* () { ), ), Effect.map(normalizeRepositoryCloneUrls), - ), + ); + }, createRepository: (input) => { const repository = parseRepositorySpecifier(input.repository); // Azure Repos access is governed by project/organization permissions. diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 8e55d453b224..84c8fc059221 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -25,6 +25,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" state: "closed", closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), + isCrossRepository: false, }), }); @@ -49,6 +50,45 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" }), ); +it.effect("preserves Azure DevOps fork provenance in provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 43, + title: "Add Azure fork support", + url: "https://dev.azure.com/acme/target-project/_git/repo/pullrequest/43", + baseRefName: "main", + headRefName: "feature/from-fork", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork-project/repo-fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "43", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "azure-devops", + number: 43, + title: "Add Azure fork support", + url: "https://dev.azure.com/acme/target-project/_git/repo/pullrequest/43", + baseRefName: "main", + headRefName: "feature/from-fork", + state: "open", + closedAt: null, + mergedAt: null, + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork-project/repo-fork", + }); + }), +); + it.effect("adds change-request context while retaining Azure CLI causes", () => Effect.gen(function* () { const cause = new AzureDevOpsCli.AzureDevOpsCommandFailedError({ diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index c2f6a5efc65a..ae43765c5efc 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -64,6 +64,8 @@ function toChangeRequest(summary: { readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; + readonly isCrossRepository: boolean; + readonly headRepositoryNameWithOwner?: string; }): ChangeRequest { return { provider: "azure-devops", @@ -77,7 +79,10 @@ function toChangeRequest(summary: { closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, - isCrossRepository: false, + isCrossRepository: summary.isCrossRepository, + ...(summary.headRepositoryNameWithOwner + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), }; } diff --git a/apps/server/src/sourceControl/SourceControlProvider.test.ts b/apps/server/src/sourceControl/SourceControlProvider.test.ts index 7e5324882799..35fae2846944 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.test.ts @@ -1,6 +1,9 @@ import { assert, it } from "@effect/vitest"; -import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts"; +import { + changeRequestHeadRef, + transportSafeSourceControlErrorValue, +} from "./SourceControlProvider.ts"; it("removes URL credentials, query parameters, and fragments from error transport values", () => { assert.strictEqual( @@ -17,3 +20,14 @@ it("normalizes control characters and bounds error transport values", () => { `owner/repo ${"x".repeat(245)}`, ); }); + +it("names the change request head ref in each host's own namespace", () => { + assert.strictEqual(changeRequestHeadRef("github", 42), "refs/pull/42/head"); + assert.strictEqual(changeRequestHeadRef("gitlab", 533), "refs/merge-requests/533/head"); +}); + +it("has no change request head ref for hosts that publish none", () => { + assert.strictEqual(changeRequestHeadRef("bitbucket", 42), null); + assert.strictEqual(changeRequestHeadRef("azure-devops", 42), null); + assert.strictEqual(changeRequestHeadRef("unknown", 42), null); +}); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 1844e1bfa7cb..eef76fcb36ce 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -79,6 +79,35 @@ export function sourceControlRefFromInput(input: { return input.source ?? parseSourceControlOwnerRef(input.headSelector); } +/** + * The ref a host publishes on the repository itself for a change request's head, which is the + * only thing that names that head when it lives in a fork or on a branch that has since been + * deleted. Each host has its own namespace, and not every host has one: + * + * - GitHub publishes `refs/pull//head`. + * - GitLab publishes `refs/merge-requests//head`. + * - Azure DevOps publishes only `refs/pull//merge`, the merge result rather than the head. + * - Bitbucket publishes nothing of the kind. + * + * Null for those last two, so callers reach for the head branch on the remote instead of + * fetching a ref that cannot exist. + */ +export function changeRequestHeadRef( + provider: SourceControlProviderKind, + changeRequestNumber: number, +): string | null { + switch (provider) { + case "github": + return `refs/pull/${changeRequestNumber}/head`; + case "gitlab": + return `refs/merge-requests/${changeRequestNumber}/head`; + case "azure-devops": + case "bitbucket": + case "unknown": + return null; + } +} + export class SourceControlProvider extends Context.Service< SourceControlProvider, { diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index d11d1a87077a..7ebd96b0cb6c 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -18,23 +18,39 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: Option.Option; + readonly isCrossRepository: boolean; + readonly headRepositoryNameWithOwner?: string; } +const AzureDevOpsRepositoryIdentitySchema = Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + project: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), +}); + const AzureDevOpsPullRequestSchema = Schema.Struct({ pullRequestId: PositiveInt, title: TrimmedNonEmptyString, url: Schema.optional(Schema.String), repository: Schema.optional( Schema.Struct({ - name: Schema.optional(Schema.String), + ...AzureDevOpsRepositoryIdentitySchema.fields, webUrl: Schema.optional(Schema.String), - project: Schema.optional( - Schema.Struct({ - name: Schema.optional(Schema.String), - }), - ), }), ), + forkSource: Schema.optional( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + repository: Schema.optional(Schema.NullOr(AzureDevOpsRepositoryIdentitySchema)), + }), + ), + ), sourceRefName: TrimmedNonEmptyString, targetRefName: TrimmedNonEmptyString, status: Schema.String, @@ -165,6 +181,12 @@ function normalizeAzureDevOpsPullRequestUrl( function normalizeAzureDevOpsPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedAzureDevOpsPullRequestRecord { + const forkSource = raw.forkSource ?? undefined; + const forkProjectName = trimOptionalString(forkSource?.repository?.project?.name); + const forkRepositoryName = trimOptionalString(forkSource?.repository?.name); + const headRepositoryNameWithOwner = + forkProjectName && forkRepositoryName ? `${forkProjectName}/${forkRepositoryName}` : null; + const state = normalizeAzureDevOpsPullRequestState(raw.status); const terminalAt = Option.match(raw.closedDate ?? Option.none(), { onNone: () => null, @@ -175,7 +197,7 @@ function normalizeAzureDevOpsPullRequestRecord( title: raw.title, url: normalizeAzureDevOpsPullRequestUrl(raw), baseRefName: normalizeRefName(raw.targetRefName), - headRefName: normalizeRefName(raw.sourceRefName), + headRefName: normalizeRefName(trimOptionalString(forkSource?.name) ?? raw.sourceRefName), state, ...(raw.isDraft === true ? { isDraft: true } : {}), closedAt: state === "closed" ? terminalAt : null, @@ -183,6 +205,8 @@ function normalizeAzureDevOpsPullRequestRecord( updatedAt: (raw.closedDate ?? Option.none()).pipe( Option.orElse(() => raw.creationDate ?? Option.none()), ), + isCrossRepository: forkSource !== undefined, + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), }; } diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f1e48a24d6fe..ec2ec3fc3589 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -145,13 +145,18 @@ export interface GitRenameBranchResult { export interface GitFetchPullRequestBranchInput { cwd: string; - prNumber: number; + /** + * The ref the host publishes for the pull request head on the primary remote, which is + * `refs/pull//head` on GitHub and `refs/merge-requests//head` on GitLab. The caller + * names it because only it knows the host. + */ + headRef: string; branch: string; } export interface GitFetchPullRequestHeadCommitInput { cwd: string; - prNumber: number; + headRef: string; } export interface GitResolveCommitInput { @@ -284,7 +289,7 @@ export class GitVcsDriver extends Context.Service< readonly fetchPullRequestBranch: ( input: GitFetchPullRequestBranchInput, ) => Effect.Effect; - /** Fetches `refs/pull//head` without writing a branch, for heads that exist nowhere else. */ + /** Fetches the pull request head ref without writing a branch, for heads that exist nowhere else. */ readonly fetchPullRequestHeadCommit: ( input: GitFetchPullRequestHeadCommitInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3c7e018ddea7..575280d34ebf 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2940,7 +2940,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--quiet", "--no-tags", remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, + `+${input.headRef}:refs/heads/${input.branch}`, ], { fallbackErrorDetail: "git fetch pull request branch failed", @@ -2968,7 +2968,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* executeGit( "GitVcsDriver.fetchPullRequestHeadCommit", input.cwd, - ["fetch", "--quiet", "--no-tags", remoteName, `refs/pull/${input.prNumber}/head`], + ["fetch", "--quiet", "--no-tags", remoteName, input.headRef], { fallbackErrorDetail: "git fetch pull request head failed", },