| author | |
| committer | |
| log | c899a0c06ab2b0ec8fe8d6a497bb345ac72a5e5e |
| tree | 62d79f69877d649c311d9ddebe017386cb1c0373 |
| parent | 4107930420cffe14776ad80783f7060236b5868b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 4948 | }), |
| 4949 | 4949 | ); |
| 4950 | 4950 | |
| 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 | 5051 | it.effect("preserves both branch materialization failures when the fallback also fails", () => |
| 4952 | 5052 | Effect.gen(function* () { |
| 4953 | 5053 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
apps/server/src/git/GitManager.ts+40-5| ... | ... | @@ -54,6 +54,7 @@ import { |
| 54 | 54 | sanitizeFeatureBranchName, |
| 55 | 55 | } from "@t3tools/shared/git"; |
| 56 | 56 | import { |
| 57 | canonicalRepositoryKey, | |
| 57 | 58 | getChangeRequestTerminologyForKind, |
| 58 | 59 | isSshRemoteUrl, |
| 59 | 60 | type ChangeRequestTerminology, |
| ... | ... | @@ -958,11 +959,45 @@ export const make = Effect.gen(function* () { |
| 958 | 959 | const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; |
| 959 | 960 | |
| 960 | 961 | if (repositoryNameWithOwner.length === 0) { |
| 961 | yield* gitCore.fetchPullRequestBranch({ | |
| 962 | cwd, | |
| 963 | prNumber: pullRequest.number, | |
| 964 | branch: localBranch, | |
| 965 | }); | |
| 962 | yield* gitCore | |
| 963 | .fetchPullRequestBranch({ | |
| 964 | cwd, | |
| 965 | prNumber: pullRequest.number, | |
| 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 | 1001 | return; |
| 967 | 1002 | } |
| 968 | 1003 |
packages/source-control-azure-devops/src/server/AzureDevOpsCli.test.ts+25| ... | ... | @@ -87,6 +87,31 @@ describe("AzureDevOpsCli.layer", () => { |
| 87 | 87 | cwd: "/repo", |
| 88 | 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 | 115 | }).pipe(Effect.provide(layer)), |
| 91 | 116 | ); |
| 92 | 117 |
packages/source-control-azure-devops/src/server/AzureDevOpsPullRequestCli.test.ts+40-3| ... | ... | @@ -451,17 +451,54 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { |
| 451 | 451 | }), |
| 452 | 452 | ); |
| 453 | 453 | |
| 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 | 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 | 474 | const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; |
| 458 | 475 | |
| 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 | 479 | const error = yield* Effect.flip(cli.getViewer({ cwd: "/w" })); |
| 460 | ||
| 461 | 480 | assert.strictEqual(error._tag, "AzureDevOpsViewerUnavailableError"); |
| 462 | 481 | }), |
| 463 | 482 | ); |
| 464 | 483 | |
| 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 | 502 | it.effect("completes a pull request to merge it, squashing only when asked", () => |
| 466 | 503 | Effect.gen(function* () { |
| 467 | 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 | 59 | }, |
| 60 | 60 | ) { |
| 61 | 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 | } |
| 64 | 64 | |
| 65 | 65 | override get message(): string { |
| ... | ... | @@ -544,6 +544,41 @@ export const make = Effect.gen(function* () { |
| 544 | 544 | ? Effect.fail(new AzureDevOpsViewerUnavailableError({ command: "az", cwd: input.cwd })) |
| 545 | 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 | ), |
| 548 | 583 | |
| 549 | 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 | 23 | baseRefName: "main", |
| 24 | 24 | headRefName: "feature/source-control", |
| 25 | 25 | state: "closed", |
| 26 | isCrossRepository: false, | |
| 26 | 27 | closedAt: "2026-08-23T10:00:00Z", |
| 27 | 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 | 61 | readonly headRefName: string; |
| 62 | 62 | readonly state: "open" | "closed" | "merged"; |
| 63 | 63 | readonly isDraft?: boolean; |
| 64 | readonly isCrossRepository: boolean; | |
| 64 | 65 | readonly closedAt?: string | null; |
| 65 | 66 | readonly mergedAt?: string | null; |
| 66 | 67 | readonly updatedAt: ChangeRequest["updatedAt"]; |
| ... | ... | @@ -77,7 +78,7 @@ function toChangeRequest(summary: { |
| 77 | 78 | closedAt: summary.closedAt ?? null, |
| 78 | 79 | mergedAt: summary.mergedAt ?? null, |
| 79 | 80 | updatedAt: summary.updatedAt, |
| 80 | isCrossRepository: false, | |
| 81 | isCrossRepository: summary.isCrossRepository, | |
| 81 | 82 | }; |
| 82 | 83 | } |
| 83 | 84 |
packages/source-control-azure-devops/src/server/azureDevOpsPullRequests.ts+4| ... | ... | @@ -15,6 +15,7 @@ export interface NormalizedAzureDevOpsPullRequestRecord { |
| 15 | 15 | readonly headRefName: string; |
| 16 | 16 | readonly state: "open" | "closed" | "merged"; |
| 17 | 17 | readonly isDraft?: boolean; |
| 18 | readonly isCrossRepository: boolean; | |
| 18 | 19 | readonly closedAt?: string | null; |
| 19 | 20 | readonly mergedAt?: string | null; |
| 20 | 21 | readonly updatedAt: Option.Option<DateTime.Utc>; |
| ... | ... | @@ -39,6 +40,8 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ |
| 39 | 40 | targetRefName: TrimmedNonEmptyString, |
| 40 | 41 | status: Schema.String, |
| 41 | 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 | 45 | creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), |
| 43 | 46 | closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), |
| 44 | 47 | _links: Schema.optional( |
| ... | ... | @@ -178,6 +181,7 @@ function normalizeAzureDevOpsPullRequestRecord( |
| 178 | 181 | headRefName: normalizeRefName(raw.sourceRefName), |
| 179 | 182 | state, |
| 180 | 183 | ...(raw.isDraft === true ? { isDraft: true } : {}), |
| 184 | isCrossRepository: raw.forkSource != null, | |
| 181 | 185 | closedAt: state === "closed" ? terminalAt : null, |
| 182 | 186 | mergedAt: state === "merged" ? terminalAt : null, |
| 183 | 187 | updatedAt: (raw.closedDate ?? Option.none()).pipe( |