From c899a0c06ab2b0ec8fe8d6a497bb345ac72a5e5e Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 10 Oct 2026 02:39:17 -0300 Subject: [PATCH] fix(azure-devops): list pull requests with token sign-in and check out into worktrees (#17725) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/src/git/GitManager.test.ts | 100 ++++++++++++++++++ apps/server/src/git/GitManager.ts | 45 +++++++- .../src/server/AzureDevOpsCli.test.ts | 25 +++++ .../server/AzureDevOpsPullRequestCli.test.ts | 43 +++++++- .../src/server/AzureDevOpsPullRequestCli.ts | 37 ++++++- .../AzureDevOpsSourceControlProvider.test.ts | 1 + .../AzureDevOpsSourceControlProvider.ts | 3 +- .../src/server/azureDevOpsPullRequests.ts | 4 + 8 files changed, 248 insertions(+), 10 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index c9a08607bc087917bef390f3f8d43bb00f8645e1..79053fbb65eb01e77ec77e06da627e7f140f2e92 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -4948,6 +4948,106 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("prepares a worktree from the head branch when the host has no pull 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/no-pull-ref"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "head.txt"), "head\n"); + yield* runGit(repoDir, ["add", "head.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Head without a pull ref"]); + yield* runGit(repoDir, ["push", "origin", "feature/no-pull-ref"]); + const headSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/no-pull-ref"]); + // The remote keeps its Azure spelling, which is what the pull request is compared with. + const remoteUrl = "https://dev.azure.com/org/project/_git/repo"; + yield* runGit(repoDir, ["remote", "set-url", "origin", remoteUrl]); + yield* runGit(repoDir, ["config", `url.${remoteDir}.insteadOf`, remoteUrl]); + + // A pull request of another repository in the organization is not this remote's branch. + const { manager: otherRepositoryManager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 79, + title: "Another repository's PR", + url: "https://dev.azure.com/org/project/_git/other/pullrequest/79", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "open", + isCrossRepository: false, + }, + }, + }); + yield* Effect.flip( + preparePullRequestThread(otherRepositoryManager, { + cwd: repoDir, + reference: "79", + mode: "worktree", + }), + ); + // Nor is the branch of a closed one, which may have moved past the head it closed with. + const { manager: mergedManager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 77, + title: "Merged PR", + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/77", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "merged", + isCrossRepository: false, + }, + }, + }); + yield* Effect.flip( + preparePullRequestThread(mergedManager, { + cwd: repoDir, + reference: "77", + mode: "worktree", + }), + ); + const localBranches = (yield* runGit(repoDir, ["branch", "--list"])).stdout; + expect(localBranches).not.toContain("feature/no-pull-ref"); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 78, + title: "Azure DevOps PR", + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/78", + baseRefName: "main", + headRefName: "feature/no-pull-ref", + state: "open", + isCrossRepository: false, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "78", + mode: "worktree", + }); + + expect(result.branch).toBe("feature/no-pull-ref"); + const worktreeHead = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "HEAD", + ])).stdout.trim(); + expect(worktreeHead).toBe(headSha); + const upstream = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(); + expect(upstream).toBe("origin/feature/no-pull-ref"); + }), + ); + it.effect("preserves both branch materialization failures when the fallback also fails", () => 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 bd4ee0da7e9d2978a76ed844873da5f83feb6c51..c5d372f8fb2b3075f34fde3ba7642529c237f1cb 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -54,6 +54,7 @@ import { sanitizeFeatureBranchName, } from "@t3tools/shared/git"; import { + canonicalRepositoryKey, getChangeRequestTerminologyForKind, isSshRemoteUrl, type ChangeRequestTerminology, @@ -958,11 +959,45 @@ export const make = Effect.gen(function* () { const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; if (repositoryNameWithOwner.length === 0) { - yield* gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }); + yield* gitCore + .fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }) + .pipe( + // Azure DevOps, GitLab and Bitbucket publish no `refs/pull//head`. A head in the + // same repository is a branch on the primary remote, so it is fetched by name instead, + // but only when that remote is the pull request's own repository: Azure finds a pull + // request by number anywhere in the organization. Only while it is open, too: the + // branch of a closed one may have moved on past the head it was closed with. + Effect.catch((cause) => + pullRequest.isCrossRepository === true || pullRequest.state !== "open" + ? Effect.fail(cause) + : Effect.gen(function* () { + const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); + const remoteUrl = yield* gitCore.readConfigValue( + cwd, + `remote.${remoteName}.url`, + ); + const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); + if ( + remoteUrl === null || + pullRequestKey === null || + canonicalRepositoryKey(pullRequestKey) !== + canonicalRepositoryKey(normalizeGitRemoteUrl(remoteUrl)) + ) { + return yield* cause; + } + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + }), + ), + ); return; } diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts index e4fca9c5ffae0338c69d7732fa6d2046f4f1de68..0a6b4fd6fc57f9ca8e95febd1baf9f18c7ab664c 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts @@ -87,6 +87,31 @@ describe("AzureDevOpsCli.layer", () => { cwd: "/repo", timeoutMs: 30_000, }); + assert.strictEqual(result.isCrossRepository, false); + }).pipe(Effect.provide(layer)), + ); + + it.effect("marks a pull request from a fork as cross-repository", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + pullRequestId: 43, + title: "From a fork", + sourceRefName: "refs/heads/main", + targetRefName: "refs/heads/main", + status: "active", + forkSource: { name: "refs/heads/main", repository: { name: "repo-fork" } }, + }), + ), + ), + ); + + const az = yield* AzureDevOpsCli.AzureDevOpsCli; + const result = yield* az.getPullRequest({ cwd: "/repo", reference: "43" }); + + assert.strictEqual(result.isCrossRepository, true); }).pipe(Effect.provide(layer)), ); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts index b3afaca037159b1b1b15d3f098a5dde75d8aecb2..8ad71188e1876f5034e1fcf21cad19c0ca4cb731 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts @@ -451,17 +451,54 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); - it.effect("fails when nobody is signed in", () => + it.effect("names a token sign-in by the viewer's own pull request", () => Effect.gen(function* () { - mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const notLoggedIn = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 7, + cause: new Error("Please run 'az login' to setup account."), + }); + mockedExecute + .mockReturnValueOnce(Effect.fail(notLoggedIn)) + .mockReturnValueOnce( + Effect.succeed( + output( + JSON.stringify([{ ...pullRequestRow, createdBy: { uniqueName: "bilal@acme.dev" } }]), + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(output(""))) + .mockReturnValueOnce(Effect.succeed(output("[]"))); const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + assert.strictEqual(yield* cli.getViewer({ cwd: "/w" }), "bilal@acme.dev"); + expect(argsOfCall(1)).toEqual(expect.arrayContaining(["--creator", "me", "--top", "1"])); + // Someone who has opened nothing has no name to compare rows against. const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); - assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); }), ); + it.effect("fails when no sign-in can read pull requests", () => + Effect.gen(function* () { + const signedOut = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({ + operation: "execute", + command: "az", + cwd: "/w", + argumentCount: 7, + cause: new Error("signed out"), + }); + mockedExecute.mockReturnValue(Effect.fail(signedOut)); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); + + assert.strictEqual(error._tag, "AzureDevOpsCliAuthenticationError"); + }), + ); + it.effect("completes a pull request to merge it, squashing only when asked", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts index 70fe78b34bf9eb5e1dc39e31d5639e5f3e51b5e3..b7d719725fe3f15cdc71c9f31a2cb7cd71b82ffb 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts @@ -59,7 +59,7 @@ export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError error._tag !== "AzureDevOpsCliUnavailableError", + () => + executeJson({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "list", + ...detectArgs, + "--creator", + "me", + "--status", + "all", + "--top", + "1", + ], + }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodePullRequestListJson(result.stdout.trim() || "[]"); + const login = Result.isSuccess(decoded) + ? decoded.success.items[0]?.author?.login + : undefined; + return login === undefined + ? Effect.fail( + new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }), + ) + : Effect.succeed(login); + }), + ), + ), ), listPullRequests: (input) => diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts index 8e55d453b224b411a0986b4cd153c588a8159f49..f0362851596de784b4612ba1fc82c2a9b4af5f68 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts @@ -23,6 +23,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" baseRefName: "main", headRefName: "feature/source-control", state: "closed", + isCrossRepository: false, closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), }), diff --git a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts index dcb07e0632f3c5646f44df552f25c02aaac495e2..f935fb808709f4223cefcd75cc4e4f2d0ab4b4fa 100644 --- a/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts +++ b/packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts @@ -61,6 +61,7 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly isCrossRepository: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; @@ -77,7 +78,7 @@ function toChangeRequest(summary: { closedAt: summary.closedAt ?? null, mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, - isCrossRepository: false, + isCrossRepository: summary.isCrossRepository, }; } diff --git a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts index 6179a61aa65d093829577491d1216c3a3e514436..7ffc398490cf70ddd977ed02c7468123656286bb 100644 --- a/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts +++ b/packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts @@ -15,6 +15,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly isCrossRepository: boolean; readonly closedAt?: string | null; readonly mergedAt?: string | null; readonly updatedAt: Option.Option; @@ -39,6 +40,8 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ targetRefName: TrimmedNonEmptyString, status: Schema.String, isDraft: Schema.optional(Schema.Boolean), + /** The fork the head branch lives in. `az` prints `null` for a pull request from the same repo. */ + forkSource: Schema.optional(Schema.Unknown), creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), _links: Schema.optional( @@ -178,6 +181,7 @@ function normalizeAzureDevOpsPullRequestRecord( headRefName: normalizeRefName(raw.sourceRefName), state, ...(raw.isDraft === true ? { isDraft: true } : {}), + isCrossRepository: raw.forkSource != null, closedAt: state === "closed" ? terminalAt : null, mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( -- 2.54.0