authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-10 02:39:17-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 02:39:17-03:00
logc899a0c06ab2b0ec8fe8d6a497bb345ac72a5e5e
tree62d79f69877d649c311d9ddebe017386cb1c0373
parent4107930420cffe14776ad80783f7060236b5868b
signature Signed by PGP key B5690EEEBB952194

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>

8 files changed, 248 insertions(+), 10 deletions(-)

apps/server/src/git/GitManager.test.ts+100
...@@ -4948,6 +4948,106 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -4948,6 +4948,106 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
4948 }),4948 }),
4949 );4949 );
49504950
4951 it.effect("prepares a worktree from the head branch when the host has no pull ref", () =>
4952 Effect.gen(function* () {
4953 const repoDir = yield* makeTempDir("t3code-git-manager-");
4954 yield* initRepo(repoDir);
4955 const remoteDir = yield* createBareRemote();
4956 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
4957 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
4958 yield* runGit(repoDir, ["checkout", "-b", "feature/no-pull-ref"]);
4959 NodeFS.writeFileSync(NodePath.join(repoDir, "head.txt"), "head\n");
4960 yield* runGit(repoDir, ["add", "head.txt"]);
4961 yield* runGit(repoDir, ["commit", "-m", "Head without a pull ref"]);
4962 yield* runGit(repoDir, ["push", "origin", "feature/no-pull-ref"]);
4963 const headSha = (yield* runGit(repoDir, ["rev-parse", "HEAD"])).stdout.trim();
4964 yield* runGit(repoDir, ["checkout", "main"]);
4965 yield* runGit(repoDir, ["branch", "-D", "feature/no-pull-ref"]);
4966 // The remote keeps its Azure spelling, which is what the pull request is compared with.
4967 const remoteUrl = "https://dev.azure.com/org/project/_git/repo";
4968 yield* runGit(repoDir, ["remote", "set-url", "origin", remoteUrl]);
4969 yield* runGit(repoDir, ["config", `url.${remoteDir}.insteadOf`, remoteUrl]);
4970
4971 // A pull request of another repository in the organization is not this remote's branch.
4972 const { manager: otherRepositoryManager } = yield* makeManager({
4973 ghScenario: {
4974 pullRequest: {
4975 number: 79,
4976 title: "Another repository's PR",
4977 url: "https://dev.azure.com/org/project/_git/other/pullrequest/79",
4978 baseRefName: "main",
4979 headRefName: "feature/no-pull-ref",
4980 state: "open",
4981 isCrossRepository: false,
4982 },
4983 },
4984 });
4985 yield* Effect.flip(
4986 preparePullRequestThread(otherRepositoryManager, {
4987 cwd: repoDir,
4988 reference: "79",
4989 mode: "worktree",
4990 }),
4991 );
4992 // Nor is the branch of a closed one, which may have moved past the head it closed with.
4993 const { manager: mergedManager } = yield* makeManager({
4994 ghScenario: {
4995 pullRequest: {
4996 number: 77,
4997 title: "Merged PR",
4998 url: "https://dev.azure.com/org/project/_git/repo/pullrequest/77",
4999 baseRefName: "main",
5000 headRefName: "feature/no-pull-ref",
5001 state: "merged",
5002 isCrossRepository: false,
5003 },
5004 },
5005 });
5006 yield* Effect.flip(
5007 preparePullRequestThread(mergedManager, {
5008 cwd: repoDir,
5009 reference: "77",
5010 mode: "worktree",
5011 }),
5012 );
5013 const localBranches = (yield* runGit(repoDir, ["branch", "--list"])).stdout;
5014 expect(localBranches).not.toContain("feature/no-pull-ref");
5015
5016 const { manager } = yield* makeManager({
5017 ghScenario: {
5018 pullRequest: {
5019 number: 78,
5020 title: "Azure DevOps PR",
5021 url: "https://dev.azure.com/org/project/_git/repo/pullrequest/78",
5022 baseRefName: "main",
5023 headRefName: "feature/no-pull-ref",
5024 state: "open",
5025 isCrossRepository: false,
5026 },
5027 },
5028 });
5029
5030 const result = yield* preparePullRequestThread(manager, {
5031 cwd: repoDir,
5032 reference: "78",
5033 mode: "worktree",
5034 });
5035
5036 expect(result.branch).toBe("feature/no-pull-ref");
5037 const worktreeHead = (yield* runGit(result.worktreePath as string, [
5038 "rev-parse",
5039 "HEAD",
5040 ])).stdout.trim();
5041 expect(worktreeHead).toBe(headSha);
5042 const upstream = (yield* runGit(result.worktreePath as string, [
5043 "rev-parse",
5044 "--abbrev-ref",
5045 "@{upstream}",
5046 ])).stdout.trim();
5047 expect(upstream).toBe("origin/feature/no-pull-ref");
5048 }),
5049 );
5050
4951 it.effect("preserves both branch materialization failures when the fallback also fails", () =>5051 it.effect("preserves both branch materialization failures when the fallback also fails", () =>
4952 Effect.gen(function* () {5052 Effect.gen(function* () {
4953 const repoDir = yield* makeTempDir("t3code-git-manager-");5053 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/git/GitManager.ts+40-5
...@@ -54,6 +54,7 @@ import {...@@ -54,6 +54,7 @@ import {
54 sanitizeFeatureBranchName,54 sanitizeFeatureBranchName,
55} from "@t3tools/shared/git";55} from "@t3tools/shared/git";
56import {56import {
57 canonicalRepositoryKey,
57 getChangeRequestTerminologyForKind,58 getChangeRequestTerminologyForKind,
58 isSshRemoteUrl,59 isSshRemoteUrl,
59 type ChangeRequestTerminology,60 type ChangeRequestTerminology,
...@@ -958,11 +959,45 @@ export const make = Effect.gen(function* () {...@@ -958,11 +959,45 @@ export const make = Effect.gen(function* () {
958 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";959 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
959960
960 if (repositoryNameWithOwner.length === 0) {961 if (repositoryNameWithOwner.length === 0) {
961 yield* gitCore.fetchPullRequestBranch({962 yield* gitCore
962 cwd,963 .fetchPullRequestBranch({
963 prNumber: pullRequest.number,964 cwd,
964 branch: localBranch,965 prNumber: pullRequest.number,
965 });966 branch: localBranch,
967 })
968 .pipe(
969 // Azure DevOps, GitLab and Bitbucket publish no `refs/pull/<n>/head`. A head in the
970 // same repository is a branch on the primary remote, so it is fetched by name instead,
971 // but only when that remote is the pull request's own repository: Azure finds a pull
972 // request by number anywhere in the organization. Only while it is open, too: the
973 // branch of a closed one may have moved on past the head it was closed with.
974 Effect.catch((cause) =>
975 pullRequest.isCrossRepository === true || pullRequest.state !== "open"
976 ? Effect.fail(cause)
977 : Effect.gen(function* () {
978 const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd);
979 const remoteUrl = yield* gitCore.readConfigValue(
980 cwd,
981 `remote.${remoteName}.url`,
982 );
983 const pullRequestKey = pullRequestRepositoryKey(pullRequest.url);
984 if (
985 remoteUrl === null ||
986 pullRequestKey === null ||
987 canonicalRepositoryKey(pullRequestKey) !==
988 canonicalRepositoryKey(normalizeGitRemoteUrl(remoteUrl))
989 ) {
990 return yield* cause;
991 }
992 yield* gitCore.fetchRemoteBranch({
993 cwd,
994 remoteName,
995 remoteBranch: pullRequest.headBranch,
996 localBranch,
997 });
998 }),
999 ),
1000 );
966 return;1001 return;
967 }1002 }
9681003
packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts+25
...@@ -87,6 +87,31 @@ describe("AzureDevOpsCli.layer", () => {...@@ -87,6 +87,31 @@ describe("AzureDevOpsCli.layer", () => {
87 cwd: "/repo",87 cwd: "/repo",
88 timeoutMs: 30_000,88 timeoutMs: 30_000,
89 });89 });
90 assert.strictEqual(result.isCrossRepository, false);
91 }).pipe(Effect.provide(layer)),
92 );
93
94 it.effect("marks a pull request from a fork as cross-repository", () =>
95 Effect.gen(function* () {
96 mockRun.mockReturnValueOnce(
97 Effect.succeed(
98 processOutput(
99 JSON.stringify({
100 pullRequestId: 43,
101 title: "From a fork",
102 sourceRefName: "refs/heads/main",
103 targetRefName: "refs/heads/main",
104 status: "active",
105 forkSource: { name: "refs/heads/main", repository: { name: "repo-fork" } },
106 }),
107 ),
108 ),
109 );
110
111 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
112 const result = yield* az.getPullRequest({ cwd: "/repo", reference: "43" });
113
114 assert.strictEqual(result.isCrossRepository, true);
90 }).pipe(Effect.provide(layer)),115 }).pipe(Effect.provide(layer)),
91 );116 );
92117
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts+40-3
...@@ -451,17 +451,54 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {...@@ -451,17 +451,54 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => {
451 }),451 }),
452 );452 );
453453
454 it.effect("fails when nobody is signed in", () =>454 it.effect("names a token sign-in by the viewer's own pull request", () =>
455 Effect.gen(function* () {455 Effect.gen(function* () {
456 mockedExecute.mockReturnValueOnce(Effect.succeed(output("")));456 const notLoggedIn = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({
457 operation: "execute",
458 command: "az",
459 cwd: "/w",
460 argumentCount: 7,
461 cause: new Error("Please run 'az login' to setup account."),
462 });
463 mockedExecute
464 .mockReturnValueOnce(Effect.fail(notLoggedIn))
465 .mockReturnValueOnce(
466 Effect.succeed(
467 output(
468 JSON.stringify([{ ...pullRequestRow, createdBy: { uniqueName: "bilal@acme.dev" } }]),
469 ),
470 ),
471 )
472 .mockReturnValueOnce(Effect.succeed(output("")))
473 .mockReturnValueOnce(Effect.succeed(output("[]")));
457 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;474 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
458475
476 assert.strictEqual(yield* cli.getViewer({ cwd: "/w" }), "bilal@acme.dev");
477 expect(argsOfCall(1)).toEqual(expect.arrayContaining(["--creator", "me", "--top", "1"]));
478 // Someone who has opened nothing has no name to compare rows against.
459 const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" }));479 const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" }));
460
461 assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError");480 assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError");
462 }),481 }),
463 );482 );
464483
484 it.effect("fails when no sign-in can read pull requests", () =>
485 Effect.gen(function* () {
486 const signedOut = new AzureDevOpsCli.AzureDevOpsCliAuthenticationError({
487 operation: "execute",
488 command: "az",
489 cwd: "/w",
490 argumentCount: 7,
491 cause: new Error("signed out"),
492 });
493 mockedExecute.mockReturnValue(Effect.fail(signedOut));
494 const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli;
495
496 const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" }));
497
498 assert.strictEqual(error._tag, "AzureDevOpsCliAuthenticationError");
499 }),
500 );
501
465 it.effect("completes a pull request to merge it, squashing only when asked", () =>502 it.effect("completes a pull request to merge it, squashing only when asked", () =>
466 Effect.gen(function* () {503 Effect.gen(function* () {
467 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));504 mockedExecute.mockReturnValue(Effect.succeed(output("{}")));
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.ts+36-1
...@@ -59,7 +59,7 @@ export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError<AzureD...@@ -59,7 +59,7 @@ export class AzureDevOpsViewerUnavailableError extends Schema.TaggedError<AzureD
59 },59 },
60) {60) {
61 get detail(): string {61 get detail(): string {
62 return "Azure CLI returned no account for the current sign-in.";62 return "Azure CLI could not name the signed-in account. Run `az login` and retry.";
63 }63 }
6464
65 override get message(): string {65 override get message(): string {
...@@ -544,6 +544,41 @@ export const make = Effect.gen(function* () {...@@ -544,6 +544,41 @@ export const make = Effect.gen(function* () {
544 ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }))544 ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }))
545 : Effect.succeed(decoded.success);545 : Effect.succeed(decoded.success);
546 }),546 }),
547 // `az devops login` signs in with a personal access token, which `az account show` knows
548 // nothing about, and nothing in `az` prints whose token it is. `--creator me` is resolved
549 // by the extension itself, so the viewer's own pull request names them. Someone with none
550 // stays unnamed: `me` would read as nobody everywhere a row is compared with the viewer.
551 Effect.catchIf(
552 (error) => error._tag !== "AzureDevOpsCliUnavailableError",
553 () =>
554 executeJson({
555 cwd: input.cwd,
556 args: [
557 "repos",
558 "pr",
559 "list",
560 ...detectArgs,
561 "--creator",
562 "me",
563 "--status",
564 "all",
565 "--top",
566 "1",
567 ],
568 }).pipe(
569 Effect.flatMap((result): Effect.Effect<string, AzureDevOpsPullRequestCliError> => {
570 const decoded = decodePullRequestListJson(result.stdout.trim() || "[]");
571 const login = Result.isSuccess(decoded)
572 ? decoded.success.items[0]?.author?.login
573 : undefined;
574 return login === undefined
575 ? Effect.fail(
576 new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd }),
577 )
578 : Effect.succeed(login);
579 }),
580 ),
581 ),
547 ),582 ),
548583
549 listPullRequests: (input) =>584 listPullRequests: (input) =>
packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.test.ts+1
...@@ -23,6 +23,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"...@@ -23,6 +23,7 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests"
23 baseRefName: "main",23 baseRefName: "main",
24 headRefName: "feature/source-control",24 headRefName: "feature/source-control",
25 state: "closed",25 state: "closed",
26 isCrossRepository: false,
26 closedAt: "2026-08-23T10:00:00Z",27 closedAt: "2026-08-23T10:00:00Z",
27 updatedAt: Option.none(),28 updatedAt: Option.none(),
28 }),29 }),
packages/source-control-azure-devops/src/server/AzureDevOpsSourceControlProvider.ts+2-1
...@@ -61,6 +61,7 @@ function toChangeRequest(summary: {...@@ -61,6 +61,7 @@ function toChangeRequest(summary: {
61 readonly headRefName: string;61 readonly headRefName: string;
62 readonly state: "open" | "closed" | "merged";62 readonly state: "open" | "closed" | "merged";
63 readonly isDraft?: boolean;63 readonly isDraft?: boolean;
64 readonly isCrossRepository: boolean;
64 readonly closedAt?: string | null;65 readonly closedAt?: string | null;
65 readonly mergedAt?: string | null;66 readonly mergedAt?: string | null;
66 readonly updatedAt: ChangeRequest["updatedAt"];67 readonly updatedAt: ChangeRequest["updatedAt"];
...@@ -77,7 +78,7 @@ function toChangeRequest(summary: {...@@ -77,7 +78,7 @@ function toChangeRequest(summary: {
77 closedAt: summary.closedAt ?? null,78 closedAt: summary.closedAt ?? null,
78 mergedAt: summary.mergedAt ?? null,79 mergedAt: summary.mergedAt ?? null,
79 updatedAt: summary.updatedAt,80 updatedAt: summary.updatedAt,
80 isCrossRepository: false,81 isCrossRepository: summary.isCrossRepository,
81 };82 };
82}83}
8384
packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts+4
...@@ -15,6 +15,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord {...@@ -15,6 +15,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord {
15 readonly headRefName: string;15 readonly headRefName: string;
16 readonly state: "open" | "closed" | "merged";16 readonly state: "open" | "closed" | "merged";
17 readonly isDraft?: boolean;17 readonly isDraft?: boolean;
18 readonly isCrossRepository: boolean;
18 readonly closedAt?: string | null;19 readonly closedAt?: string | null;
19 readonly mergedAt?: string | null;20 readonly mergedAt?: string | null;
20 readonly updatedAt: Option.Option<DateTime.Utc>;21 readonly updatedAt: Option.Option<DateTime.Utc>;
...@@ -39,6 +40,8 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({...@@ -39,6 +40,8 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({
39 targetRefName: TrimmedNonEmptyString,40 targetRefName: TrimmedNonEmptyString,
40 status: Schema.String,41 status: Schema.String,
41 isDraft: Schema.optional(Schema.Boolean),42 isDraft: Schema.optional(Schema.Boolean),
43 /** The fork the head branch lives in. `az` prints `null` for a pull request from the same repo. */
44 forkSource: Schema.optional(Schema.Unknown),
42 creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),45 creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
43 closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),46 closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
44 _links: Schema.optional(47 _links: Schema.optional(
...@@ -178,6 +181,7 @@ function normalizeAzureDevOpsPullRequestRecord(...@@ -178,6 +181,7 @@ function normalizeAzureDevOpsPullRequestRecord(
178 headRefName: normalizeRefName(raw.sourceRefName),181 headRefName: normalizeRefName(raw.sourceRefName),
179 state,182 state,
180 ...(raw.isDraft === true ? { isDraft: true } : {}),183 ...(raw.isDraft === true ? { isDraft: true } : {}),
184 isCrossRepository: raw.forkSource != null,
181 closedAt: state === "closed" ? terminalAt : null,185 closedAt: state === "closed" ? terminalAt : null,
182 mergedAt: state === "merged" ? terminalAt : null,186 mergedAt: state === "merged" ? terminalAt : null,
183 updatedAt: (raw.closedDate ?? Option.none()).pipe(187 updatedAt: (raw.closedDate ?? Option.none()).pipe(