| 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,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 | 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"; |
| 56 | import { | 56 | import { |
| 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) ?? ""; |
| 959 | 960 | ||
| 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 | } |
| 968 | 1003 |
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 | ); |
| 92 | 117 |
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 | ); |
| 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 | 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; |
| 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 | 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 | ); |
| 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 | 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 | } |
| 64 | 64 | ||
| 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 | ), |
| 548 | 583 | ||
| 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 | } |
| 83 | 84 |
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( |