| author | |
| committer | |
| log | 42d92ec59775c187af69071c81b2b91180810bdd |
| tree | 4932688041805162f1918e6842c912574e7a0168 |
| parent | a21ac5fb153e3bf418ccd2a4abef2787c241813c |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>21 files changed, 1746 insertions(+), 2291 deletions(-)
apps/server/scripts/evaluate-thread-titles.ts+2-2| ... | ... | @@ -23,7 +23,7 @@ import { |
| 23 | 23 | } from "../src/textGeneration/ThreadTitleContext.ts"; |
| 24 | 24 | import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; |
| 25 | 25 | import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; |
| 26 | import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts"; | |
| 26 | import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; | |
| 27 | 27 | import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; |
| 28 | 28 | import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; |
| 29 | 29 | import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; |
| ... | ... | @@ -152,7 +152,7 @@ await Effect.runPromise( |
| 152 | 152 | SourceControlProviderRegistry.layer.pipe( |
| 153 | 153 | Layer.provide( |
| 154 | 154 | Layer.mergeAll( |
| 155 | GitHubRepositoryApi.layer, | |
| 155 | GitHubApi.layerWithDependencies, | |
| 156 | 156 | GitLabCli.layer, |
| 157 | 157 | ForgejoCli.layer, |
| 158 | 158 | AzureDevOpsCli.layer, |
apps/server/src/git/GitManager.test.ts+130-150| ... | ... | @@ -24,6 +24,7 @@ import { TestClock } from "effect/testing"; |
| 24 | 24 | import { ChildProcessSpawner } from "effect/process"; |
| 25 | 25 | import { expect } from "vite-plus/test"; |
| 26 | 26 | import type { |
| 27 | ChangeRequest, | |
| 27 | 28 | GitActionProgressEvent, |
| 28 | 29 | GitManagerServiceError, |
| 29 | 30 | GitPreparePullRequestThreadInput, |
| ... | ... | @@ -37,12 +38,11 @@ import { |
| 37 | 38 | ProjectId, |
| 38 | 39 | ProviderDriverKind, |
| 39 | 40 | ProviderInstanceId, |
| 41 | SourceControlProviderError as SourceControlProviderFailure, | |
| 40 | 42 | TextGenerationError, |
| 41 | 43 | ThreadId, |
| 42 | 44 | } from "@t3tools/contracts"; |
| 43 | 45 | import * as DateTime from "effect/DateTime"; |
| 44 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 45 | import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 46 | 46 | import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; |
| 47 | 47 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 48 | 48 | import * as TextGeneration from "../textGeneration/TextGeneration.ts"; |
| ... | ... | @@ -53,7 +53,6 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; |
| 53 | 53 | import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; |
| 54 | 54 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 55 | 55 | import * as GitWorkflowService from "./GitWorkflowService.ts"; |
| 56 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; | |
| 57 | 56 | import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; |
| 58 | 57 | import { |
| 59 | 58 | ForgejoPullRequestSchema, |
| ... | ... | @@ -92,7 +91,10 @@ interface FakeGhScenario { |
| 92 | 91 | headRepositoryOwnerLogin?: string | null; |
| 93 | 92 | }; |
| 94 | 93 | repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>; |
| 95 | failWith?: GitHubRepositoryApi.GitHubRepositoryApiError; | |
| 94 | /** The detail the provider fails with, the way a GitHub failure reaches GitManager. */ | |
| 95 | failWith?: string; | |
| 96 | /** The raw failure behind `failWith`, which must stay out of anything shown or logged. */ | |
| 97 | failCause?: unknown; | |
| 96 | 98 | /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ |
| 97 | 99 | failAfterCalls?: number; |
| 98 | 100 | } |
| ... | ... | @@ -111,9 +113,12 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; |
| 111 | 113 | |
| 112 | 114 | type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>; |
| 113 | 115 | |
| 114 | function normalizeFakePullRequestSummary( | |
| 115 | raw: unknown, | |
| 116 | ): GitHubRepositoryApi.GitHubPullRequestSummary | null { | |
| 116 | type FakePullRequestSummary = Omit<ChangeRequest, "provider" | "state" | "updatedAt"> & { | |
| 117 | readonly state?: ChangeRequest["state"]; | |
| 118 | readonly updatedAt?: string; | |
| 119 | }; | |
| 120 | ||
| 121 | function normalizeFakePullRequestSummary(raw: unknown): FakePullRequestSummary | null { | |
| 117 | 122 | if (!raw || typeof raw !== "object") { |
| 118 | 123 | return null; |
| 119 | 124 | } |
| ... | ... | @@ -383,8 +388,12 @@ function createTextGeneration( |
| 383 | 388 | }; |
| 384 | 389 | } |
| 385 | 390 | |
| 386 | function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { | |
| 387 | service: GitHubRepositoryApi.GitHubRepositoryApi["Service"]; | |
| 391 | /** | |
| 392 | * A GitHub source control provider over a fake `gh`. The fake still speaks gh's command shapes, | |
| 393 | * which is what `ghCalls` records and the tests assert on; the provider methods translate to them. | |
| 394 | */ | |
| 395 | function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { | |
| 396 | service: SourceControlProvider["Service"]; | |
| 388 | 397 | ghCalls: string[]; |
| 389 | 398 | } { |
| 390 | 399 | const prListQueue = [...(scenario.prListSequence ?? [])]; |
| ... | ... | @@ -396,16 +405,20 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { |
| 396 | 405 | ); |
| 397 | 406 | const ghCalls: string[] = []; |
| 398 | 407 | |
| 399 | // The fake still speaks in gh's command shapes; the service methods below translate to them. | |
| 408 | const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") => | |
| 409 | new SourceControlProviderFailure({ provider: "github", operation, cwd, detail, cause }); | |
| 410 | ||
| 411 | /** `operation` is the provider method the call answers, which a failure reports. */ | |
| 400 | 412 | const execute = (input: { |
| 401 | 413 | readonly cwd: string; |
| 402 | 414 | readonly args: ReadonlyArray<string>; |
| 403 | }): Effect.Effect<VcsProcess.VcsProcessOutput, GitHubRepositoryApi.GitHubRepositoryApiError> => { | |
| 415 | readonly operation: string; | |
| 416 | }): Effect.Effect<VcsProcess.VcsProcessOutput, SourceControlProviderFailure> => { | |
| 404 | 417 | const args = [...input.args]; |
| 405 | 418 | ghCalls.push(args.join(" ")); |
| 406 | 419 | |
| 407 | 420 | if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { |
| 408 | return Effect.fail(scenario.failWith); | |
| 421 | return Effect.fail(fail(input.cwd, scenario.failWith, scenario.failCause, input.operation)); | |
| 409 | 422 | } |
| 410 | 423 | |
| 411 | 424 | if (args[0] === "pr" && args[1] === "list") { |
| ... | ... | @@ -487,13 +500,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { |
| 487 | 500 | } |
| 488 | 501 | return fakeGhOutput(""); |
| 489 | 502 | }, |
| 490 | catch: (error) => | |
| 491 | GitHubRepositoryApi.isGitHubRepositoryApiError(error) | |
| 492 | ? error | |
| 493 | : new GitHubRepositoryApi.GitHubRepositoryCommandError({ | |
| 494 | cwd: input.cwd, | |
| 495 | cause: error, | |
| 496 | }), | |
| 503 | catch: (error) => fail(input.cwd, "GitHub request failed.", error), | |
| 497 | 504 | }); |
| 498 | 505 | } |
| 499 | 506 | |
| ... | ... | @@ -502,12 +509,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { |
| 502 | 509 | if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { |
| 503 | 510 | const cloneUrls = scenario.repositoryCloneUrls?.[repository]; |
| 504 | 511 | if (!cloneUrls) { |
| 505 | return Effect.fail( | |
| 506 | new GitHubRepositoryApi.GitHubRepositoryCommandError({ | |
| 507 | cwd: input.cwd, | |
| 508 | cause: new Error(`Unexpected repository lookup: ${repository}`), | |
| 509 | }), | |
| 510 | ); | |
| 512 | return Effect.fail(fail(input.cwd, `Unexpected repository lookup: ${repository}`)); | |
| 511 | 513 | } |
| 512 | 514 | return Effect.succeed( |
| 513 | 515 | fakeGhOutput( |
| ... | ... | @@ -522,73 +524,86 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { |
| 522 | 524 | return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`)); |
| 523 | 525 | } |
| 524 | 526 | |
| 525 | return Effect.fail( | |
| 526 | new GitHubRepositoryApi.GitHubRepositoryCommandError({ | |
| 527 | cwd: input.cwd, | |
| 528 | cause: new Error(`Unexpected gh command: ${args.join(" ")}`), | |
| 529 | }), | |
| 530 | ); | |
| 527 | return Effect.fail(fail(input.cwd, `Unexpected gh command: ${args.join(" ")}`)); | |
| 531 | 528 | }; |
| 532 | 529 | |
| 530 | /** What GitHubSourceControlProvider makes of a pull request it read. */ | |
| 531 | const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({ | |
| 532 | ...summary, | |
| 533 | provider: "github", | |
| 534 | state: summary.state ?? "open", | |
| 535 | closedAt: summary.closedAt ?? null, | |
| 536 | mergedAt: summary.mergedAt ?? null, | |
| 537 | updatedAt: | |
| 538 | summary.updatedAt === undefined | |
| 539 | ? Option.none() | |
| 540 | : Option.some(DateTime.makeUnsafe(summary.updatedAt)), | |
| 541 | }); | |
| 542 | ||
| 533 | 543 | return { |
| 534 | 544 | service: { |
| 535 | // The fake answers the CLI shape, so batched lookups read it the way the fallback does. | |
| 536 | listPullRequestsByHead: (input) => | |
| 537 | execute({ | |
| 538 | cwd: input.cwd, | |
| 539 | args: [ | |
| 540 | "pr", | |
| 541 | "list", | |
| 542 | "--head", | |
| 543 | input.headSelector, | |
| 544 | "--state", | |
| 545 | input.state, | |
| 546 | "--limit", | |
| 547 | String(input.limit), | |
| 548 | "--json", | |
| 549 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 550 | ], | |
| 551 | }).pipe( | |
| 552 | Effect.map((result) => { | |
| 553 | const raw = result.stdout.trim(); | |
| 554 | if (raw.length === 0) return []; | |
| 555 | const decoded = decodeGitHubPullRequestListJson(raw); | |
| 556 | return Result.isSuccess(decoded) ? decoded.success : []; | |
| 557 | }), | |
| 558 | ), | |
| 559 | listOpenPullRequests: (input) => | |
| 560 | execute({ | |
| 561 | cwd: input.cwd, | |
| 562 | args: [ | |
| 563 | "pr", | |
| 564 | "list", | |
| 565 | "--head", | |
| 566 | input.headSelector, | |
| 567 | "--state", | |
| 568 | "open", | |
| 569 | "--limit", | |
| 570 | String(input.limit ?? 1), | |
| 571 | "--json", | |
| 572 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 573 | ], | |
| 574 | }).pipe( | |
| 575 | Effect.map((result) => JSON.parse(result.stdout) as unknown[]), | |
| 576 | Effect.map((raw) => | |
| 577 | raw | |
| 578 | .map((entry) => normalizeFakePullRequestSummary(entry)) | |
| 579 | .filter( | |
| 580 | (entry): entry is GitHubRepositoryApi.GitHubPullRequestSummary => entry !== null, | |
| 545 | kind: "github", | |
| 546 | listChangeRequests: (input) => | |
| 547 | input.state === "open" | |
| 548 | ? execute({ | |
| 549 | operation: "listChangeRequests", | |
| 550 | cwd: input.cwd, | |
| 551 | args: [ | |
| 552 | "pr", | |
| 553 | "list", | |
| 554 | "--head", | |
| 555 | input.headSelector, | |
| 556 | "--state", | |
| 557 | "open", | |
| 558 | "--limit", | |
| 559 | String(input.limit ?? 1), | |
| 560 | "--json", | |
| 561 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 562 | ], | |
| 563 | }).pipe( | |
| 564 | Effect.map((result) => JSON.parse(result.stdout) as unknown[]), | |
| 565 | Effect.map((raw) => | |
| 566 | raw | |
| 567 | .map((entry) => normalizeFakePullRequestSummary(entry)) | |
| 568 | .filter((entry): entry is FakePullRequestSummary => entry !== null) | |
| 569 | .map(toChangeRequest), | |
| 581 | 570 | ), |
| 582 | ), | |
| 583 | ), | |
| 584 | createPullRequest: (input) => | |
| 571 | ) | |
| 572 | : // The fake answers the CLI shape, so batched lookups read it the way the fallback does. | |
| 573 | execute({ | |
| 574 | operation: "listChangeRequests", | |
| 575 | cwd: input.cwd, | |
| 576 | args: [ | |
| 577 | "pr", | |
| 578 | "list", | |
| 579 | "--head", | |
| 580 | input.headSelector, | |
| 581 | "--state", | |
| 582 | input.state, | |
| 583 | "--limit", | |
| 584 | String(input.limit ?? 20), | |
| 585 | "--json", | |
| 586 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", | |
| 587 | ], | |
| 588 | }).pipe( | |
| 589 | Effect.map((result) => { | |
| 590 | const raw = result.stdout.trim(); | |
| 591 | if (raw.length === 0) return []; | |
| 592 | const decoded = decodeGitHubPullRequestListJson(raw); | |
| 593 | return Result.isSuccess(decoded) | |
| 594 | ? decoded.success.map((record) => ({ provider: "github" as const, ...record })) | |
| 595 | : []; | |
| 596 | }), | |
| 597 | ), | |
| 598 | createChangeRequest: (input) => | |
| 585 | 599 | execute({ |
| 600 | operation: "createChangeRequest", | |
| 586 | 601 | cwd: input.cwd, |
| 587 | 602 | args: [ |
| 588 | 603 | "pr", |
| 589 | 604 | "create", |
| 590 | 605 | "--base", |
| 591 | input.baseBranch, | |
| 606 | input.baseRefName, | |
| 592 | 607 | "--head", |
| 593 | 608 | input.headSelector, |
| 594 | 609 | "--title", |
| ... | ... | @@ -599,6 +614,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { |
| 599 | 614 | }).pipe(Effect.asVoid), |
| 600 | 615 | getDefaultBranch: (input) => |
| 601 | 616 | execute({ |
| 617 | operation: "getDefaultBranch", | |
| 602 | 618 | cwd: input.cwd, |
| 603 | 619 | args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], |
| 604 | 620 | }).pipe( |
| ... | ... | @@ -607,8 +623,9 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { |
| 607 | 623 | return value.length > 0 ? value : null; |
| 608 | 624 | }), |
| 609 | 625 | ), |
| 610 | getPullRequest: (input) => | |
| 626 | getChangeRequest: (input) => | |
| 611 | 627 | execute({ |
| 628 | operation: "getChangeRequest", | |
| 612 | 629 | cwd: input.cwd, |
| 613 | 630 | args: [ |
| 614 | 631 | "pr", |
| ... | ... | @@ -618,24 +635,21 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { |
| 618 | 635 | "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", |
| 619 | 636 | ], |
| 620 | 637 | }).pipe( |
| 621 | Effect.map( | |
| 622 | (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary, | |
| 638 | Effect.map((result) => | |
| 639 | toChangeRequest(JSON.parse(result.stdout) as FakePullRequestSummary), | |
| 623 | 640 | ), |
| 624 | 641 | ), |
| 625 | 642 | getRepositoryCloneUrls: (input) => |
| 626 | 643 | execute({ |
| 644 | operation: "getRepositoryCloneUrls", | |
| 627 | 645 | cwd: input.cwd, |
| 628 | 646 | args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], |
| 629 | 647 | }).pipe(Effect.map((result) => JSON.parse(result.stdout))), |
| 630 | 648 | createRepository: (input) => |
| 631 | Effect.fail( | |
| 632 | new GitHubRepositoryApi.GitHubRepositoryCommandError({ | |
| 633 | cwd: input.cwd, | |
| 634 | cause: new Error(`Unexpected repository create: ${input.repository}`), | |
| 635 | }), | |
| 636 | ), | |
| 637 | checkoutPullRequest: (input) => | |
| 649 | Effect.fail(fail(input.cwd, `Unexpected repository create: ${input.repository}`)), | |
| 650 | checkoutChangeRequest: (input) => | |
| 638 | 651 | execute({ |
| 652 | operation: "checkoutChangeRequest", | |
| 639 | 653 | cwd: input.cwd, |
| 640 | 654 | args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], |
| 641 | 655 | }).pipe(Effect.asVoid), |
| ... | ... | @@ -693,7 +707,7 @@ function makeManager(input?: { |
| 693 | 707 | ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2 |
| 694 | 708 | >; |
| 695 | 709 | }) { |
| 696 | const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh( | |
| 710 | const { service: fakeGitHubProvider, ghCalls } = createGitHubProviderWithFakeGh( | |
| 697 | 711 | input?.ghScenario, |
| 698 | 712 | ); |
| 699 | 713 | const textGeneration = createTextGeneration(input?.textGeneration); |
| ... | ... | @@ -729,10 +743,7 @@ function makeManager(input?: { |
| 729 | 743 | ); |
| 730 | 744 | const layerSourceControlRegistry = Layer.effect( |
| 731 | 745 | SourceControlProviderRegistry.SourceControlProviderRegistry, |
| 732 | (input?.sourceControlProvider === undefined | |
| 733 | ? GitHubSourceControlProvider.make | |
| 734 | : Effect.succeed(input.sourceControlProvider) | |
| 735 | ).pipe( | |
| 746 | Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe( | |
| 736 | 747 | Effect.map((provider) => |
| 737 | 748 | SourceControlProviderRegistry.SourceControlProviderRegistry.of({ |
| 738 | 749 | resolveLink: (input) => provider.resolveLink?.(input), |
| ... | ... | @@ -742,12 +753,6 @@ function makeManager(input?: { |
| 742 | 753 | discover: Effect.succeed([]), |
| 743 | 754 | }), |
| 744 | 755 | ), |
| 745 | Effect.provide( | |
| 746 | Layer.merge( | |
| 747 | Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi), | |
| 748 | Layer.mock(GitHubApi.GitHubApi)({}), | |
| 749 | ), | |
| 750 | ), | |
| 751 | 756 | ), |
| 752 | 757 | ); |
| 753 | 758 | |
| ... | ... | @@ -1300,10 +1305,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 1300 | 1305 | yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); |
| 1301 | 1306 | const { manager, ghCalls } = yield* makeManager({ |
| 1302 | 1307 | ghScenario: { |
| 1303 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 1304 | cwd: repoDir, | |
| 1305 | cause: new Error("rate limited"), | |
| 1306 | }), | |
| 1308 | failWith: | |
| 1309 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 1307 | 1310 | }, |
| 1308 | 1311 | }); |
| 1309 | 1312 | yield* manager.remoteStatus({ cwd: repoDir }); |
| ... | ... | @@ -1782,10 +1785,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 1782 | 1785 | |
| 1783 | 1786 | const { manager, ghCalls } = yield* makeManager({ |
| 1784 | 1787 | ghScenario: { |
| 1785 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 1786 | cwd: repoDir, | |
| 1787 | cause: new Error("gh is not available on PATH"), | |
| 1788 | }), | |
| 1788 | failWith: | |
| 1789 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 1789 | 1790 | }, |
| 1790 | 1791 | }); |
| 1791 | 1792 | |
| ... | ... | @@ -2642,10 +2643,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2642 | 2643 | }, |
| 2643 | 2644 | ]), |
| 2644 | 2645 | }, |
| 2645 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2646 | cwd: repoDir, | |
| 2647 | cause: new Error("rate limited"), | |
| 2648 | }), | |
| 2646 | failWith: | |
| 2647 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 2649 | 2648 | failAfterCalls: 1, |
| 2650 | 2649 | }, |
| 2651 | 2650 | }); |
| ... | ... | @@ -2718,10 +2717,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2718 | 2717 | |
| 2719 | 2718 | const { manager } = yield* makeManager({ |
| 2720 | 2719 | ghScenario: { |
| 2721 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2722 | cwd: repoDir, | |
| 2723 | cause: new Error("gh is not available on PATH"), | |
| 2724 | }), | |
| 2720 | failWith: | |
| 2721 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 2725 | 2722 | }, |
| 2726 | 2723 | }); |
| 2727 | 2724 | |
| ... | ... | @@ -2743,10 +2740,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2743 | 2740 | const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; |
| 2744 | 2741 | const { manager } = yield* makeManager({ |
| 2745 | 2742 | ghScenario: { |
| 2746 | failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({ | |
| 2747 | cwd: repoDir, | |
| 2748 | cause: new Error(upstreamCause), | |
| 2749 | }), | |
| 2743 | failWith: "GitHub API rate limit exceeded. Requests resume when the limit resets.", | |
| 2744 | failCause: new Error(upstreamCause), | |
| 2750 | 2745 | }, |
| 2751 | 2746 | }); |
| 2752 | 2747 | const logs: Array<{ message: string; annotations: Record<string, unknown> }> = []; |
| ... | ... | @@ -2799,10 +2794,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2799 | 2794 | const { manager } = yield* makeManager({ |
| 2800 | 2795 | ghScenario: { |
| 2801 | 2796 | prListSequence: [JSON.stringify([existingPr])], |
| 2802 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2803 | cwd: repoDir, | |
| 2804 | cause: new Error("rate limited"), | |
| 2805 | }), | |
| 2797 | failWith: | |
| 2798 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 2806 | 2799 | failAfterCalls: 1, |
| 2807 | 2800 | }, |
| 2808 | 2801 | }); |
| ... | ... | @@ -2841,10 +2834,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2841 | 2834 | const { manager } = yield* makeManager({ |
| 2842 | 2835 | ghScenario: { |
| 2843 | 2836 | prListSequence: [JSON.stringify([existingPr])], |
| 2844 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2845 | cwd: repoDir, | |
| 2846 | cause: new Error("rate limited"), | |
| 2847 | }), | |
| 2837 | failWith: | |
| 2838 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 2848 | 2839 | failAfterCalls: 1, |
| 2849 | 2840 | }, |
| 2850 | 2841 | }); |
| ... | ... | @@ -2888,10 +2879,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2888 | 2879 | const { manager } = yield* makeManager({ |
| 2889 | 2880 | ghScenario: { |
| 2890 | 2881 | prListSequence: [JSON.stringify([existingPr])], |
| 2891 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2892 | cwd: repoDir, | |
| 2893 | cause: new Error("rate limited"), | |
| 2894 | }), | |
| 2882 | failWith: | |
| 2883 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 2895 | 2884 | failAfterCalls: 1, |
| 2896 | 2885 | }, |
| 2897 | 2886 | }); |
| ... | ... | @@ -2926,10 +2915,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2926 | 2915 | const { manager } = yield* makeManager({ |
| 2927 | 2916 | ghScenario: { |
| 2928 | 2917 | prListSequence: [JSON.stringify([existingPr])], |
| 2929 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2930 | cwd: repoDir, | |
| 2931 | cause: new Error("rate limited"), | |
| 2932 | }), | |
| 2918 | failWith: | |
| 2919 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 2933 | 2920 | failAfterCalls: 1, |
| 2934 | 2921 | }, |
| 2935 | 2922 | }); |
| ... | ... | @@ -2967,10 +2954,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 2967 | 2954 | const { manager } = yield* makeManager({ |
| 2968 | 2955 | ghScenario: { |
| 2969 | 2956 | prListSequence: [JSON.stringify([existingPr])], |
| 2970 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 2971 | cwd: repoDir, | |
| 2972 | cause: new Error("rate limited"), | |
| 2973 | }), | |
| 2957 | failWith: | |
| 2958 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 2974 | 2959 | failAfterCalls: 1, |
| 2975 | 2960 | }, |
| 2976 | 2961 | }); |
| ... | ... | @@ -4611,10 +4596,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 4611 | 4596 | |
| 4612 | 4597 | const { manager } = yield* makeManager({ |
| 4613 | 4598 | ghScenario: { |
| 4614 | failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ | |
| 4615 | cwd: repoDir, | |
| 4616 | cause: new Error("gh is not available on PATH"), | |
| 4617 | }), | |
| 4599 | failWith: | |
| 4600 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 4618 | 4601 | }, |
| 4619 | 4602 | }); |
| 4620 | 4603 | |
| ... | ... | @@ -4640,10 +4623,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { |
| 4640 | 4623 | |
| 4641 | 4624 | const { manager } = yield* makeManager({ |
| 4642 | 4625 | ghScenario: { |
| 4643 | failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({ | |
| 4644 | cwd: repoDir, | |
| 4645 | cause: new Error("gh is not authenticated"), | |
| 4646 | }), | |
| 4626 | failWith: "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", | |
| 4647 | 4627 | }, |
| 4648 | 4628 | }); |
| 4649 | 4629 |
apps/server/src/orchestration-v2/ThreadPullRequestService.ts+1-1| ... | ... | @@ -345,7 +345,7 @@ export const make = Effect.gen(function* () { |
| 345 | 345 | }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), |
| 346 | 346 | ), |
| 347 | 347 | ), |
| 348 | // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one | |
| 348 | // Wide enough that a sweep's GitHub branch lookups reach the GitHub provider together and share one | |
| 349 | 349 | // GraphQL document, instead of one `gh pr list` per branch. |
| 350 | 350 | { concurrency: 32, discard: true }, |
| 351 | 351 | ); |
apps/server/src/pullRequest/GitHubPullRequestApi.test.ts+1-1| ... | ... | @@ -12,7 +12,7 @@ import * as Redacted from "effect/Redacted"; |
| 12 | 12 | |
| 13 | 13 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 14 | 14 | |
| 15 | import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 15 | import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; | |
| 16 | 16 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 17 | 17 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; |
| 18 | 18 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; |
apps/server/src/pullRequest/GitHubPullRequestApi.ts+1-2| ... | ... | @@ -42,7 +42,6 @@ import { |
| 42 | 42 | type PullRequestPreview, |
| 43 | 43 | } from "@t3tools/contracts"; |
| 44 | 44 | |
| 45 | import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 46 | 45 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 47 | 46 | import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; |
| 48 | 47 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| ... | ... | @@ -1524,7 +1523,7 @@ export const make = Effect.gen(function* () { |
| 1524 | 1523 | |
| 1525 | 1524 | const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { |
| 1526 | 1525 | const { owner, name } = parseRepositorySelector(input.repository); |
| 1527 | return AllowGitHubReserve.pipe( | |
| 1526 | return GitHubApi.AllowGitHubReserve.pipe( | |
| 1528 | 1527 | Effect.flatMap((allowReserve) => |
| 1529 | 1528 | graphqlRead({ |
| 1530 | 1529 | allowReserve, |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+1-2| ... | ... | @@ -7,7 +7,6 @@ import type { PullRequestReaction } from "@t3tools/contracts"; |
| 7 | 7 | |
| 8 | 8 | import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; |
| 9 | 9 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 10 | import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 11 | 10 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| 12 | 11 | import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; |
| 13 | 12 | import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; |
| ... | ... | @@ -726,7 +725,7 @@ describe("getViewerPermissions", () => { |
| 726 | 725 | Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ |
| 727 | 726 | revalidateChecks: (_input, read) => read, |
| 728 | 727 | getPullRequestDetail: () => |
| 729 | GitHubRepositoryApi.AllowGitHubReserve.pipe( | |
| 728 | GitHubApi.AllowGitHubReserve.pipe( | |
| 730 | 729 | Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), |
| 731 | 730 | Effect.flatMap(() => detail), |
| 732 | 731 | ), |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+2-2| ... | ... | @@ -7,7 +7,7 @@ import type { |
| 7 | 7 | PullRequestViewerPermissions, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | |
| 10 | import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 10 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; | |
| 11 | 11 | import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; |
| 12 | 12 | import { |
| 13 | 13 | PullRequestProviderError, |
| ... | ... | @@ -526,7 +526,7 @@ export const make = Effect.gen(function* () { |
| 526 | 526 | // comparison, so one read usually answers what used to take three. When that heavier read |
| 527 | 527 | // fails, the light access read still answers, withholding only update-branch. |
| 528 | 528 | return cli.getPullRequestDetail(input).pipe( |
| 529 | Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true), | |
| 529 | Effect.provideService(GitHubApi.AllowGitHubReserve, true), | |
| 530 | 530 | Effect.map((pullRequest) => |
| 531 | 531 | gitHubViewerPermissions({ |
| 532 | 532 | ...pullRequest.viewerAccess, |
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+2-9| ... | ... | @@ -6,9 +6,6 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; |
| 6 | 6 | import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; |
| 7 | 7 | import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; |
| 8 | 8 | import * as GitHubApi from "../sourceControl/GitHubApi.ts"; |
| 9 | import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; | |
| 10 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | |
| 11 | import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; | |
| 12 | 9 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 13 | 10 | import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; |
| 14 | 11 | import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; |
| ... | ... | @@ -63,12 +60,8 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( |
| 63 | 60 | Layer.provide( |
| 64 | 61 | GitHubPullRequestApi.layer.pipe( |
| 65 | 62 | Layer.provide( |
| 66 | GitHubApi.layer.pipe( | |
| 67 | Layer.provide(GitHubCredentials.layer), | |
| 68 | // The same layers GitHubRepositoryApi merges, so both share one budget and one pause per host. | |
| 69 | Layer.provide(GitHubGraphQlBudget.layer), | |
| 70 | Layer.provide(SourceControlRateLimit.layer), | |
| 71 | ), | |
| 63 | // Built here too, so a registry built alone still has one budget and pause per host. | |
| 64 | GitHubApi.layerWithDependencies, | |
| 72 | 65 | ), |
| 73 | 66 | ), |
| 74 | 67 | ), |
apps/server/src/pullRequest/PullRequestService.ts+1-1| ... | ... | @@ -77,7 +77,7 @@ import { |
| 77 | 77 | import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; |
| 78 | 78 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; |
| 79 | 79 | |
| 80 | import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; | |
| 80 | import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; | |
| 81 | 81 | import * as ProjectService from "../project/ProjectService.ts"; |
| 82 | 82 | import * as ServerSettings from "../serverSettings.ts"; |
| 83 | 83 | import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; |
apps/server/src/server.ts+3-3| ... | ... | @@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| 50 | 50 | import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; |
| 51 | 51 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| 52 | 52 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 53 | import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; | |
| 53 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; | |
| 54 | 54 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 55 | 55 | import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; |
| 56 | 56 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| ... | ... | @@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p |
| 270 | 270 | Layer.mergeAll( |
| 271 | 271 | AzureDevOpsCli.layer, |
| 272 | 272 | BitbucketApi.layer, |
| 273 | GitHubRepositoryApi.layer, | |
| 273 | GitHubApi.layerWithDependencies, | |
| 274 | 274 | GitLabCli.layer, |
| 275 | 275 | ForgejoCli.layer, |
| 276 | 276 | ), |
| ... | ... | @@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 565 | 565 | Layer.provideMerge(layerServerSettings), |
| 566 | 566 | // The asset route uses the registry's GitHub credential for private PR media. |
| 567 | 567 | Layer.provideMerge(layerSourceControlProviderRegistry), |
| 568 | Layer.provideMerge(GitHubRepositoryApi.layer), | |
| 568 | Layer.provideMerge(GitHubApi.layerWithDependencies), | |
| 569 | 569 | Layer.provideMerge(layerGit), |
| 570 | 570 | Layer.provideMerge(layerVcs), |
| 571 | 571 | Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), |
apps/server/src/sourceControl/GitHubApi.ts+10| ... | ... | @@ -598,3 +598,13 @@ export const make = Effect.gen(function* () { |
| 598 | 598 | }); |
| 599 | 599 | |
| 600 | 600 | export const layer = Layer.effect(GitHubApi, make); |
| 601 | ||
| 602 | /** | |
| 603 | * The transport with the credential, GraphQL budget and rate-limit pause it reads through. An | |
| 604 | * application builds this once so every GitHub reader shares one budget and one pause per host. | |
| 605 | */ | |
| 606 | export const layerWithDependencies = layer.pipe( | |
| 607 | Layer.provideMerge(GitHubCredentials.layer), | |
| 608 | Layer.provideMerge(GitHubGraphQlBudget.layer), | |
| 609 | Layer.provideMerge(SourceControlRateLimit.layer), | |
| 610 | ); |
apps/server/src/sourceControl/GitHubCredentials.ts-4| ... | ... | @@ -81,10 +81,6 @@ export type GitHubCredentialUnavailableError = |
| 81 | 81 | | GitHubHostDisabledError |
| 82 | 82 | | GitHubCliFailedError; |
| 83 | 83 | |
| 84 | export const isGitHubCredentialUnavailableError = Schema.is( | |
| 85 | Schema.Union([GitHubCliMissingError, GitHubNotSignedInError, GitHubHostDisabledError]), | |
| 86 | ); | |
| 87 | ||
| 88 | 84 | /** |
| 89 | 85 | * Where GitHub tokens come from. Callers ask per host and never see how the token was found, |
| 90 | 86 | * so another source (an in-app OAuth login) slots in here without touching any of them. |
apps/server/src/sourceControl/GitHubRepositoryApi.test.ts deleted-651| ... | ... | @@ -1,651 +0,0 @@ |
| 1 | import { assert, it, describe } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as TestClock from "effect/testing/TestClock"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { ChildProcessSpawner } from "effect/process"; | |
| 10 | ||
| 11 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 12 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 13 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 14 | import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; | |
| 15 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 16 | ||
| 17 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 18 | ||
| 19 | const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ | |
| 20 | exitCode: ChildProcessSpawner.ExitCode(exitCode), | |
| 21 | stdout, | |
| 22 | stderr: "", | |
| 23 | stdoutTruncated: false, | |
| 24 | stderrTruncated: false, | |
| 25 | }); | |
| 26 | ||
| 27 | const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 28 | entries | |
| 29 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 30 | .join("\n"); | |
| 31 | ||
| 32 | const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ | |
| 33 | status, | |
| 34 | headers: {}, | |
| 35 | body: body === undefined ? "" : encodeJson(body), | |
| 36 | truncated: false, | |
| 37 | invalidUtf8: false, | |
| 38 | }); | |
| 39 | ||
| 40 | const node = (number: number, headRefName: string, owner = "acme") => ({ | |
| 41 | number, | |
| 42 | title: `PR ${number}`, | |
| 43 | url: `https://github.com/acme/web/pull/${number}`, | |
| 44 | baseRefName: "main", | |
| 45 | headRefName, | |
| 46 | state: "OPEN", | |
| 47 | isCrossRepository: owner !== "acme", | |
| 48 | updatedAt: "2026-01-02T00:00:00Z", | |
| 49 | headRepository: { name: "web", nameWithOwner: `${owner}/web` }, | |
| 50 | headRepositoryOwner: { login: owner }, | |
| 51 | }); | |
| 52 | ||
| 53 | /** | |
| 54 | * A GitHubRepositoryApi over a mocked GitHubApi, git driver and process. `remotes` is what | |
| 55 | * `git remote -v` prints; `git` records every driver call. | |
| 56 | */ | |
| 57 | function harness(input: { | |
| 58 | readonly remotes: string; | |
| 59 | readonly api: Partial<GitHubApi.GitHubApi["Service"]>; | |
| 60 | readonly localBranches?: ReadonlyArray<string>; | |
| 61 | }) { | |
| 62 | const git: Array<readonly [string, unknown]> = []; | |
| 63 | const record = | |
| 64 | <A>(name: string, value: A) => | |
| 65 | (args: unknown) => | |
| 66 | Effect.sync(() => { | |
| 67 | git.push([name, args]); | |
| 68 | return value; | |
| 69 | }); | |
| 70 | const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ | |
| 71 | execute: (args) => | |
| 72 | Effect.sync(() => { | |
| 73 | git.push(["execute", args.args]); | |
| 74 | return processOutput(""); | |
| 75 | }), | |
| 76 | resolvePrimaryRemoteName: () => Effect.succeed("origin"), | |
| 77 | readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), | |
| 78 | ensureRemote: (args) => | |
| 79 | Effect.sync(() => { | |
| 80 | git.push(["ensureRemote", args]); | |
| 81 | return args.preferredName; | |
| 82 | }), | |
| 83 | fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), | |
| 84 | setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), | |
| 85 | switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, | |
| 86 | listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), | |
| 87 | resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), | |
| 88 | }); | |
| 89 | const process = Layer.mock(VcsProcess.VcsProcess)({ | |
| 90 | run: (args) => | |
| 91 | Effect.succeed( | |
| 92 | args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), | |
| 93 | ), | |
| 94 | }); | |
| 95 | const layer = Layer.effect( | |
| 96 | GitHubRepositoryApi.GitHubRepositoryApi, | |
| 97 | GitHubRepositoryApi.make, | |
| 98 | ).pipe( | |
| 99 | Layer.provide( | |
| 100 | Layer.mergeAll( | |
| 101 | driver, | |
| 102 | process, | |
| 103 | Layer.mock(GitHubApi.GitHubApi)(input.api), | |
| 104 | NodeServices.layer, | |
| 105 | ), | |
| 106 | ), | |
| 107 | ); | |
| 108 | return { layer, git }; | |
| 109 | } | |
| 110 | ||
| 111 | describe("selectGitHubBaseRepository", () => { | |
| 112 | const remotes = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 113 | entries | |
| 114 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 115 | .join("\n"); | |
| 116 | const select = (input: { remotes: string; resolved?: string }) => | |
| 117 | GitHubRepositoryApi.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); | |
| 118 | ||
| 119 | it("picks the repository gh reads without a prompt", () => { | |
| 120 | assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { | |
| 121 | owner: "me", | |
| 122 | name: "web", | |
| 123 | }); | |
| 124 | const fork = remotes( | |
| 125 | ["origin", "git@github.com:me/web.git"], | |
| 126 | ["upstream", "https://github.com/Acme/Web.git"], | |
| 127 | ); | |
| 128 | assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); | |
| 129 | assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { | |
| 130 | owner: "me", | |
| 131 | name: "web", | |
| 132 | }); | |
| 133 | assert.deepStrictEqual( | |
| 134 | select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), | |
| 135 | { owner: "acme", name: "other" }, | |
| 136 | ); | |
| 137 | // gh ranks remote names in any case. | |
| 138 | assert.deepStrictEqual( | |
| 139 | select({ | |
| 140 | remotes: remotes( | |
| 141 | ["origin", "git@github.com:me/web.git"], | |
| 142 | ["Upstream", "git@github.com:acme/web.git"], | |
| 143 | ), | |
| 144 | }), | |
| 145 | { owner: "acme", name: "web" }, | |
| 146 | ); | |
| 147 | }); | |
| 148 | ||
| 149 | it("leaves gh to choose when it might weigh the remotes differently", () => { | |
| 150 | for (const input of [ | |
| 151 | { remotes: "" }, | |
| 152 | { | |
| 153 | remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), | |
| 154 | }, | |
| 155 | { | |
| 156 | remotes: remotes( | |
| 157 | ["origin", "git@github.com:me/web.git"], | |
| 158 | ["mirror", "git@gitlab.com:me/web.git"], | |
| 159 | ), | |
| 160 | }, | |
| 161 | { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, | |
| 162 | { | |
| 163 | remotes: remotes( | |
| 164 | ["origin", "git@github.com:me/web.git"], | |
| 165 | ["Origin", "git@github.com:acme/web.git"], | |
| 166 | ), | |
| 167 | }, | |
| 168 | { | |
| 169 | remotes: remotes( | |
| 170 | ["origin", "git@github.com:me/web.git"], | |
| 171 | ["upstream", "git@github.com:acme/web.git"], | |
| 172 | ), | |
| 173 | resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", | |
| 174 | }, | |
| 175 | ]) { | |
| 176 | assert.strictEqual(select(input), null); | |
| 177 | } | |
| 178 | }); | |
| 179 | }); | |
| 180 | ||
| 181 | describe("GitHubRepositoryApi repository resolution", () => { | |
| 182 | it.effect("reads the repository gh would pick from the remotes", () => { | |
| 183 | const paths: string[] = []; | |
| 184 | const { layer } = harness({ | |
| 185 | remotes: remotesOutput( | |
| 186 | ["origin", "git@github.com:me/web.git"], | |
| 187 | ["upstream", "https://github.com/acme/web.git"], | |
| 188 | ), | |
| 189 | api: { | |
| 190 | rest: (input) => | |
| 191 | Effect.sync(() => { | |
| 192 | paths.push(`${input.host} ${input.path}`); | |
| 193 | return restResponse({ | |
| 194 | full_name: "acme/web", | |
| 195 | html_url: "https://github.com/acme/web", | |
| 196 | ssh_url: "git@github.com:acme/web.git", | |
| 197 | default_branch: "trunk", | |
| 198 | }); | |
| 199 | }), | |
| 200 | }, | |
| 201 | }); | |
| 202 | return Effect.gen(function* () { | |
| 203 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 204 | assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); | |
| 205 | assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); | |
| 206 | }).pipe(Effect.provide(layer)); | |
| 207 | }); | |
| 208 | ||
| 209 | it.effect("reads an SSH alias remote through github.com", () => { | |
| 210 | const hosts: string[] = []; | |
| 211 | const { layer } = harness({ | |
| 212 | remotes: remotesOutput(["origin", "git@github:acme/web.git"]), | |
| 213 | api: { | |
| 214 | rest: (input) => | |
| 215 | Effect.sync(() => { | |
| 216 | hosts.push(`${input.host} ${input.path}`); | |
| 217 | return restResponse({ | |
| 218 | full_name: "acme/web", | |
| 219 | html_url: "https://github.com/acme/web", | |
| 220 | ssh_url: "git@github.com:acme/web.git", | |
| 221 | default_branch: "main", | |
| 222 | }); | |
| 223 | }), | |
| 224 | }, | |
| 225 | }); | |
| 226 | return Effect.gen(function* () { | |
| 227 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 228 | yield* gh.getDefaultBranch({ cwd: "/repo" }); | |
| 229 | // A provider's host hint for the same alias (`github` here) resolves the same way. | |
| 230 | yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); | |
| 231 | assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); | |
| 232 | assert.strictEqual( | |
| 233 | GitHubRepositoryApi.gitHubApiHostForRemote("git@github.example.com:a/b.git"), | |
| 234 | "github.example.com", | |
| 235 | ); | |
| 236 | assert.strictEqual( | |
| 237 | GitHubRepositoryApi.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), | |
| 238 | null, | |
| 239 | ); | |
| 240 | }).pipe(Effect.provide(layer)); | |
| 241 | }); | |
| 242 | ||
| 243 | it.effect("fails clearly when no remote is on GitHub", () => { | |
| 244 | const { layer } = harness({ | |
| 245 | remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), | |
| 246 | api: {}, | |
| 247 | }); | |
| 248 | return Effect.gen(function* () { | |
| 249 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 250 | const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); | |
| 251 | assert.strictEqual(error._tag, "GitHubRepositoryCommandError"); | |
| 252 | assert.include(String((error.cause as Error).message), "No GitHub repository"); | |
| 253 | }).pipe(Effect.provide(layer)); | |
| 254 | }); | |
| 255 | }); | |
| 256 | ||
| 257 | describe("GitHubRepositoryApi.listPullRequestsByHead", () => { | |
| 258 | const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); | |
| 259 | ||
| 260 | it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { | |
| 261 | const documents: Array<GitHubApi.GitHubGraphQlInput> = []; | |
| 262 | const { layer } = harness({ | |
| 263 | remotes, | |
| 264 | api: { | |
| 265 | graphql: (input) => | |
| 266 | Effect.sync(() => { | |
| 267 | documents.push(input); | |
| 268 | return encodeJson({ | |
| 269 | data: { | |
| 270 | repository: { | |
| 271 | h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, | |
| 272 | h1: { nodes: [] }, | |
| 273 | }, | |
| 274 | }, | |
| 275 | }); | |
| 276 | }), | |
| 277 | }, | |
| 278 | }); | |
| 279 | return Effect.gen(function* () { | |
| 280 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 281 | const lookup = (headSelector: string) => | |
| 282 | gh | |
| 283 | .listPullRequestsByHead({ | |
| 284 | cwd: "/repo", | |
| 285 | headSelector, | |
| 286 | state: "all", | |
| 287 | limit: 100, | |
| 288 | rateLimitHost: "github.com", | |
| 289 | }) | |
| 290 | .pipe(Effect.forkChild); | |
| 291 | // Each branch's own git reads come first, so a sweep's lookups arrive spread out. | |
| 292 | const firstLookup = yield* lookup("feature/a"); | |
| 293 | yield* TestClock.adjust("200 millis"); | |
| 294 | const secondLookup = yield* lookup("feature/b"); | |
| 295 | yield* TestClock.adjust("300 millis"); | |
| 296 | const first = yield* Fiber.join(firstLookup); | |
| 297 | const second = yield* Fiber.join(secondLookup); | |
| 298 | assert.deepStrictEqual( | |
| 299 | first?.map((pr) => pr.number), | |
| 300 | [7], | |
| 301 | ); | |
| 302 | assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); | |
| 303 | assert.deepStrictEqual(second, []); | |
| 304 | assert.strictEqual(documents.length, 1); | |
| 305 | assert.include(documents[0]!.query, "headRefOid"); | |
| 306 | assert.deepStrictEqual(documents[0]!.variables, { | |
| 307 | owner: "acme", | |
| 308 | name: "web", | |
| 309 | h0: "feature/a", | |
| 310 | s0: ["OPEN", "CLOSED", "MERGED"], | |
| 311 | h1: "feature/b", | |
| 312 | s1: ["OPEN", "CLOSED", "MERGED"], | |
| 313 | }); | |
| 314 | }).pipe(Effect.provide(layer)); | |
| 315 | }); | |
| 316 | ||
| 317 | it.effect("caps a background document at twenty-five heads", () => { | |
| 318 | const headCounts: Array<number> = []; | |
| 319 | const { layer } = harness({ | |
| 320 | remotes, | |
| 321 | api: { | |
| 322 | graphql: (input) => | |
| 323 | Effect.sync(() => { | |
| 324 | const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); | |
| 325 | headCounts.push(heads.length); | |
| 326 | return encodeJson({ | |
| 327 | data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, | |
| 328 | }); | |
| 329 | }), | |
| 330 | }, | |
| 331 | }); | |
| 332 | return Effect.gen(function* () { | |
| 333 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 334 | const lookups = yield* Effect.all( | |
| 335 | Array.from({ length: 26 }, (_, index) => | |
| 336 | gh.listPullRequestsByHead({ | |
| 337 | cwd: "/repo", | |
| 338 | headSelector: `feature/${index}`, | |
| 339 | state: "all", | |
| 340 | limit: 100, | |
| 341 | rateLimitHost: "github.com", | |
| 342 | }), | |
| 343 | ), | |
| 344 | { concurrency: "unbounded" }, | |
| 345 | ).pipe(Effect.forkChild); | |
| 346 | yield* TestClock.adjust("500 millis"); | |
| 347 | yield* Fiber.join(lookups); | |
| 348 | assert.deepStrictEqual( | |
| 349 | headCounts.toSorted((a, b) => a - b), | |
| 350 | [1, 25], | |
| 351 | ); | |
| 352 | }).pipe(Effect.provide(layer)); | |
| 353 | }); | |
| 354 | ||
| 355 | it.effect("matches an owner:branch selector on the head owner", () => { | |
| 356 | const { layer } = harness({ | |
| 357 | remotes, | |
| 358 | api: { | |
| 359 | graphql: (input) => | |
| 360 | Effect.succeed( | |
| 361 | encodeJson({ | |
| 362 | data: { | |
| 363 | repository: { | |
| 364 | h0: { | |
| 365 | nodes: | |
| 366 | input.variables?.h0 === "main" | |
| 367 | ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] | |
| 368 | : [], | |
| 369 | }, | |
| 370 | }, | |
| 371 | }, | |
| 372 | }), | |
| 373 | ), | |
| 374 | }, | |
| 375 | }); | |
| 376 | return Effect.gen(function* () { | |
| 377 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 378 | const open = yield* gh | |
| 379 | .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) | |
| 380 | .pipe(Effect.forkChild); | |
| 381 | yield* TestClock.adjust("50 millis"); | |
| 382 | assert.deepStrictEqual( | |
| 383 | (yield* Fiber.join(open)).map((pr) => pr.number), | |
| 384 | [8], | |
| 385 | ); | |
| 386 | }).pipe(Effect.provide(layer)); | |
| 387 | }); | |
| 388 | ||
| 389 | it.effect("maps API failures onto the errors callers handle", () => { | |
| 390 | const { layer } = harness({ | |
| 391 | remotes, | |
| 392 | api: { | |
| 393 | graphql: (input) => | |
| 394 | Effect.fail( | |
| 395 | input.variables?.h0 === "missing" | |
| 396 | ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) | |
| 397 | : new GitHubApi.GitHubApiRateLimitError({ | |
| 398 | host: "github.com", | |
| 399 | operation: "x", | |
| 400 | retryAt: 123, | |
| 401 | }), | |
| 402 | ), | |
| 403 | }, | |
| 404 | }); | |
| 405 | return Effect.gen(function* () { | |
| 406 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 407 | const read = (headSelector: string) => | |
| 408 | gh | |
| 409 | .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) | |
| 410 | .pipe(Effect.flip, Effect.forkChild); | |
| 411 | const missing = yield* read("missing"); | |
| 412 | yield* TestClock.adjust("500 millis"); | |
| 413 | assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubRepositoryUnavailableError"); | |
| 414 | const limited = yield* read("limited"); | |
| 415 | yield* TestClock.adjust("500 millis"); | |
| 416 | const error = yield* Fiber.join(limited); | |
| 417 | assert.strictEqual(error._tag, "GitHubRepositoryRateLimitError"); | |
| 418 | assert.propertyVal(error, "retryAt", 123); | |
| 419 | }).pipe(Effect.provide(layer)); | |
| 420 | }); | |
| 421 | }); | |
| 422 | ||
| 423 | describe("GitHubRepositoryApi.getPullRequest", () => { | |
| 424 | it.effect("reads a pull request by number, and by URL on its own repository", () => { | |
| 425 | const variables: Array<unknown> = []; | |
| 426 | const { layer } = harness({ | |
| 427 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 428 | api: { | |
| 429 | graphql: (input) => | |
| 430 | Effect.sync(() => { | |
| 431 | variables.push(input.variables); | |
| 432 | return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); | |
| 433 | }), | |
| 434 | }, | |
| 435 | }); | |
| 436 | return Effect.gen(function* () { | |
| 437 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 438 | assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); | |
| 439 | yield* gh.getPullRequest({ | |
| 440 | cwd: "/repo", | |
| 441 | reference: "https://github.com/other/thing/pull/42", | |
| 442 | }); | |
| 443 | assert.deepStrictEqual(variables, [ | |
| 444 | { owner: "acme", name: "web", number: 42 }, | |
| 445 | { owner: "other", name: "thing", number: 42 }, | |
| 446 | ]); | |
| 447 | }).pipe(Effect.provide(layer)); | |
| 448 | }); | |
| 449 | ||
| 450 | it.effect("fails a missing pull request as not found", () => { | |
| 451 | const { layer } = harness({ | |
| 452 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 453 | api: { | |
| 454 | graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), | |
| 455 | }, | |
| 456 | }); | |
| 457 | return Effect.gen(function* () { | |
| 458 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 459 | const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); | |
| 460 | assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); | |
| 461 | }).pipe(Effect.provide(layer)); | |
| 462 | }); | |
| 463 | }); | |
| 464 | ||
| 465 | describe("GitHubRepositoryApi writes", () => { | |
| 466 | it.effect("creates a cross-repository pull request with an owner:branch head", () => { | |
| 467 | const requests: Array<GitHubApi.GitHubRestInput> = []; | |
| 468 | const { layer } = harness({ | |
| 469 | remotes: remotesOutput( | |
| 470 | ["origin", "git@github.com:me/web.git"], | |
| 471 | ["upstream", "git@github.com:acme/web.git"], | |
| 472 | ), | |
| 473 | api: { | |
| 474 | rest: (input) => | |
| 475 | Effect.sync(() => { | |
| 476 | requests.push(input); | |
| 477 | return restResponse({ number: 1 }, 201); | |
| 478 | }), | |
| 479 | }, | |
| 480 | }); | |
| 481 | return Effect.gen(function* () { | |
| 482 | const fs = yield* FileSystem.FileSystem; | |
| 483 | const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); | |
| 484 | yield* fs.writeFileString(bodyFile, "Body"); | |
| 485 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 486 | yield* gh.createPullRequest({ | |
| 487 | cwd: "/repo", | |
| 488 | baseBranch: "main", | |
| 489 | headSelector: "me:feature", | |
| 490 | title: "Title", | |
| 491 | bodyFile, | |
| 492 | }); | |
| 493 | assert.strictEqual(requests[0]!.method, "POST"); | |
| 494 | assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); | |
| 495 | assert.deepStrictEqual(requests[0]!.body, { | |
| 496 | base: "main", | |
| 497 | head: "me:feature", | |
| 498 | title: "Title", | |
| 499 | body: "Body", | |
| 500 | maintainer_can_modify: true, | |
| 501 | }); | |
| 502 | }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); | |
| 503 | }); | |
| 504 | ||
| 505 | it.effect("creates a repository under an organization the viewer is not", () => { | |
| 506 | const requests: Array<string> = []; | |
| 507 | const { layer } = harness({ | |
| 508 | remotes: "", | |
| 509 | api: { | |
| 510 | rest: (input) => | |
| 511 | Effect.sync(() => { | |
| 512 | requests.push(`${input.method ?? "GET"} ${input.path}`); | |
| 513 | return input.path === "user" | |
| 514 | ? restResponse({ login: "me" }) | |
| 515 | : restResponse({ | |
| 516 | full_name: "acme/new", | |
| 517 | html_url: "https://github.com/acme/new", | |
| 518 | ssh_url: "git@github.com:acme/new.git", | |
| 519 | }); | |
| 520 | }), | |
| 521 | }, | |
| 522 | }); | |
| 523 | return Effect.gen(function* () { | |
| 524 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 525 | const urls = yield* gh.createRepository({ | |
| 526 | cwd: "/repo", | |
| 527 | repository: "acme/new", | |
| 528 | visibility: "private", | |
| 529 | }); | |
| 530 | assert.deepStrictEqual(urls, { | |
| 531 | nameWithOwner: "acme/new", | |
| 532 | url: "https://github.com/acme/new", | |
| 533 | sshUrl: "git@github.com:acme/new.git", | |
| 534 | }); | |
| 535 | assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); | |
| 536 | }).pipe(Effect.provide(layer)); | |
| 537 | }); | |
| 538 | }); | |
| 539 | ||
| 540 | describe("GitHubRepositoryApi.checkoutPullRequest", () => { | |
| 541 | const repository = (fullName: string, defaultBranch = "main") => | |
| 542 | restResponse({ | |
| 543 | full_name: fullName, | |
| 544 | html_url: `https://github.com/${fullName}`, | |
| 545 | ssh_url: `git@github.com:${fullName}.git`, | |
| 546 | default_branch: defaultBranch, | |
| 547 | }); | |
| 548 | ||
| 549 | it.effect("checks a same-repository pull request out from its head branch", () => { | |
| 550 | const { layer, git } = harness({ | |
| 551 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 552 | api: { | |
| 553 | graphql: () => | |
| 554 | Effect.succeed( | |
| 555 | encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), | |
| 556 | ), | |
| 557 | }, | |
| 558 | }); | |
| 559 | return Effect.gen(function* () { | |
| 560 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 561 | yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); | |
| 562 | assert.deepStrictEqual(git, [ | |
| 563 | [ | |
| 564 | "fetchRemoteTrackingBranch", | |
| 565 | { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 566 | ], | |
| 567 | ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], | |
| 568 | ["switchRef", { cwd: "/repo", refName: "feature/x" }], | |
| 569 | [ | |
| 570 | "setBranchUpstream", | |
| 571 | { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 572 | ], | |
| 573 | ]); | |
| 574 | }).pipe(Effect.provide(layer)); | |
| 575 | }); | |
| 576 | ||
| 577 | it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { | |
| 578 | const { layer, git } = harness({ | |
| 579 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 580 | localBranches: ["main"], | |
| 581 | api: { | |
| 582 | graphql: () => | |
| 583 | Effect.succeed( | |
| 584 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 585 | ), | |
| 586 | rest: () => | |
| 587 | Effect.fail( | |
| 588 | new GitHubApi.GitHubApiRequestError({ | |
| 589 | host: "github.com", | |
| 590 | operation: "x", | |
| 591 | cause: "offline", | |
| 592 | }), | |
| 593 | ), | |
| 594 | }, | |
| 595 | }); | |
| 596 | return Effect.gen(function* () { | |
| 597 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 598 | yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); | |
| 599 | // Nothing touched the local branches: `main` must not be reset to the fork's commit. | |
| 600 | assert.deepStrictEqual(git, []); | |
| 601 | }).pipe(Effect.provide(layer)); | |
| 602 | }); | |
| 603 | ||
| 604 | it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { | |
| 605 | const { layer, git } = harness({ | |
| 606 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 607 | localBranches: ["someone/main"], | |
| 608 | api: { | |
| 609 | graphql: () => | |
| 610 | Effect.succeed( | |
| 611 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 612 | ), | |
| 613 | rest: (input) => | |
| 614 | Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), | |
| 615 | }, | |
| 616 | }); | |
| 617 | return Effect.gen(function* () { | |
| 618 | const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 619 | yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); | |
| 620 | assert.deepStrictEqual(git, [ | |
| 621 | [ | |
| 622 | "ensureRemote", | |
| 623 | { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, | |
| 624 | ], | |
| 625 | [ | |
| 626 | "fetchRemoteTrackingBranch", | |
| 627 | { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, | |
| 628 | ], | |
| 629 | ["switchRef", { cwd: "/repo", refName: "someone/main" }], | |
| 630 | ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], | |
| 631 | [ | |
| 632 | "setBranchUpstream", | |
| 633 | { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, | |
| 634 | ], | |
| 635 | ]); | |
| 636 | }).pipe(Effect.provide(layer)); | |
| 637 | }); | |
| 638 | ||
| 639 | it("names the local branch the way gh pr checkout does", () => { | |
| 640 | const name = (headRefName: string, isCrossRepository: boolean) => | |
| 641 | GitHubRepositoryApi.pullRequestCheckoutBranchName({ | |
| 642 | headRefName, | |
| 643 | headOwner: "someone", | |
| 644 | isCrossRepository, | |
| 645 | defaultBranch: "main", | |
| 646 | }); | |
| 647 | assert.strictEqual(name("main", true), "someone/main"); | |
| 648 | assert.strictEqual(name("feature", true), "feature"); | |
| 649 | assert.strictEqual(name("main", false), "main"); | |
| 650 | }); | |
| 651 | }); |
apps/server/src/sourceControl/GitHubRepositoryApi.ts deleted-1086| ... | ... | @@ -1,1086 +0,0 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Request from "effect/Request"; | |
| 9 | import * as RequestResolver from "effect/RequestResolver"; | |
| 10 | import * as Result from "effect/Result"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | ||
| 13 | import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; | |
| 14 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 15 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 16 | import { | |
| 17 | detectSourceControlProviderFromRemoteUrl, | |
| 18 | isSshRemoteUrl, | |
| 19 | } from "@t3tools/shared/sourceControl"; | |
| 20 | ||
| 21 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 22 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 23 | ||
| 24 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 25 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 26 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 27 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | |
| 28 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | |
| 29 | import { | |
| 30 | decodeGitHubPullRequestEntries, | |
| 31 | type NormalizedGitHubPullRequestRecord, | |
| 32 | } from "./gitHubPullRequests.ts"; | |
| 33 | ||
| 34 | export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; | |
| 35 | ||
| 36 | const gitHubFailureFields = { | |
| 37 | cwd: Schema.String, | |
| 38 | cause: Schema.Defect(), | |
| 39 | } as const; | |
| 40 | ||
| 41 | export class GitHubRepositoryUnavailableError extends Schema.TaggedError<GitHubRepositoryUnavailableError>()( | |
| 42 | "GitHubRepositoryUnavailableError", | |
| 43 | gitHubFailureFields, | |
| 44 | ) { | |
| 45 | override get message(): string { | |
| 46 | return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | export class GitHubRepositoryAuthenticationError extends Schema.TaggedError<GitHubRepositoryAuthenticationError>()( | |
| 51 | "GitHubRepositoryAuthenticationError", | |
| 52 | gitHubFailureFields, | |
| 53 | ) { | |
| 54 | override get message(): string { | |
| 55 | // A missing or turned-off credential already says what to do about it. | |
| 56 | return GitHubCredentials.isGitHubCredentialUnavailableError(this.cause) | |
| 57 | ? this.cause.message | |
| 58 | : "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry."; | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | export class GitHubRepositoryRateLimitError extends Schema.TaggedError<GitHubRepositoryRateLimitError>()( | |
| 63 | "GitHubRepositoryRateLimitError", | |
| 64 | { ...gitHubFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, | |
| 65 | ) { | |
| 66 | override get message(): string { | |
| 67 | return "GitHub API rate limit exceeded. Requests resume when the limit resets."; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | export class GitHubPullRequestNotFoundError extends Schema.TaggedError<GitHubPullRequestNotFoundError>()( | |
| 72 | "GitHubPullRequestNotFoundError", | |
| 73 | gitHubFailureFields, | |
| 74 | ) { | |
| 75 | override get message(): string { | |
| 76 | return "Pull request not found. Check the PR number or URL and try again."; | |
| 77 | } | |
| 78 | } | |
| 79 | ||
| 80 | export class GitHubRepositoryCommandError extends Schema.TaggedError<GitHubRepositoryCommandError>()( | |
| 81 | "GitHubRepositoryCommandError", | |
| 82 | { ...gitHubFailureFields, httpStatus: Schema.optional(Schema.Int) }, | |
| 83 | ) { | |
| 84 | override get message(): string { | |
| 85 | // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. | |
| 86 | const reason = | |
| 87 | this.cause instanceof Error && this.cause.message.trim() !== "" | |
| 88 | ? this.cause.message.trim() | |
| 89 | : null; | |
| 90 | return reason === null ? "GitHub request failed." : reason; | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | const gitHubDecodeFields = { | |
| 95 | cwd: Schema.String, | |
| 96 | cause: Schema.Defect(), | |
| 97 | } as const; | |
| 98 | ||
| 99 | export class GitHubPullRequestListDecodeError extends Schema.TaggedError<GitHubPullRequestListDecodeError>()( | |
| 100 | "GitHubPullRequestListDecodeError", | |
| 101 | gitHubDecodeFields, | |
| 102 | ) { | |
| 103 | override get message(): string { | |
| 104 | return "GitHub returned an invalid pull request list."; | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | export class GitHubChangeRequestListDecodeError extends Schema.TaggedError<GitHubChangeRequestListDecodeError>()( | |
| 109 | "GitHubChangeRequestListDecodeError", | |
| 110 | gitHubDecodeFields, | |
| 111 | ) { | |
| 112 | override get message(): string { | |
| 113 | return "GitHub returned an invalid change request list."; | |
| 114 | } | |
| 115 | } | |
| 116 | ||
| 117 | export class GitHubPullRequestDecodeError extends Schema.TaggedError<GitHubPullRequestDecodeError>()( | |
| 118 | "GitHubPullRequestDecodeError", | |
| 119 | gitHubDecodeFields, | |
| 120 | ) { | |
| 121 | override get message(): string { | |
| 122 | return "GitHub returned an invalid pull request."; | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubRepositoryDecodeError>()( | |
| 127 | "GitHubRepositoryDecodeError", | |
| 128 | gitHubDecodeFields, | |
| 129 | ) { | |
| 130 | override get message(): string { | |
| 131 | return "GitHub returned an invalid repository."; | |
| 132 | } | |
| 133 | } | |
| 134 | ||
| 135 | export const GitHubRepositoryApiError = Schema.Union([ | |
| 136 | GitHubRepositoryUnavailableError, | |
| 137 | GitHubRepositoryAuthenticationError, | |
| 138 | GitHubRepositoryRateLimitError, | |
| 139 | GitHubPullRequestNotFoundError, | |
| 140 | GitHubRepositoryCommandError, | |
| 141 | GitHubPullRequestListDecodeError, | |
| 142 | GitHubChangeRequestListDecodeError, | |
| 143 | GitHubPullRequestDecodeError, | |
| 144 | GitHubRepositoryDecodeError, | |
| 145 | ]); | |
| 146 | export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type; | |
| 147 | ||
| 148 | export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError); | |
| 149 | ||
| 150 | /** Maps a GitHub API failure onto the errors callers of this service already handle. */ | |
| 151 | function fromGitHubApiError( | |
| 152 | cwd: string, | |
| 153 | error: GitHubApi.GitHubApiError, | |
| 154 | ): GitHubRepositoryApiError { | |
| 155 | const context = { cwd, cause: error }; | |
| 156 | switch (error._tag) { | |
| 157 | case "GitHubCliMissingError": | |
| 158 | return new GitHubRepositoryUnavailableError(context); | |
| 159 | case "GitHubNotSignedInError": | |
| 160 | case "GitHubHostDisabledError": | |
| 161 | case "GitHubApiAuthenticationError": | |
| 162 | return new GitHubRepositoryAuthenticationError(context); | |
| 163 | case "GitHubCliFailedError": | |
| 164 | return new GitHubRepositoryCommandError(context); | |
| 165 | case "GitHubApiRateLimitError": | |
| 166 | return new GitHubRepositoryRateLimitError({ | |
| 167 | ...context, | |
| 168 | ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), | |
| 169 | }); | |
| 170 | case "SourceControlRateLimitPausedError": | |
| 171 | return new GitHubRepositoryRateLimitError({ ...context, retryAt: error.retryAt }); | |
| 172 | case "GitHubApiNotFoundError": | |
| 173 | return new GitHubPullRequestNotFoundError(context); | |
| 174 | case "GitHubApiResponseError": | |
| 175 | return new GitHubRepositoryCommandError({ ...context, httpStatus: error.status }); | |
| 176 | case "GitHubApiRequestError": | |
| 177 | return new GitHubRepositoryCommandError(context); | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | export interface GitHubPullRequestSummary { | |
| 182 | readonly number: number; | |
| 183 | readonly title: string; | |
| 184 | readonly url: string; | |
| 185 | readonly baseRefName: string; | |
| 186 | readonly headRefName: string; | |
| 187 | readonly headSha?: string; | |
| 188 | readonly state?: "open" | "closed" | "merged"; | |
| 189 | readonly isDraft?: boolean; | |
| 190 | readonly closedAt?: string | null; | |
| 191 | readonly mergedAt?: string | null; | |
| 192 | readonly updatedAt?: string; | |
| 193 | readonly isCrossRepository?: boolean; | |
| 194 | readonly headRepositoryNameWithOwner?: string | null; | |
| 195 | readonly headRepositoryOwnerLogin?: string | null; | |
| 196 | } | |
| 197 | ||
| 198 | function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { | |
| 199 | const { updatedAt, ...summary } = input; | |
| 200 | return { | |
| 201 | ...summary, | |
| 202 | ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), | |
| 203 | }; | |
| 204 | } | |
| 205 | ||
| 206 | export interface GitHubRepositoryCloneUrls { | |
| 207 | readonly nameWithOwner: string; | |
| 208 | readonly url: string; | |
| 209 | readonly sshUrl: string; | |
| 210 | } | |
| 211 | ||
| 212 | export class GitHubRepositoryApi extends Context.Service< | |
| 213 | GitHubRepositoryApi, | |
| 214 | { | |
| 215 | readonly listOpenPullRequests: (input: { | |
| 216 | readonly cwd: string; | |
| 217 | readonly headSelector: string; | |
| 218 | readonly limit?: number; | |
| 219 | readonly rateLimitHost?: string; | |
| 220 | }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubRepositoryApiError>; | |
| 221 | ||
| 222 | /** | |
| 223 | * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the | |
| 224 | * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive | |
| 225 | * together share one GraphQL document. | |
| 226 | */ | |
| 227 | readonly listPullRequestsByHead: (input: { | |
| 228 | readonly cwd: string; | |
| 229 | readonly headSelector: string; | |
| 230 | readonly state: "open" | "closed" | "merged" | "all"; | |
| 231 | readonly limit: number; | |
| 232 | /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ | |
| 233 | readonly rateLimitHost?: string; | |
| 234 | }) => Effect.Effect<ReadonlyArray<NormalizedGitHubPullRequestRecord>, GitHubRepositoryApiError>; | |
| 235 | ||
| 236 | readonly getPullRequest: (input: { | |
| 237 | readonly cwd: string; | |
| 238 | readonly reference: string; | |
| 239 | readonly rateLimitHost?: string; | |
| 240 | }) => Effect.Effect<GitHubPullRequestSummary, GitHubRepositoryApiError>; | |
| 241 | ||
| 242 | readonly getRepositoryCloneUrls: (input: { | |
| 243 | readonly cwd: string; | |
| 244 | readonly repository: string; | |
| 245 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubRepositoryApiError>; | |
| 246 | ||
| 247 | readonly createRepository: (input: { | |
| 248 | readonly cwd: string; | |
| 249 | readonly repository: string; | |
| 250 | readonly visibility: SourceControlRepositoryVisibility; | |
| 251 | }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubRepositoryApiError>; | |
| 252 | ||
| 253 | readonly createPullRequest: (input: { | |
| 254 | readonly cwd: string; | |
| 255 | readonly baseBranch: string; | |
| 256 | readonly headSelector: string; | |
| 257 | readonly title: string; | |
| 258 | readonly bodyFile: string; | |
| 259 | }) => Effect.Effect<void, GitHubRepositoryApiError>; | |
| 260 | ||
| 261 | readonly getDefaultBranch: (input: { | |
| 262 | readonly cwd: string; | |
| 263 | readonly rateLimitHost?: string; | |
| 264 | }) => Effect.Effect<string | null, GitHubRepositoryApiError>; | |
| 265 | ||
| 266 | readonly checkoutPullRequest: (input: { | |
| 267 | readonly cwd: string; | |
| 268 | readonly reference: string; | |
| 269 | readonly force?: boolean; | |
| 270 | }) => Effect.Effect<void, GitHubRepositoryApiError>; | |
| 271 | } | |
| 272 | >()("t3/sourceControl/GitHubRepositoryApi") {} | |
| 273 | ||
| 274 | /** | |
| 275 | * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a | |
| 276 | * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin | |
| 277 | * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of | |
| 278 | * `git config --get-regexp '^remote\..*\.gh-resolved$'`. | |
| 279 | * | |
| 280 | * Null whenever gh might weigh the remotes differently: a remote on another host or under an | |
| 281 | * SSH alias, more than one mark, or several remotes with none of those names. Callers then | |
| 282 | * fall back to the provider's remote, then to the best-ranked remote on the host. | |
| 283 | */ | |
| 284 | export function selectGitHubBaseRepository(input: { | |
| 285 | readonly remotes: string; | |
| 286 | readonly resolved: string; | |
| 287 | readonly host: string; | |
| 288 | }): { readonly owner: string; readonly name: string } | null { | |
| 289 | const host = input.host.toLowerCase(); | |
| 290 | const repositories = new Map<string, { readonly owner: string; readonly name: string }>(); | |
| 291 | for (const line of input.remotes.split("\n")) { | |
| 292 | const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); | |
| 293 | if (!match) continue; | |
| 294 | const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); | |
| 295 | if (remoteHost !== host || !owner || !name || rest.length > 0) return null; | |
| 296 | repositories.set(match[1]!, { owner, name }); | |
| 297 | } | |
| 298 | const marks = input.resolved | |
| 299 | .split("\n") | |
| 300 | .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) | |
| 301 | .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); | |
| 302 | if (marks.length > 1) return null; | |
| 303 | const [mark] = marks; | |
| 304 | if (mark) { | |
| 305 | if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; | |
| 306 | const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); | |
| 307 | return owner && name && rest.length === 0 ? { owner, name } : null; | |
| 308 | } | |
| 309 | // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the | |
| 310 | // top place has no defined winner. | |
| 311 | const score = (remoteName: string) => | |
| 312 | ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; | |
| 313 | const ranked = [...repositories.entries()].toSorted( | |
| 314 | ([left], [right]) => score(right) - score(left), | |
| 315 | ); | |
| 316 | const [top, next] = ranked; | |
| 317 | return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) | |
| 318 | ? top[1] | |
| 319 | : null; | |
| 320 | } | |
| 321 | ||
| 322 | const RawRepositorySchema = Schema.Struct({ | |
| 323 | full_name: TrimmedNonEmptyString, | |
| 324 | html_url: TrimmedNonEmptyString, | |
| 325 | ssh_url: TrimmedNonEmptyString, | |
| 326 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 327 | }); | |
| 328 | const decodeRawRepository = decodeJsonResult(RawRepositorySchema); | |
| 329 | ||
| 330 | function repositoryCloneUrls( | |
| 331 | raw: Schema.Schema.Type<typeof RawRepositorySchema>, | |
| 332 | ): GitHubRepositoryCloneUrls { | |
| 333 | return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; | |
| 334 | } | |
| 335 | ||
| 336 | const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); | |
| 337 | ||
| 338 | type PullRequestListState = "open" | "closed" | "merged" | "all"; | |
| 339 | ||
| 340 | /** The pull request fields every read selects, in GraphQL. */ | |
| 341 | const PULL_REQUEST_NODE_SELECTION = | |
| 342 | "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; | |
| 343 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { | |
| 344 | open: ["OPEN"], | |
| 345 | closed: ["CLOSED"], | |
| 346 | merged: ["MERGED"], | |
| 347 | all: ["OPEN", "CLOSED", "MERGED"], | |
| 348 | }; | |
| 349 | /** | |
| 350 | * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one | |
| 351 | * single lookup, but half that keeps each answer near half a second. | |
| 352 | */ | |
| 353 | const HEAD_LOOKUPS_PER_DOCUMENT = 50; | |
| 354 | /** | |
| 355 | * How long a head lookup waits for company. Branch discovery reaches GitHub only after each | |
| 356 | * branch's own git reads, so lookups started together arrive tens of milliseconds apart. | |
| 357 | * A background sweep's lookups spread over up to ~300ms, and every document costs a point no | |
| 358 | * matter how few heads it holds, so reads without reserve wait longer. | |
| 359 | */ | |
| 360 | const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; | |
| 361 | const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; | |
| 362 | /** | |
| 363 | * Background documents fill up under the longer window, and a failed document fails every head | |
| 364 | * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own | |
| 365 | * processing limit; twenty-five took ~7s. | |
| 366 | */ | |
| 367 | const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; | |
| 368 | /** A full document is 5,000 rows of well under 2 KB each. */ | |
| 369 | const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; | |
| 370 | /** | |
| 371 | * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch | |
| 372 | * named like a busy default (`main`) is the case this bounds; the owner's own row is near the | |
| 373 | * top because the newest come first. | |
| 374 | */ | |
| 375 | const OWNER_HEAD_SCAN_LIMIT = 100; | |
| 376 | ||
| 377 | class PullRequestsByHeadRead extends Request.Class< | |
| 378 | { | |
| 379 | readonly cwd: string; | |
| 380 | readonly host: string; | |
| 381 | readonly owner: string; | |
| 382 | readonly name: string; | |
| 383 | readonly headRefName: string; | |
| 384 | readonly state: PullRequestListState; | |
| 385 | readonly limit: number; | |
| 386 | readonly allowReserve: boolean; | |
| 387 | }, | |
| 388 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 389 | GitHubRepositoryApiError | |
| 390 | > {} | |
| 391 | ||
| 392 | /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ | |
| 393 | function buildPullRequestsByHeadQuery( | |
| 394 | lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>, | |
| 395 | ): { readonly document: string; readonly variables: Record<string, unknown> } { | |
| 396 | const variables: Record<string, unknown> = {}; | |
| 397 | const declarations: string[] = ["$owner: String!", "$name: String!"]; | |
| 398 | const selections: string[] = []; | |
| 399 | for (const [index, lookup] of lookups.entries()) { | |
| 400 | variables[`h${index}`] = lookup.headRefName; | |
| 401 | variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; | |
| 402 | declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); | |
| 403 | // `gh pr list` orders the same way, so a head with more matches than the limit keeps the | |
| 404 | // same rows. | |
| 405 | selections.push( | |
| 406 | ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, | |
| 407 | ); | |
| 408 | } | |
| 409 | return { | |
| 410 | document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, | |
| 411 | variables, | |
| 412 | }; | |
| 413 | } | |
| 414 | ||
| 415 | const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { | |
| 416 | repository(owner: $owner, name: $name) { | |
| 417 | pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } | |
| 418 | } | |
| 419 | }`; | |
| 420 | ||
| 421 | const decodePullRequestsByHead = decodeJsonResult( | |
| 422 | Schema.Struct({ | |
| 423 | data: Schema.Struct({ | |
| 424 | repository: Schema.NullOr( | |
| 425 | Schema.Record( | |
| 426 | Schema.String, | |
| 427 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), | |
| 428 | ), | |
| 429 | ), | |
| 430 | }), | |
| 431 | }), | |
| 432 | ); | |
| 433 | ||
| 434 | const decodePullRequestByNumber = decodeJsonResult( | |
| 435 | Schema.Struct({ | |
| 436 | data: Schema.Struct({ | |
| 437 | repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), | |
| 438 | }), | |
| 439 | }), | |
| 440 | ); | |
| 441 | ||
| 442 | /** A repository on a GitHub host: the API it is read through, and its owner and name. */ | |
| 443 | export interface GitHubRepositoryLocator { | |
| 444 | readonly host: string; | |
| 445 | readonly owner: string; | |
| 446 | readonly name: string; | |
| 447 | } | |
| 448 | ||
| 449 | /** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ | |
| 450 | function parseGitHubRepositorySelector( | |
| 451 | selector: string, | |
| 452 | defaultHost: string, | |
| 453 | ): GitHubRepositoryLocator | null { | |
| 454 | const trimmed = selector.trim().replace(/\.git$/i, ""); | |
| 455 | if (/^https?:\/\//i.test(trimmed)) { | |
| 456 | try { | |
| 457 | const url = new URL(trimmed); | |
| 458 | const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); | |
| 459 | return owner && name && rest.length === 0 | |
| 460 | ? { host: url.host.toLowerCase(), owner, name } | |
| 461 | : null; | |
| 462 | } catch { | |
| 463 | return null; | |
| 464 | } | |
| 465 | } | |
| 466 | const parts = trimmed.split("/").filter(Boolean); | |
| 467 | if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; | |
| 468 | if (parts.length === 3) | |
| 469 | return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; | |
| 470 | return null; | |
| 471 | } | |
| 472 | ||
| 473 | /** | |
| 474 | * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request | |
| 475 | * URL, or a branch name. | |
| 476 | */ | |
| 477 | function parsePullRequestReference( | |
| 478 | reference: string, | |
| 479 | ): | |
| 480 | | { readonly kind: "number"; readonly number: number } | |
| 481 | | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } | |
| 482 | | { readonly kind: "branch"; readonly headSelector: string } { | |
| 483 | const trimmed = reference.trim(); | |
| 484 | const numbered = /^#?([1-9]\d*)$/.exec(trimmed); | |
| 485 | if (numbered) return { kind: "number", number: Number(numbered[1]) }; | |
| 486 | if (/^https?:\/\//i.test(trimmed)) { | |
| 487 | try { | |
| 488 | const url = new URL(trimmed); | |
| 489 | const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); | |
| 490 | if (match) { | |
| 491 | return { | |
| 492 | kind: "url", | |
| 493 | locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, | |
| 494 | number: Number(match[3]), | |
| 495 | }; | |
| 496 | } | |
| 497 | } catch { | |
| 498 | // Not a URL after all; read it as a branch. | |
| 499 | } | |
| 500 | } | |
| 501 | return { kind: "branch", headSelector: trimmed }; | |
| 502 | } | |
| 503 | ||
| 504 | /** | |
| 505 | * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH | |
| 506 | * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through | |
| 507 | * `github.com`, which is what such an alias almost always stands for (issue #6198). | |
| 508 | */ | |
| 509 | export function gitHubApiHostForRemote(remoteUrl: string): string | null { | |
| 510 | const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); | |
| 511 | if (provider === null) return null; | |
| 512 | const host = new URL(provider.baseUrl).host.toLowerCase(); | |
| 513 | // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. | |
| 514 | if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { | |
| 515 | return host.includes("github") ? "github.com" : null; | |
| 516 | } | |
| 517 | return provider.kind === "github" ? host : null; | |
| 518 | } | |
| 519 | ||
| 520 | /** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ | |
| 521 | function apiHostForHint(host: string): string { | |
| 522 | const normalized = host.toLowerCase(); | |
| 523 | return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; | |
| 524 | } | |
| 525 | ||
| 526 | /** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ | |
| 527 | export function pullRequestCheckoutBranchName(input: { | |
| 528 | readonly headRefName: string; | |
| 529 | readonly headOwner: string | null; | |
| 530 | readonly isCrossRepository: boolean; | |
| 531 | readonly defaultBranch: string | null; | |
| 532 | }): string { | |
| 533 | // gh prefixes the owner only where the fork's branch would take over the default branch's | |
| 534 | // name, which is the one collision every fork pull request from `main` would hit. | |
| 535 | return input.isCrossRepository && | |
| 536 | input.headOwner !== null && | |
| 537 | input.defaultBranch !== null && | |
| 538 | input.headRefName === input.defaultBranch | |
| 539 | ? `${input.headOwner}/${input.headRefName}` | |
| 540 | : input.headRefName; | |
| 541 | } | |
| 542 | ||
| 543 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 544 | export const make = Effect.gen(function* () { | |
| 545 | const process = yield* VcsProcess.VcsProcess; | |
| 546 | const environment = yield* HostProcessEnvironment; | |
| 547 | const api = yield* GitHubApi.GitHubApi; | |
| 548 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 549 | const fileSystem = yield* FileSystem.FileSystem; | |
| 550 | ||
| 551 | const gitRead = (cwd: string, args: ReadonlyArray<string>) => | |
| 552 | process.run({ | |
| 553 | operation: "GitHubRepositoryApi.resolveRepository", | |
| 554 | command: "git", | |
| 555 | args, | |
| 556 | cwd, | |
| 557 | allowNonZeroExit: true, | |
| 558 | timeoutMs: 5_000, | |
| 559 | }); | |
| 560 | ||
| 561 | const commandFailure = (cwd: string, detail: string) => | |
| 562 | new GitHubRepositoryCommandError({ cwd, cause: new Error(detail) }); | |
| 563 | ||
| 564 | /** | |
| 565 | * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else | |
| 566 | * the remote the caller resolved the provider from, else the best-ranked GitHub remote. | |
| 567 | */ | |
| 568 | const resolveRepository = Effect.fn("GitHubRepositoryApi.resolveRepository")(function* (input: { | |
| 569 | readonly cwd: string; | |
| 570 | readonly host?: string | undefined; | |
| 571 | }) { | |
| 572 | const envRepository = environment.GH_REPO?.trim(); | |
| 573 | const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); | |
| 574 | if (envRepository) { | |
| 575 | const locator = parseGitHubRepositorySelector(envRepository, defaultHost); | |
| 576 | if (locator !== null) return locator; | |
| 577 | } | |
| 578 | const [remotes, resolved] = yield* Effect.all([ | |
| 579 | gitRead(input.cwd, ["remote", "-v"]), | |
| 580 | gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), | |
| 581 | ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); | |
| 582 | const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; | |
| 583 | const fetchRemotes = remoteOutput | |
| 584 | .split("\n") | |
| 585 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) | |
| 586 | .filter((match): match is RegExpExecArray => match !== null) | |
| 587 | .map((match) => ({ | |
| 588 | name: match[1]!, | |
| 589 | url: match[2]!, | |
| 590 | host: gitHubApiHostForRemote(match[2]!), | |
| 591 | })); | |
| 592 | const host = | |
| 593 | (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? | |
| 594 | fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? | |
| 595 | fetchRemotes.find((remote) => remote.host !== null)?.host ?? | |
| 596 | defaultHost; | |
| 597 | const selected = selectGitHubBaseRepository({ | |
| 598 | remotes: remoteOutput, | |
| 599 | resolved: resolved?.exitCode === 0 ? resolved.stdout : "", | |
| 600 | host, | |
| 601 | }); | |
| 602 | if (selected !== null) return { host, ...selected }; | |
| 603 | // gh's own order without its prompt: upstream, github, origin, then the first remote, among | |
| 604 | // the ones on this host (an SSH alias counts as its API host). | |
| 605 | const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); | |
| 606 | const candidates = fetchRemotes | |
| 607 | .filter((remote) => remote.host === host) | |
| 608 | .toSorted((left, right) => { | |
| 609 | const l = rank(left.name); | |
| 610 | const r = rank(right.name); | |
| 611 | return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); | |
| 612 | }); | |
| 613 | for (const remote of candidates) { | |
| 614 | const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); | |
| 615 | if (owner && name && rest.length === 0) return { host, owner, name }; | |
| 616 | } | |
| 617 | return yield* commandFailure( | |
| 618 | input.cwd, | |
| 619 | `No GitHub repository on ${host} was found among this checkout's git remotes.`, | |
| 620 | ); | |
| 621 | }); | |
| 622 | ||
| 623 | const graphqlJson = <A>( | |
| 624 | cwd: string, | |
| 625 | input: GitHubApi.GitHubGraphQlInput, | |
| 626 | decode: (raw: string) => Result.Result<A, unknown>, | |
| 627 | onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError, | |
| 628 | ) => | |
| 629 | api.graphql(input).pipe( | |
| 630 | Effect.mapError((error) => fromGitHubApiError(cwd, error)), | |
| 631 | Effect.flatMap((raw) => { | |
| 632 | const decoded = decode(raw); | |
| 633 | return Result.isSuccess(decoded) | |
| 634 | ? Effect.succeed(decoded.success) | |
| 635 | : Effect.fail(onDecodeFailure(decoded.failure)); | |
| 636 | }), | |
| 637 | ); | |
| 638 | ||
| 639 | const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => | |
| 640 | api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); | |
| 641 | ||
| 642 | /** Pull requests whose head branch is `headRefName` on one repository. */ | |
| 643 | const readPullRequestsByHead = (input: { | |
| 644 | readonly cwd: string; | |
| 645 | readonly locator: GitHubRepositoryLocator; | |
| 646 | readonly lookups: ReadonlyArray< | |
| 647 | Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit"> | |
| 648 | >; | |
| 649 | readonly allowReserve: boolean; | |
| 650 | readonly onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError; | |
| 651 | }) => { | |
| 652 | const query = buildPullRequestsByHeadQuery(input.lookups); | |
| 653 | return graphqlJson( | |
| 654 | input.cwd, | |
| 655 | { | |
| 656 | host: input.locator.host, | |
| 657 | operation: "listPullRequestsByHead", | |
| 658 | query: query.document, | |
| 659 | variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, | |
| 660 | allowReserve: input.allowReserve, | |
| 661 | // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred | |
| 662 | // fork pull requests, so the usual cap would cut the answer short. | |
| 663 | maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, | |
| 664 | }, | |
| 665 | decodePullRequestsByHead, | |
| 666 | input.onDecodeFailure, | |
| 667 | ).pipe( | |
| 668 | Effect.flatMap((decoded) => | |
| 669 | decoded.data.repository === null | |
| 670 | ? Effect.fail( | |
| 671 | new GitHubPullRequestNotFoundError({ | |
| 672 | cwd: input.cwd, | |
| 673 | cause: new Error("The repository could not be read."), | |
| 674 | }), | |
| 675 | ) | |
| 676 | : Effect.succeed(decoded.data.repository), | |
| 677 | ), | |
| 678 | ); | |
| 679 | }; | |
| 680 | ||
| 681 | const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({ | |
| 682 | key: ({ request, context }) => | |
| 683 | [ | |
| 684 | request.host, | |
| 685 | request.owner, | |
| 686 | request.name, | |
| 687 | String(request.allowReserve), | |
| 688 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 689 | ?.credentialFingerprint ?? "", | |
| 690 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 691 | ].join("\0"), | |
| 692 | resolver: (entries) => { | |
| 693 | const [first] = entries; | |
| 694 | const { cwd, host, owner, name, allowReserve } = first.request; | |
| 695 | return readPullRequestsByHead({ | |
| 696 | cwd, | |
| 697 | locator: { host, owner, name }, | |
| 698 | lookups: entries.map((entry) => entry.request), | |
| 699 | allowReserve, | |
| 700 | onDecodeFailure: (cause) => new GitHubChangeRequestListDecodeError({ cwd, cause }), | |
| 701 | }).pipe( | |
| 702 | Effect.map((aliases) => { | |
| 703 | for (const [index, entry] of entries.entries()) { | |
| 704 | const alias = aliases[`h${index}`]; | |
| 705 | entry.completeUnsafe( | |
| 706 | Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), | |
| 707 | ); | |
| 708 | } | |
| 709 | }), | |
| 710 | Effect.catchCause((cause) => | |
| 711 | Effect.sync(() => { | |
| 712 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 713 | }), | |
| 714 | ), | |
| 715 | ); | |
| 716 | }, | |
| 717 | }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); | |
| 718 | const interactiveHeadResolver = headResolver.pipe( | |
| 719 | RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), | |
| 720 | ); | |
| 721 | const backgroundHeadResolver = headResolver.pipe( | |
| 722 | RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), | |
| 723 | RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), | |
| 724 | ); | |
| 725 | ||
| 726 | const listByHead = Effect.fn("GitHubRepositoryApi.listByHead")(function* (input: { | |
| 727 | readonly cwd: string; | |
| 728 | readonly headSelector: string; | |
| 729 | readonly state: PullRequestListState; | |
| 730 | readonly limit: number; | |
| 731 | readonly rateLimitHost?: string | undefined; | |
| 732 | readonly allowReserve: boolean; | |
| 733 | }) { | |
| 734 | const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); | |
| 735 | const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); | |
| 736 | // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the | |
| 737 | // owner is matched on the rows it returns. | |
| 738 | const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); | |
| 739 | const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); | |
| 740 | const rows = yield* Effect.request( | |
| 741 | new PullRequestsByHeadRead({ | |
| 742 | cwd: input.cwd, | |
| 743 | ...locator, | |
| 744 | headRefName, | |
| 745 | state: input.state, | |
| 746 | limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, | |
| 747 | allowReserve: input.allowReserve, | |
| 748 | }), | |
| 749 | input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, | |
| 750 | ); | |
| 751 | if (!ownerMatch) return rows; | |
| 752 | const headOwner = ownerMatch[1]!.toLowerCase(); | |
| 753 | return rows | |
| 754 | .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) | |
| 755 | .slice(0, limit); | |
| 756 | }); | |
| 757 | ||
| 758 | const toSummaries = (rows: ReadonlyArray<NormalizedGitHubPullRequestRecord>) => | |
| 759 | rows.map(pullRequestSummary); | |
| 760 | ||
| 761 | const readPullRequest = Effect.fn("GitHubRepositoryApi.readPullRequest")(function* (input: { | |
| 762 | readonly cwd: string; | |
| 763 | readonly reference: string; | |
| 764 | readonly rateLimitHost?: string | undefined; | |
| 765 | }) { | |
| 766 | const parsed = parsePullRequestReference(input.reference); | |
| 767 | if (parsed.kind === "branch") { | |
| 768 | // `gh pr view <branch>` prefers an open pull request, then the newest of any state. | |
| 769 | const [open] = yield* listByHead({ | |
| 770 | cwd: input.cwd, | |
| 771 | headSelector: parsed.headSelector, | |
| 772 | state: "open", | |
| 773 | limit: 1, | |
| 774 | rateLimitHost: input.rateLimitHost, | |
| 775 | allowReserve: true, | |
| 776 | }); | |
| 777 | const found = | |
| 778 | open ?? | |
| 779 | (yield* listByHead({ | |
| 780 | cwd: input.cwd, | |
| 781 | headSelector: parsed.headSelector, | |
| 782 | state: "all", | |
| 783 | limit: 1, | |
| 784 | rateLimitHost: input.rateLimitHost, | |
| 785 | allowReserve: true, | |
| 786 | }))[0]; | |
| 787 | if (found === undefined) { | |
| 788 | return yield* new GitHubPullRequestNotFoundError({ | |
| 789 | cwd: input.cwd, | |
| 790 | cause: new Error("No pull request has this head branch."), | |
| 791 | }); | |
| 792 | } | |
| 793 | return found; | |
| 794 | } | |
| 795 | const locator = | |
| 796 | parsed.kind === "url" | |
| 797 | ? parsed.locator | |
| 798 | : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); | |
| 799 | const decodeFailure = (cause: unknown) => | |
| 800 | new GitHubPullRequestDecodeError({ cwd: input.cwd, cause }); | |
| 801 | const decoded = yield* graphqlJson( | |
| 802 | input.cwd, | |
| 803 | { | |
| 804 | host: locator.host, | |
| 805 | operation: "getPullRequest", | |
| 806 | query: PULL_REQUEST_BY_NUMBER_QUERY, | |
| 807 | variables: { owner: locator.owner, name: locator.name, number: parsed.number }, | |
| 808 | allowReserve: true, | |
| 809 | }, | |
| 810 | decodePullRequestByNumber, | |
| 811 | decodeFailure, | |
| 812 | ); | |
| 813 | const node = decoded.data.repository?.pullRequest; | |
| 814 | if (node == null) { | |
| 815 | return yield* new GitHubPullRequestNotFoundError({ | |
| 816 | cwd: input.cwd, | |
| 817 | cause: new Error("The pull request does not exist."), | |
| 818 | }); | |
| 819 | } | |
| 820 | const [record] = decodeGitHubPullRequestEntries([node]); | |
| 821 | if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); | |
| 822 | return record; | |
| 823 | }); | |
| 824 | ||
| 825 | const readRepository = Effect.fn("GitHubRepositoryApi.readRepository")(function* ( | |
| 826 | cwd: string, | |
| 827 | locator: GitHubRepositoryLocator, | |
| 828 | ) { | |
| 829 | const response = yield* rest(cwd, { | |
| 830 | host: locator.host, | |
| 831 | operation: "getRepository", | |
| 832 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, | |
| 833 | allowReserve: true, | |
| 834 | }); | |
| 835 | const decoded = decodeRawRepository(response.body); | |
| 836 | if (Result.isFailure(decoded)) { | |
| 837 | return yield* new GitHubRepositoryDecodeError({ | |
| 838 | cwd, | |
| 839 | cause: decoded.failure, | |
| 840 | }); | |
| 841 | } | |
| 842 | return decoded.success; | |
| 843 | }); | |
| 844 | ||
| 845 | const readViewerLogin = Effect.fn("GitHubRepositoryApi.readViewerLogin")(function* ( | |
| 846 | cwd: string, | |
| 847 | host: string, | |
| 848 | ) { | |
| 849 | const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); | |
| 850 | const decoded = decodeViewerLogin(response.body); | |
| 851 | if (Result.isFailure(decoded)) { | |
| 852 | return yield* new GitHubRepositoryCommandError({ cwd, cause: decoded.failure }); | |
| 853 | } | |
| 854 | return decoded.success.login; | |
| 855 | }); | |
| 856 | ||
| 857 | const gitFailure = (cwd: string) => (cause: unknown) => | |
| 858 | new GitHubRepositoryCommandError({ cwd, cause }); | |
| 859 | ||
| 860 | const runGit = (cwd: string, operation: string, args: ReadonlyArray<string>) => | |
| 861 | git.execute({ operation: `GitHubRepositoryApi.checkoutPullRequest.${operation}`, cwd, args }); | |
| 862 | ||
| 863 | /** | |
| 864 | * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a | |
| 865 | * fork gets a remote of its own), checked out under the name gh would give it, and set to | |
| 866 | * track the head. A head branch that is gone is read from the base's `refs/pull/<n>/head`. | |
| 867 | * An existing branch fast-forwards, or with `force` is reset to the pull request. | |
| 868 | */ | |
| 869 | const checkoutPullRequest: GitHubRepositoryApi["Service"]["checkoutPullRequest"] = Effect.fn( | |
| 870 | "GitHubRepositoryApi.checkoutPullRequest", | |
| 871 | )(function* (input) { | |
| 872 | const reference = parsePullRequestReference(input.reference); | |
| 873 | const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); | |
| 874 | const base = | |
| 875 | reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); | |
| 876 | const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); | |
| 877 | const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; | |
| 878 | const isCrossRepository = | |
| 879 | pullRequest.isCrossRepository ?? | |
| 880 | (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); | |
| 881 | const headOwner = | |
| 882 | pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; | |
| 883 | ||
| 884 | const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( | |
| 885 | Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), | |
| 886 | Effect.mapError(gitFailure(input.cwd)), | |
| 887 | ); | |
| 888 | const remoteFor = (nameWithOwner: string) => | |
| 889 | remotes | |
| 890 | .split("\n") | |
| 891 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) | |
| 892 | .find( | |
| 893 | (match) => | |
| 894 | match !== null && | |
| 895 | normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === | |
| 896 | nameWithOwner.toLowerCase(), | |
| 897 | )?.[1] ?? null; | |
| 898 | const baseRemote = Effect.suspend(() => { | |
| 899 | const known = remoteFor(baseNameWithOwner); | |
| 900 | return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); | |
| 901 | }); | |
| 902 | // A fork's branch named like the base's default branch is checked out under the owner's | |
| 903 | // prefix. Without the default branch that collision cannot be ruled out, and the checkout | |
| 904 | // would reset the local default branch to the fork's commit, so it fails instead. | |
| 905 | const defaultBranch = isCrossRepository | |
| 906 | ? yield* readRepository(input.cwd, base).pipe( | |
| 907 | Effect.map((repository) => repository.default_branch ?? null), | |
| 908 | ) | |
| 909 | : null; | |
| 910 | const localBranch = pullRequestCheckoutBranchName({ | |
| 911 | headRefName: pullRequest.headRefName, | |
| 912 | headOwner, | |
| 913 | isCrossRepository, | |
| 914 | defaultBranch, | |
| 915 | }); | |
| 916 | ||
| 917 | /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ | |
| 918 | const headRemote = Effect.gen(function* () { | |
| 919 | if (!isCrossRepository) return yield* baseRemote; | |
| 920 | if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); | |
| 921 | const known = remoteFor(headNameWithOwner); | |
| 922 | if (known !== null) return known; | |
| 923 | const [owner, name] = headNameWithOwner.split("/"); | |
| 924 | const fork = yield* readRepository(input.cwd, { | |
| 925 | host: base.host, | |
| 926 | owner: owner!, | |
| 927 | name: name!, | |
| 928 | }); | |
| 929 | const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); | |
| 930 | return yield* git.ensureRemote({ | |
| 931 | cwd: input.cwd, | |
| 932 | preferredName: headOwner ?? "fork", | |
| 933 | url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, | |
| 934 | }); | |
| 935 | }); | |
| 936 | ||
| 937 | const exists = (yield* git | |
| 938 | .listLocalBranchNames(input.cwd) | |
| 939 | .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); | |
| 940 | ||
| 941 | // The commit to check out, fetched from the head branch where it still exists. | |
| 942 | const target = yield* Effect.gen(function* () { | |
| 943 | const remoteName = yield* headRemote; | |
| 944 | yield* git.fetchRemoteTrackingBranch({ | |
| 945 | cwd: input.cwd, | |
| 946 | remoteName, | |
| 947 | remoteBranch: pullRequest.headRefName, | |
| 948 | }); | |
| 949 | return { | |
| 950 | ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, | |
| 951 | upstream: { remoteName, remoteBranch: pullRequest.headRefName }, | |
| 952 | }; | |
| 953 | }).pipe( | |
| 954 | Effect.catch(() => | |
| 955 | Effect.gen(function* () { | |
| 956 | yield* runGit(input.cwd, "fetchPullRef", [ | |
| 957 | "fetch", | |
| 958 | "--quiet", | |
| 959 | "--no-tags", | |
| 960 | yield* baseRemote, | |
| 961 | `refs/pull/${pullRequest.number}/head`, | |
| 962 | ]); | |
| 963 | const { commitSha } = yield* git.resolveCommit({ | |
| 964 | cwd: input.cwd, | |
| 965 | revision: "FETCH_HEAD", | |
| 966 | }); | |
| 967 | return { ref: commitSha, upstream: null }; | |
| 968 | }), | |
| 969 | ), | |
| 970 | Effect.mapError(gitFailure(input.cwd)), | |
| 971 | ); | |
| 972 | ||
| 973 | yield* Effect.gen(function* () { | |
| 974 | if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); | |
| 975 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | |
| 976 | if (exists) { | |
| 977 | yield* runGit( | |
| 978 | input.cwd, | |
| 979 | "sync", | |
| 980 | input.force === true | |
| 981 | ? ["reset", "--hard", "--quiet", target.ref] | |
| 982 | : ["merge", "--ff-only", "--quiet", target.ref], | |
| 983 | ); | |
| 984 | } | |
| 985 | // Tracking is set once the branch is the pull request's, so a sync that fails leaves an | |
| 986 | // existing branch's upstream as it was. | |
| 987 | if (target.upstream !== null) { | |
| 988 | yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); | |
| 989 | } | |
| 990 | }).pipe(Effect.mapError(gitFailure(input.cwd))); | |
| 991 | }); | |
| 992 | ||
| 993 | return GitHubRepositoryApi.of({ | |
| 994 | listPullRequestsByHead: (input) => | |
| 995 | AllowGitHubReserve.pipe( | |
| 996 | Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), | |
| 997 | ), | |
| 998 | listOpenPullRequests: (input) => | |
| 999 | listByHead({ | |
| 1000 | cwd: input.cwd, | |
| 1001 | headSelector: input.headSelector, | |
| 1002 | state: "open", | |
| 1003 | limit: input.limit ?? 1, | |
| 1004 | rateLimitHost: input.rateLimitHost, | |
| 1005 | allowReserve: true, | |
| 1006 | }).pipe(Effect.map(toSummaries)), | |
| 1007 | getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), | |
| 1008 | getRepositoryCloneUrls: (input) => | |
| 1009 | Effect.gen(function* () { | |
| 1010 | const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( | |
| 1011 | Effect.map((locator) => locator.host), | |
| 1012 | Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), | |
| 1013 | )).toLowerCase(); | |
| 1014 | const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); | |
| 1015 | if (locator === null) { | |
| 1016 | return yield* commandFailure(input.cwd, "Repositories are named owner/name."); | |
| 1017 | } | |
| 1018 | return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); | |
| 1019 | }), | |
| 1020 | createRepository: (input) => | |
| 1021 | Effect.gen(function* () { | |
| 1022 | const locator = parseGitHubRepositorySelector( | |
| 1023 | input.repository, | |
| 1024 | (environment.GH_HOST ?? "github.com").toLowerCase(), | |
| 1025 | ); | |
| 1026 | const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); | |
| 1027 | const owner = locator?.owner ?? viewer; | |
| 1028 | const name = locator?.name ?? input.repository.trim(); | |
| 1029 | const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); | |
| 1030 | const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); | |
| 1031 | const response = yield* rest(input.cwd, { | |
| 1032 | host, | |
| 1033 | operation: "createRepository", | |
| 1034 | method: "POST", | |
| 1035 | path: | |
| 1036 | isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, | |
| 1037 | body: { name, private: input.visibility === "private" }, | |
| 1038 | }); | |
| 1039 | const decoded = decodeRawRepository(response.body); | |
| 1040 | if (Result.isFailure(decoded)) { | |
| 1041 | return yield* new GitHubRepositoryDecodeError({ | |
| 1042 | cwd: input.cwd, | |
| 1043 | cause: decoded.failure, | |
| 1044 | }); | |
| 1045 | } | |
| 1046 | return repositoryCloneUrls(decoded.success); | |
| 1047 | }), | |
| 1048 | createPullRequest: (input) => | |
| 1049 | Effect.gen(function* () { | |
| 1050 | const locator = yield* resolveRepository({ cwd: input.cwd }); | |
| 1051 | const body = yield* fileSystem | |
| 1052 | .readFileString(input.bodyFile) | |
| 1053 | .pipe(Effect.mapError(gitFailure(input.cwd))); | |
| 1054 | yield* rest(input.cwd, { | |
| 1055 | host: locator.host, | |
| 1056 | operation: "createPullRequest", | |
| 1057 | method: "POST", | |
| 1058 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, | |
| 1059 | // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. | |
| 1060 | // gh allows maintainer edits unless told otherwise; the API's default is not documented. | |
| 1061 | body: { | |
| 1062 | base: input.baseBranch, | |
| 1063 | head: input.headSelector, | |
| 1064 | title: input.title, | |
| 1065 | body, | |
| 1066 | maintainer_can_modify: true, | |
| 1067 | }, | |
| 1068 | }); | |
| 1069 | }), | |
| 1070 | getDefaultBranch: (input) => | |
| 1071 | Effect.gen(function* () { | |
| 1072 | const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); | |
| 1073 | const repository = yield* readRepository(input.cwd, locator); | |
| 1074 | const branch = repository.default_branch?.trim() ?? ""; | |
| 1075 | return branch.length > 0 ? branch : null; | |
| 1076 | }), | |
| 1077 | checkoutPullRequest, | |
| 1078 | }); | |
| 1079 | }); | |
| 1080 | ||
| 1081 | export const layer = Layer.effect(GitHubRepositoryApi, make).pipe( | |
| 1082 | Layer.provideMerge(GitHubApi.layer), | |
| 1083 | Layer.provideMerge(GitHubCredentials.layer), | |
| 1084 | Layer.provideMerge(GitHubGraphQlBudget.layer), | |
| 1085 | Layer.provideMerge(SourceControlRateLimit.layer), | |
| 1086 | ); |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+593-175| ... | ... | @@ -1,17 +1,23 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | 6 | import * as Layer from "effect/Layer"; |
| 5 | 7 | import * as Option from "effect/Option"; |
| 6 | 8 | import * as Schema from "effect/Schema"; |
| 7 | 9 | import { ChildProcessSpawner } from "effect/process"; |
| 10 | import * as TestClock from "effect/testing/TestClock"; | |
| 8 | 11 | |
| 12 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 9 | 13 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | 14 | import * as GitHubApi from "./GitHubApi.ts"; |
| 11 | import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; | |
| 15 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 12 | 16 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; |
| 13 | 17 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 14 | 18 | |
| 19 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 20 | ||
| 15 | 21 | const processResult = ( |
| 16 | 22 | stdout: string, |
| 17 | 23 | options?: { |
| ... | ... | @@ -26,191 +32,611 @@ const processResult = ( |
| 26 | 32 | stderrTruncated: false, |
| 27 | 33 | }); |
| 28 | 34 | |
| 29 | function makeProvider( | |
| 30 | github: Partial<GitHubRepositoryApi.GitHubRepositoryApi["Service"]>, | |
| 31 | api: Partial<GitHubApi.GitHubApi["Service"]> = {}, | |
| 32 | ) { | |
| 33 | return GitHubSourceControlProvider.make.pipe( | |
| 34 | Effect.provide( | |
| 35 | Layer.merge( | |
| 36 | Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github), | |
| 37 | Layer.mock(GitHubApi.GitHubApi)(api), | |
| 38 | ), | |
| 39 | ), | |
| 40 | ); | |
| 41 | } | |
| 35 | const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ | |
| 36 | exitCode: ChildProcessSpawner.ExitCode(exitCode), | |
| 37 | stdout, | |
| 38 | stderr: "", | |
| 39 | stdoutTruncated: false, | |
| 40 | stderrTruncated: false, | |
| 41 | }); | |
| 42 | 42 | |
| 43 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 43 | const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 44 | entries | |
| 45 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 46 | .join("\n"); | |
| 44 | 47 | |
| 45 | const restResponse = (body: string): GitHubApi.GitHubRestResponse => ({ | |
| 46 | status: 200, | |
| 48 | const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ | |
| 49 | status, | |
| 47 | 50 | headers: {}, |
| 48 | body, | |
| 51 | body: body === undefined ? "" : encodeJson(body), | |
| 49 | 52 | truncated: false, |
| 50 | 53 | invalidUtf8: false, |
| 51 | 54 | }); |
| 52 | 55 | |
| 53 | it.effect("maps GitHub PR summaries into provider-neutral change requests", () => | |
| 54 | Effect.gen(function* () { | |
| 55 | const provider = yield* makeProvider({ | |
| 56 | getPullRequest: () => | |
| 57 | Effect.succeed({ | |
| 58 | number: 42, | |
| 59 | title: "Add GitHub provider", | |
| 60 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 61 | baseRefName: "main", | |
| 62 | headRefName: "feature/source-control", | |
| 63 | state: "open", | |
| 64 | isCrossRepository: true, | |
| 65 | headRepositoryNameWithOwner: "fork/t3code", | |
| 66 | headRepositoryOwnerLogin: "fork", | |
| 67 | }), | |
| 56 | const node = (number: number, headRefName: string, owner = "acme") => ({ | |
| 57 | number, | |
| 58 | title: `PR ${number}`, | |
| 59 | url: `https://github.com/acme/web/pull/${number}`, | |
| 60 | baseRefName: "main", | |
| 61 | headRefName, | |
| 62 | state: "OPEN", | |
| 63 | isCrossRepository: owner !== "acme", | |
| 64 | updatedAt: "2026-01-02T00:00:00Z", | |
| 65 | headRepository: { name: "web", nameWithOwner: `${owner}/web` }, | |
| 66 | headRepositoryOwner: { login: owner }, | |
| 67 | }); | |
| 68 | ||
| 69 | /** | |
| 70 | * The provider over a mocked GitHubApi, git driver and process. `remotes` is what | |
| 71 | * `git remote -v` prints; `git` records every driver call. | |
| 72 | */ | |
| 73 | function harness(input: { | |
| 74 | readonly remotes: string; | |
| 75 | readonly api: Partial<GitHubApi.GitHubApi["Service"]>; | |
| 76 | readonly localBranches?: ReadonlyArray<string>; | |
| 77 | /** Fails the local branch listing, the first git read a checkout makes. */ | |
| 78 | readonly gitFailure?: unknown; | |
| 79 | }) { | |
| 80 | const git: Array<readonly [string, unknown]> = []; | |
| 81 | const record = | |
| 82 | <A>(name: string, value: A) => | |
| 83 | (args: unknown) => | |
| 84 | Effect.sync(() => { | |
| 85 | git.push([name, args]); | |
| 86 | return value; | |
| 87 | }); | |
| 88 | const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ | |
| 89 | execute: (args) => | |
| 90 | Effect.sync(() => { | |
| 91 | git.push(["execute", args.args]); | |
| 92 | return processOutput(""); | |
| 93 | }), | |
| 94 | resolvePrimaryRemoteName: () => Effect.succeed("origin"), | |
| 95 | readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), | |
| 96 | ensureRemote: (args) => | |
| 97 | Effect.sync(() => { | |
| 98 | git.push(["ensureRemote", args]); | |
| 99 | return args.preferredName; | |
| 100 | }), | |
| 101 | fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), | |
| 102 | setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), | |
| 103 | switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, | |
| 104 | listLocalBranchNames: () => | |
| 105 | input.gitFailure === undefined | |
| 106 | ? Effect.succeed([...(input.localBranches ?? [])]) | |
| 107 | : Effect.fail(input.gitFailure as never), | |
| 108 | resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), | |
| 109 | }); | |
| 110 | const process = Layer.mock(VcsProcess.VcsProcess)({ | |
| 111 | run: (args) => | |
| 112 | Effect.succeed( | |
| 113 | args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), | |
| 114 | ), | |
| 115 | }); | |
| 116 | const layer = Layer.mergeAll( | |
| 117 | driver, | |
| 118 | process, | |
| 119 | Layer.mock(GitHubApi.GitHubApi)(input.api), | |
| 120 | NodeServices.layer, | |
| 121 | ); | |
| 122 | return { layer, git }; | |
| 123 | } | |
| 124 | ||
| 125 | /** The provider context for a checkout whose remote is on `host`. */ | |
| 126 | /** The provider over a mocked GitHubApi, for reads that never touch the checkout. */ | |
| 127 | const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) => | |
| 128 | GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); | |
| 129 | ||
| 130 | const githubContext = (host: string) => ({ | |
| 131 | provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, | |
| 132 | remoteName: "origin", | |
| 133 | remoteUrl: `git@${host}:acme/web.git`, | |
| 134 | }); | |
| 135 | ||
| 136 | describe("GitHubSourceControlProvider repository resolution", () => { | |
| 137 | it.effect("reads the repository gh would pick from the remotes", () => { | |
| 138 | const paths: string[] = []; | |
| 139 | const { layer } = harness({ | |
| 140 | remotes: remotesOutput( | |
| 141 | ["origin", "git@github.com:me/web.git"], | |
| 142 | ["upstream", "https://github.com/acme/web.git"], | |
| 143 | ), | |
| 144 | api: { | |
| 145 | rest: (input) => | |
| 146 | Effect.sync(() => { | |
| 147 | paths.push(`${input.host} ${input.path}`); | |
| 148 | return restResponse({ | |
| 149 | full_name: "acme/web", | |
| 150 | html_url: "https://github.com/acme/web", | |
| 151 | ssh_url: "git@github.com:acme/web.git", | |
| 152 | default_branch: "trunk", | |
| 153 | }); | |
| 154 | }), | |
| 155 | }, | |
| 68 | 156 | }); |
| 157 | return Effect.gen(function* () { | |
| 158 | const gh = yield* GitHubSourceControlProvider.make; | |
| 159 | assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); | |
| 160 | assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); | |
| 161 | }).pipe(Effect.provide(layer)); | |
| 162 | }); | |
| 69 | 163 | |
| 70 | const changeRequest = yield* provider.getChangeRequest({ | |
| 71 | cwd: "/repo", | |
| 72 | reference: "42", | |
| 164 | it.effect("reads an SSH alias remote through github.com", () => { | |
| 165 | const hosts: string[] = []; | |
| 166 | const { layer } = harness({ | |
| 167 | remotes: remotesOutput(["origin", "git@github:acme/web.git"]), | |
| 168 | api: { | |
| 169 | rest: (input) => | |
| 170 | Effect.sync(() => { | |
| 171 | hosts.push(`${input.host} ${input.path}`); | |
| 172 | return restResponse({ | |
| 173 | full_name: "acme/web", | |
| 174 | html_url: "https://github.com/acme/web", | |
| 175 | ssh_url: "git@github.com:acme/web.git", | |
| 176 | default_branch: "main", | |
| 177 | }); | |
| 178 | }), | |
| 179 | }, | |
| 73 | 180 | }); |
| 181 | return Effect.gen(function* () { | |
| 182 | const gh = yield* GitHubSourceControlProvider.make; | |
| 183 | yield* gh.getDefaultBranch({ cwd: "/repo" }); | |
| 184 | // A provider's host hint for the same alias (`github` here) resolves the same way. | |
| 185 | yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("github") }); | |
| 186 | assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); | |
| 187 | }).pipe(Effect.provide(layer)); | |
| 188 | }); | |
| 74 | 189 | |
| 75 | assert.deepStrictEqual(changeRequest, { | |
| 76 | provider: "github", | |
| 77 | number: 42, | |
| 78 | title: "Add GitHub provider", | |
| 79 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 80 | baseRefName: "main", | |
| 81 | headRefName: "feature/source-control", | |
| 82 | state: "open", | |
| 83 | closedAt: null, | |
| 84 | mergedAt: null, | |
| 85 | updatedAt: Option.none(), | |
| 86 | isCrossRepository: true, | |
| 87 | headRepositoryNameWithOwner: "fork/t3code", | |
| 88 | headRepositoryOwnerLogin: "fork", | |
| 190 | it.effect("names a missing repository rather than a pull request", () => { | |
| 191 | const { layer } = harness({ | |
| 192 | remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]), | |
| 193 | api: { | |
| 194 | rest: (input) => | |
| 195 | Effect.fail( | |
| 196 | new GitHubApi.GitHubApiNotFoundError({ host: input.host, operation: input.operation }), | |
| 197 | ), | |
| 198 | }, | |
| 89 | 199 | }); |
| 90 | }), | |
| 91 | ); | |
| 200 | return Effect.gen(function* () { | |
| 201 | const gh = yield* GitHubSourceControlProvider.make; | |
| 202 | const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); | |
| 203 | assert.include(error.detail, "Repository not found"); | |
| 204 | }).pipe(Effect.provide(layer)); | |
| 205 | }); | |
| 92 | 206 | |
| 93 | it.effect("adds safe request context while retaining GitHub API causes", () => | |
| 94 | Effect.gen(function* () { | |
| 95 | const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({ | |
| 96 | cwd: "/repo", | |
| 97 | cause: new Error("raw upstream detail that should remain in the cause"), | |
| 207 | it.effect("fails clearly when no remote is on GitHub", () => { | |
| 208 | const { layer } = harness({ | |
| 209 | remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), | |
| 210 | api: {}, | |
| 98 | 211 | }); |
| 99 | const provider = yield* makeProvider({ | |
| 100 | getPullRequest: () => Effect.fail(cause), | |
| 212 | return Effect.gen(function* () { | |
| 213 | const gh = yield* GitHubSourceControlProvider.make; | |
| 214 | const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); | |
| 215 | assert.strictEqual(error._tag, "SourceControlProviderError"); | |
| 216 | assert.include(error.detail, "No GitHub repository"); | |
| 217 | }).pipe(Effect.provide(layer)); | |
| 218 | }); | |
| 219 | }); | |
| 220 | ||
| 221 | describe("GitHubSourceControlProvider change request lookups by head", () => { | |
| 222 | const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); | |
| 223 | ||
| 224 | it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { | |
| 225 | const documents: Array<GitHubApi.GitHubGraphQlInput> = []; | |
| 226 | const { layer } = harness({ | |
| 227 | remotes, | |
| 228 | api: { | |
| 229 | graphql: (input) => | |
| 230 | Effect.sync(() => { | |
| 231 | documents.push(input); | |
| 232 | return encodeJson({ | |
| 233 | data: { | |
| 234 | repository: { | |
| 235 | h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, | |
| 236 | h1: { nodes: [] }, | |
| 237 | }, | |
| 238 | }, | |
| 239 | }); | |
| 240 | }), | |
| 241 | }, | |
| 101 | 242 | }); |
| 243 | return Effect.gen(function* () { | |
| 244 | const gh = yield* GitHubSourceControlProvider.make; | |
| 245 | const lookup = (headSelector: string) => | |
| 246 | gh | |
| 247 | .listChangeRequests({ | |
| 248 | cwd: "/repo", | |
| 249 | headSelector, | |
| 250 | state: "all", | |
| 251 | limit: 100, | |
| 252 | context: githubContext("github.com"), | |
| 253 | }) | |
| 254 | .pipe(Effect.forkChild); | |
| 255 | // Each branch's own git reads come first, so a sweep's lookups arrive spread out. | |
| 256 | const firstLookup = yield* lookup("feature/a"); | |
| 257 | yield* TestClock.adjust("200 millis"); | |
| 258 | const secondLookup = yield* lookup("feature/b"); | |
| 259 | yield* TestClock.adjust("300 millis"); | |
| 260 | const first = yield* Fiber.join(firstLookup); | |
| 261 | const second = yield* Fiber.join(secondLookup); | |
| 262 | assert.deepStrictEqual( | |
| 263 | first?.map((pr) => pr.number), | |
| 264 | [7], | |
| 265 | ); | |
| 266 | assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); | |
| 267 | assert.deepStrictEqual(second, []); | |
| 268 | assert.strictEqual(documents.length, 1); | |
| 269 | assert.include(documents[0]!.query, "headRefOid"); | |
| 270 | assert.deepStrictEqual(documents[0]!.variables, { | |
| 271 | owner: "acme", | |
| 272 | name: "web", | |
| 273 | h0: "feature/a", | |
| 274 | s0: ["OPEN", "CLOSED", "MERGED"], | |
| 275 | h1: "feature/b", | |
| 276 | s1: ["OPEN", "CLOSED", "MERGED"], | |
| 277 | }); | |
| 278 | }).pipe(Effect.provide(layer)); | |
| 279 | }); | |
| 102 | 280 | |
| 103 | const error = yield* provider | |
| 104 | .getChangeRequest({ | |
| 105 | cwd: "/repo", | |
| 106 | reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", | |
| 107 | }) | |
| 108 | .pipe(Effect.flip); | |
| 281 | it.effect("caps a background document at twenty-five heads", () => { | |
| 282 | const headCounts: Array<number> = []; | |
| 283 | const { layer } = harness({ | |
| 284 | remotes, | |
| 285 | api: { | |
| 286 | graphql: (input) => | |
| 287 | Effect.sync(() => { | |
| 288 | const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); | |
| 289 | headCounts.push(heads.length); | |
| 290 | return encodeJson({ | |
| 291 | data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, | |
| 292 | }); | |
| 293 | }), | |
| 294 | }, | |
| 295 | }); | |
| 296 | return Effect.gen(function* () { | |
| 297 | const gh = yield* GitHubSourceControlProvider.make; | |
| 298 | const lookups = yield* Effect.all( | |
| 299 | Array.from({ length: 26 }, (_, index) => | |
| 300 | gh.listChangeRequests({ | |
| 301 | cwd: "/repo", | |
| 302 | headSelector: `feature/${index}`, | |
| 303 | state: "all", | |
| 304 | limit: 100, | |
| 305 | context: githubContext("github.com"), | |
| 306 | }), | |
| 307 | ), | |
| 308 | { concurrency: "unbounded" }, | |
| 309 | ).pipe(Effect.forkChild); | |
| 310 | yield* TestClock.adjust("500 millis"); | |
| 311 | yield* Fiber.join(lookups); | |
| 312 | assert.deepStrictEqual( | |
| 313 | headCounts.toSorted((a, b) => a - b), | |
| 314 | [1, 25], | |
| 315 | ); | |
| 316 | }).pipe(Effect.provide(layer)); | |
| 317 | }); | |
| 109 | 318 | |
| 110 | assert.deepStrictEqual( | |
| 111 | { | |
| 112 | provider: error.provider, | |
| 113 | operation: error.operation, | |
| 114 | cwd: error.cwd, | |
| 115 | reference: error.reference, | |
| 116 | detail: error.detail, | |
| 319 | it.effect("matches an owner:branch selector on the head owner", () => { | |
| 320 | const { layer } = harness({ | |
| 321 | remotes, | |
| 322 | api: { | |
| 323 | graphql: (input) => | |
| 324 | Effect.succeed( | |
| 325 | encodeJson({ | |
| 326 | data: { | |
| 327 | repository: { | |
| 328 | h0: { | |
| 329 | nodes: | |
| 330 | input.variables?.h0 === "main" | |
| 331 | ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] | |
| 332 | : [], | |
| 333 | }, | |
| 334 | }, | |
| 335 | }, | |
| 336 | }), | |
| 337 | ), | |
| 117 | 338 | }, |
| 118 | { | |
| 119 | provider: "github", | |
| 120 | operation: "getChangeRequest", | |
| 339 | }); | |
| 340 | return Effect.gen(function* () { | |
| 341 | const gh = yield* GitHubSourceControlProvider.make; | |
| 342 | const open = yield* gh | |
| 343 | .listChangeRequests({ cwd: "/repo", headSelector: "me:main", state: "open", limit: 1 }) | |
| 344 | .pipe(Effect.forkChild); | |
| 345 | yield* TestClock.adjust("50 millis"); | |
| 346 | assert.deepStrictEqual( | |
| 347 | (yield* Fiber.join(open)).map((pr) => pr.number), | |
| 348 | [8], | |
| 349 | ); | |
| 350 | }).pipe(Effect.provide(layer)); | |
| 351 | }); | |
| 352 | ||
| 353 | it.effect("maps API failures onto the errors callers handle", () => { | |
| 354 | const { layer } = harness({ | |
| 355 | remotes, | |
| 356 | api: { | |
| 357 | graphql: (input) => | |
| 358 | Effect.fail( | |
| 359 | input.variables?.h0 === "missing" | |
| 360 | ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) | |
| 361 | : new GitHubApi.GitHubApiRateLimitError({ | |
| 362 | host: "github.com", | |
| 363 | operation: "x", | |
| 364 | retryAt: 123, | |
| 365 | }), | |
| 366 | ), | |
| 367 | }, | |
| 368 | }); | |
| 369 | return Effect.gen(function* () { | |
| 370 | const gh = yield* GitHubSourceControlProvider.make; | |
| 371 | const read = (headSelector: string) => | |
| 372 | gh | |
| 373 | .listChangeRequests({ cwd: "/repo", headSelector, state: "open", limit: 1 }) | |
| 374 | .pipe(Effect.flip, Effect.forkChild); | |
| 375 | const missing = yield* read("missing"); | |
| 376 | yield* TestClock.adjust("500 millis"); | |
| 377 | assert.include((yield* Fiber.join(missing)).detail, "No GitHub credential on the server"); | |
| 378 | const limited = yield* read("limited"); | |
| 379 | yield* TestClock.adjust("500 millis"); | |
| 380 | const error = yield* Fiber.join(limited); | |
| 381 | assert.include(error.detail, "rate limit exceeded"); | |
| 382 | assert.propertyVal(error.cause, "retryAt", 123); | |
| 383 | }).pipe(Effect.provide(layer)); | |
| 384 | }); | |
| 385 | }); | |
| 386 | ||
| 387 | describe("GitHubSourceControlProvider.getChangeRequest", () => { | |
| 388 | it.effect("reads a pull request by number, and by URL on its own repository", () => { | |
| 389 | const variables: Array<unknown> = []; | |
| 390 | const { layer } = harness({ | |
| 391 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 392 | api: { | |
| 393 | graphql: (input) => | |
| 394 | Effect.sync(() => { | |
| 395 | variables.push(input.variables); | |
| 396 | return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); | |
| 397 | }), | |
| 398 | }, | |
| 399 | }); | |
| 400 | return Effect.gen(function* () { | |
| 401 | const gh = yield* GitHubSourceControlProvider.make; | |
| 402 | assert.strictEqual( | |
| 403 | (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number, | |
| 404 | 42, | |
| 405 | ); | |
| 406 | yield* gh.getChangeRequest({ | |
| 121 | 407 | cwd: "/repo", |
| 122 | reference: "https://github.com/pingdotgg/t3code/pull/42", | |
| 123 | detail: "Pull request not found. Check the PR number or URL and try again.", | |
| 408 | reference: "https://github.com/other/thing/pull/42", | |
| 409 | }); | |
| 410 | assert.deepStrictEqual(variables, [ | |
| 411 | { owner: "acme", name: "web", number: 42 }, | |
| 412 | { owner: "other", name: "thing", number: 42 }, | |
| 413 | ]); | |
| 414 | }).pipe(Effect.provide(layer)); | |
| 415 | }); | |
| 416 | ||
| 417 | it.effect("fails a missing pull request as not found", () => { | |
| 418 | const { layer } = harness({ | |
| 419 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 420 | api: { | |
| 421 | graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), | |
| 124 | 422 | }, |
| 125 | ); | |
| 126 | assert.strictEqual(error.cause, cause); | |
| 127 | assert.equal(error.message.includes("raw upstream detail"), false); | |
| 128 | }), | |
| 129 | ); | |
| 423 | }); | |
| 424 | return Effect.gen(function* () { | |
| 425 | const gh = yield* GitHubSourceControlProvider.make; | |
| 426 | const error = yield* gh.getChangeRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); | |
| 427 | assert.include(error.detail, "Pull request not found"); | |
| 428 | }).pipe(Effect.provide(layer)); | |
| 429 | }); | |
| 430 | }); | |
| 130 | 431 | |
| 131 | it.effect("lists change request history through the batched head lookup", () => | |
| 132 | Effect.gen(function* () { | |
| 133 | let lookup: | |
| 134 | | Parameters<GitHubRepositoryApi.GitHubRepositoryApi["Service"]["listPullRequestsByHead"]>[0] | |
| 135 | | null = null; | |
| 136 | const provider = yield* makeProvider({ | |
| 137 | listPullRequestsByHead: (input) => { | |
| 138 | lookup = input; | |
| 139 | return Effect.succeed([ | |
| 140 | { | |
| 141 | number: 7, | |
| 142 | title: "Merged work", | |
| 143 | url: "https://enterprise.test/acme/web/pull/7", | |
| 144 | baseRefName: "main", | |
| 145 | headRefName: "feature/merged", | |
| 146 | headSha: "a".repeat(40), | |
| 147 | state: "merged", | |
| 148 | mergedAt: "2026-01-01T00:00:00Z", | |
| 149 | updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), | |
| 150 | }, | |
| 151 | ]); | |
| 432 | describe("GitHubSourceControlProvider writes", () => { | |
| 433 | it.effect("creates a cross-repository pull request with an owner:branch head", () => { | |
| 434 | const requests: Array<GitHubApi.GitHubRestInput> = []; | |
| 435 | const { layer } = harness({ | |
| 436 | remotes: remotesOutput( | |
| 437 | ["origin", "git@github.com:me/web.git"], | |
| 438 | ["upstream", "git@github.com:acme/web.git"], | |
| 439 | ), | |
| 440 | api: { | |
| 441 | rest: (input) => | |
| 442 | Effect.sync(() => { | |
| 443 | requests.push(input); | |
| 444 | return restResponse({ number: 1 }, 201); | |
| 445 | }), | |
| 152 | 446 | }, |
| 153 | 447 | }); |
| 448 | return Effect.gen(function* () { | |
| 449 | const fs = yield* FileSystem.FileSystem; | |
| 450 | const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); | |
| 451 | yield* fs.writeFileString(bodyFile, "Body"); | |
| 452 | const gh = yield* GitHubSourceControlProvider.make; | |
| 453 | yield* gh.createChangeRequest({ | |
| 454 | cwd: "/repo", | |
| 455 | baseRefName: "main", | |
| 456 | headSelector: "me:feature", | |
| 457 | title: "Title", | |
| 458 | bodyFile, | |
| 459 | }); | |
| 460 | assert.strictEqual(requests[0]!.method, "POST"); | |
| 461 | assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); | |
| 462 | assert.deepStrictEqual(requests[0]!.body, { | |
| 463 | base: "main", | |
| 464 | head: "me:feature", | |
| 465 | title: "Title", | |
| 466 | body: "Body", | |
| 467 | maintainer_can_modify: true, | |
| 468 | }); | |
| 469 | }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); | |
| 470 | }); | |
| 154 | 471 | |
| 155 | const changeRequests = yield* provider.listChangeRequests({ | |
| 156 | cwd: "/repo", | |
| 157 | context: { | |
| 158 | provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" }, | |
| 159 | remoteName: "origin", | |
| 160 | remoteUrl: "https://enterprise.test/acme/web.git", | |
| 472 | it.effect("creates a repository under an organization the viewer is not", () => { | |
| 473 | const requests: Array<string> = []; | |
| 474 | const { layer } = harness({ | |
| 475 | remotes: "", | |
| 476 | api: { | |
| 477 | rest: (input) => | |
| 478 | Effect.sync(() => { | |
| 479 | requests.push(`${input.method ?? "GET"} ${input.path}`); | |
| 480 | return input.path === "user" | |
| 481 | ? restResponse({ login: "me" }) | |
| 482 | : restResponse({ | |
| 483 | full_name: "acme/new", | |
| 484 | html_url: "https://github.com/acme/new", | |
| 485 | ssh_url: "git@github.com:acme/new.git", | |
| 486 | }); | |
| 487 | }), | |
| 161 | 488 | }, |
| 162 | headSelector: "feature/merged", | |
| 163 | state: "all", | |
| 164 | limit: 10, | |
| 489 | }); | |
| 490 | return Effect.gen(function* () { | |
| 491 | const gh = yield* GitHubSourceControlProvider.make; | |
| 492 | const urls = yield* gh.createRepository({ | |
| 493 | cwd: "/repo", | |
| 494 | repository: "acme/new", | |
| 495 | visibility: "private", | |
| 496 | }); | |
| 497 | assert.deepStrictEqual(urls, { | |
| 498 | nameWithOwner: "acme/new", | |
| 499 | url: "https://github.com/acme/new", | |
| 500 | sshUrl: "git@github.com:acme/new.git", | |
| 501 | }); | |
| 502 | assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); | |
| 503 | }).pipe(Effect.provide(layer)); | |
| 504 | }); | |
| 505 | }); | |
| 506 | ||
| 507 | describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { | |
| 508 | const repository = (fullName: string, defaultBranch = "main") => | |
| 509 | restResponse({ | |
| 510 | full_name: fullName, | |
| 511 | html_url: `https://github.com/${fullName}`, | |
| 512 | ssh_url: `git@github.com:${fullName}.git`, | |
| 513 | default_branch: defaultBranch, | |
| 165 | 514 | }); |
| 166 | 515 | |
| 167 | assert.deepStrictEqual(lookup, { | |
| 168 | cwd: "/repo", | |
| 169 | headSelector: "feature/merged", | |
| 170 | state: "all", | |
| 171 | limit: 10, | |
| 172 | rateLimitHost: "enterprise.test", | |
| 516 | it.effect("checks a same-repository pull request out from its head branch", () => { | |
| 517 | const { layer, git } = harness({ | |
| 518 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 519 | api: { | |
| 520 | graphql: () => | |
| 521 | Effect.succeed( | |
| 522 | encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), | |
| 523 | ), | |
| 524 | }, | |
| 173 | 525 | }); |
| 174 | assert.strictEqual(changeRequests[0]?.provider, "github"); | |
| 175 | assert.strictEqual(changeRequests[0]?.state, "merged"); | |
| 176 | assert.strictEqual(changeRequests[0]?.headSha, "a".repeat(40)); | |
| 177 | assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); | |
| 178 | assert.deepStrictEqual( | |
| 179 | changeRequests[0]?.updatedAt, | |
| 180 | Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), | |
| 181 | ); | |
| 182 | }), | |
| 183 | ); | |
| 526 | return Effect.gen(function* () { | |
| 527 | const gh = yield* GitHubSourceControlProvider.make; | |
| 528 | yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "5" }); | |
| 529 | assert.deepStrictEqual(git, [ | |
| 530 | [ | |
| 531 | "fetchRemoteTrackingBranch", | |
| 532 | { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 533 | ], | |
| 534 | ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], | |
| 535 | ["switchRef", { cwd: "/repo", refName: "feature/x" }], | |
| 536 | [ | |
| 537 | "setBranchUpstream", | |
| 538 | { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, | |
| 539 | ], | |
| 540 | ]); | |
| 541 | }).pipe(Effect.provide(layer)); | |
| 542 | }); | |
| 184 | 543 | |
| 185 | it.effect("creates GitHub PRs through provider-neutral input names", () => | |
| 186 | Effect.gen(function* () { | |
| 187 | let createInput: | |
| 188 | | Parameters<GitHubRepositoryApi.GitHubRepositoryApi["Service"]["createPullRequest"]>[0] | |
| 189 | | null = null; | |
| 190 | const provider = yield* makeProvider({ | |
| 191 | createPullRequest: (input) => { | |
| 192 | createInput = input; | |
| 193 | return Effect.void; | |
| 544 | it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { | |
| 545 | const { layer, git } = harness({ | |
| 546 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 547 | localBranches: ["main"], | |
| 548 | api: { | |
| 549 | graphql: () => | |
| 550 | Effect.succeed( | |
| 551 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 552 | ), | |
| 553 | rest: () => | |
| 554 | Effect.fail( | |
| 555 | new GitHubApi.GitHubApiRequestError({ | |
| 556 | host: "github.com", | |
| 557 | operation: "x", | |
| 558 | cause: "offline", | |
| 559 | }), | |
| 560 | ), | |
| 194 | 561 | }, |
| 195 | 562 | }); |
| 563 | return Effect.gen(function* () { | |
| 564 | const gh = yield* GitHubSourceControlProvider.make; | |
| 565 | yield* Effect.flip(gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true })); | |
| 566 | // Nothing touched the local branches: `main` must not be reset to the fork's commit. | |
| 567 | assert.deepStrictEqual(git, []); | |
| 568 | }).pipe(Effect.provide(layer)); | |
| 569 | }); | |
| 196 | 570 | |
| 197 | yield* provider.createChangeRequest({ | |
| 198 | cwd: "/repo", | |
| 199 | baseRefName: "main", | |
| 200 | headSelector: "owner:feature/provider", | |
| 201 | title: "Provider PR", | |
| 202 | bodyFile: "/tmp/body.md", | |
| 571 | it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { | |
| 572 | const { layer, git } = harness({ | |
| 573 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 574 | localBranches: ["someone/main"], | |
| 575 | api: { | |
| 576 | graphql: () => | |
| 577 | Effect.succeed( | |
| 578 | encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), | |
| 579 | ), | |
| 580 | rest: (input) => | |
| 581 | Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), | |
| 582 | }, | |
| 203 | 583 | }); |
| 584 | return Effect.gen(function* () { | |
| 585 | const gh = yield* GitHubSourceControlProvider.make; | |
| 586 | yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true }); | |
| 587 | assert.deepStrictEqual(git, [ | |
| 588 | [ | |
| 589 | "ensureRemote", | |
| 590 | { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, | |
| 591 | ], | |
| 592 | [ | |
| 593 | "fetchRemoteTrackingBranch", | |
| 594 | { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, | |
| 595 | ], | |
| 596 | ["switchRef", { cwd: "/repo", refName: "someone/main" }], | |
| 597 | ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], | |
| 598 | [ | |
| 599 | "setBranchUpstream", | |
| 600 | { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, | |
| 601 | ], | |
| 602 | ]); | |
| 603 | }).pipe(Effect.provide(layer)); | |
| 604 | }); | |
| 204 | 605 | |
| 205 | assert.deepStrictEqual(createInput, { | |
| 206 | cwd: "/repo", | |
| 207 | baseBranch: "main", | |
| 208 | headSelector: "owner:feature/provider", | |
| 209 | title: "Provider PR", | |
| 210 | bodyFile: "/tmp/body.md", | |
| 606 | it.effect("keeps git's own output out of what a failed checkout reports", () => { | |
| 607 | const { layer } = harness({ | |
| 608 | remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), | |
| 609 | gitFailure: new Error("fatal: /home/me/secret-path: permission denied"), | |
| 610 | api: { | |
| 611 | graphql: () => | |
| 612 | Effect.succeed( | |
| 613 | encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), | |
| 614 | ), | |
| 615 | }, | |
| 211 | 616 | }); |
| 212 | }), | |
| 213 | ); | |
| 617 | return Effect.gen(function* () { | |
| 618 | const gh = yield* GitHubSourceControlProvider.make; | |
| 619 | const error = yield* gh | |
| 620 | .checkoutChangeRequest({ cwd: "/repo", reference: "5" }) | |
| 621 | .pipe(Effect.flip); | |
| 622 | assert.strictEqual(error.detail, "The pull request could not be checked out with git."); | |
| 623 | assert.notInclude(error.message, "secret-path"); | |
| 624 | }).pipe(Effect.provide(layer)); | |
| 625 | }); | |
| 626 | ||
| 627 | it("names the local branch the way gh pr checkout does", () => { | |
| 628 | const name = (headRefName: string, isCrossRepository: boolean) => | |
| 629 | GitHubSourceControlProvider.pullRequestCheckoutBranchName({ | |
| 630 | headRefName, | |
| 631 | headOwner: "someone", | |
| 632 | isCrossRepository, | |
| 633 | defaultBranch: "main", | |
| 634 | }); | |
| 635 | assert.strictEqual(name("main", true), "someone/main"); | |
| 636 | assert.strictEqual(name("feature", true), "feature"); | |
| 637 | assert.strictEqual(name("main", false), "main"); | |
| 638 | }); | |
| 639 | }); | |
| 214 | 640 | |
| 215 | 641 | it("accepts active authenticated GitHub accounts when another account fails", () => { |
| 216 | 642 | const auth = GitHubSourceControlProvider.discovery.parseAuth( |
| ... | ... | @@ -409,20 +835,15 @@ it.effect.each(["pull", "issues"])( |
| 409 | 835 | "resolves %s subjects on the linked host without using the checkout", |
| 410 | 836 | (kind) => |
| 411 | 837 | Effect.gen(function* () { |
| 412 | const provider = yield* makeProvider( | |
| 413 | {}, | |
| 414 | { | |
| 415 | rest: (input) => { | |
| 416 | assert.strictEqual(input.host, "github.com"); | |
| 417 | assert.strictEqual(input.path, "repos/owner/repo/issues/42"); | |
| 418 | return Effect.succeed( | |
| 419 | restResponse( | |
| 420 | encodeJson({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), | |
| 421 | ), | |
| 422 | ); | |
| 423 | }, | |
| 838 | const provider = yield* makeProvider({ | |
| 839 | rest: (input) => { | |
| 840 | assert.strictEqual(input.host, "github.com"); | |
| 841 | assert.strictEqual(input.path, "repos/owner/repo/issues/42"); | |
| 842 | return Effect.succeed( | |
| 843 | restResponse({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), | |
| 844 | ); | |
| 424 | 845 | }, |
| 425 | ); | |
| 846 | }); | |
| 426 | 847 | const lookup = provider.resolveLink?.({ |
| 427 | 848 | cwd: "/unrelated", |
| 428 | 849 | url: new URL(`https://github.com/owner/repo/${kind}/42`), |
| ... | ... | @@ -451,15 +872,12 @@ it.effect.each(["read", "decode"] as const)( |
| 451 | 872 | operation: "resolveLink", |
| 452 | 873 | status: 500, |
| 453 | 874 | }); |
| 454 | const provider = yield* makeProvider( | |
| 455 | {}, | |
| 456 | { | |
| 457 | rest: () => | |
| 458 | stage === "read" | |
| 459 | ? Effect.fail(cause) | |
| 460 | : Effect.succeed(restResponse("private response text")), | |
| 461 | }, | |
| 462 | ); | |
| 875 | const provider = yield* makeProvider({ | |
| 876 | rest: () => | |
| 877 | stage === "read" | |
| 878 | ? Effect.fail(cause) | |
| 879 | : Effect.succeed({ ...restResponse(undefined), body: "private response text" }), | |
| 880 | }); | |
| 463 | 881 | const lookup = provider.resolveLink?.({ |
| 464 | 882 | cwd: "/repo", |
| 465 | 883 | url: new URL("https://github.com/owner/repo/issues/42"), |
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+716-195| ... | ... | @@ -1,21 +1,42 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import * as DateTime from "effect/DateTime"; | |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Data from "effect/Data"; | |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 4 | 6 | import * as Option from "effect/Option"; |
| 7 | import * as Request from "effect/Request"; | |
| 8 | import * as RequestResolver from "effect/RequestResolver"; | |
| 5 | 9 | import * as Result from "effect/Result"; |
| 10 | import * as Schema from "effect/Schema"; | |
| 6 | 11 | import { |
| 7 | 12 | DEFAULT_SERVER_SETTINGS, |
| 8 | 13 | SourceControlProviderError, |
| 14 | TrimmedNonEmptyString, | |
| 9 | 15 | type ChangeRequest, |
| 10 | 16 | type GitHubSettings, |
| 11 | 17 | type SourceControlProviderDiscoveryItem, |
| 18 | type SourceControlRepositoryCloneUrls, | |
| 12 | 19 | } from "@t3tools/contracts"; |
| 13 | ||
| 20 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 14 | 21 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 22 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 23 | import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; | |
| 15 | 24 | |
| 16 | 25 | import * as ServerSettings from "../serverSettings.ts"; |
| 26 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 17 | 27 | import * as GitHubApi from "./GitHubApi.ts"; |
| 18 | import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; | |
| 28 | import { | |
| 29 | decodeGitHubPullRequestEntries, | |
| 30 | type NormalizedGitHubPullRequestRecord, | |
| 31 | } from "./gitHubPullRequests.ts"; | |
| 32 | import { | |
| 33 | parseFetchRemotes, | |
| 34 | parseGitHubRepositorySelector, | |
| 35 | parsePullRequestReference, | |
| 36 | resolveGitHubRepository, | |
| 37 | type GitHubRepositoryLocator, | |
| 38 | } from "./gitHubRepositoryResolution.ts"; | |
| 39 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | |
| 19 | 40 | import { |
| 20 | 41 | effectiveGitHubAccount, |
| 21 | 42 | findAuthenticatedGitHubAccount, |
| ... | ... | @@ -40,33 +61,8 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( |
| 40 | 61 | ), |
| 41 | 62 | ); |
| 42 | 63 | |
| 43 | function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest { | |
| 44 | return { | |
| 45 | provider: "github", | |
| 46 | number: summary.number, | |
| 47 | title: summary.title, | |
| 48 | url: summary.url, | |
| 49 | baseRefName: summary.baseRefName, | |
| 50 | headRefName: summary.headRefName, | |
| 51 | ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}), | |
| 52 | state: summary.state ?? "open", | |
| 53 | ...(summary.isDraft === true ? { isDraft: true } : {}), | |
| 54 | closedAt: summary.closedAt ?? null, | |
| 55 | mergedAt: summary.mergedAt ?? null, | |
| 56 | updatedAt: | |
| 57 | summary.updatedAt === undefined | |
| 58 | ? Option.none() | |
| 59 | : Option.some(DateTime.makeUnsafe(summary.updatedAt)), | |
| 60 | ...(summary.isCrossRepository !== undefined | |
| 61 | ? { isCrossRepository: summary.isCrossRepository } | |
| 62 | : {}), | |
| 63 | ...(summary.headRepositoryNameWithOwner !== undefined | |
| 64 | ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } | |
| 65 | : {}), | |
| 66 | ...(summary.headRepositoryOwnerLogin !== undefined | |
| 67 | ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } | |
| 68 | : {}), | |
| 69 | }; | |
| 64 | function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { | |
| 65 | return { provider: "github", ...record }; | |
| 70 | 66 | } |
| 71 | 67 | |
| 72 | 68 | function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) { |
| ... | ... | @@ -263,77 +259,601 @@ export const makeDiscovery = Effect.gen(function* () { |
| 263 | 259 | } satisfies SourceControlManagedCliDiscoverySpec; |
| 264 | 260 | }); |
| 265 | 261 | |
| 262 | /** | |
| 263 | * Why a GitHub read or write failed, in words a user can act on. The provider methods attach the | |
| 264 | * operation and request context; `cause` keeps the raw failure for logs, never for transport. | |
| 265 | */ | |
| 266 | class GitHubFailure extends Data.TaggedError("GitHubFailure")<{ | |
| 267 | readonly detail: string; | |
| 268 | readonly cause: unknown; | |
| 269 | }> {} | |
| 270 | ||
| 271 | const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause }); | |
| 272 | ||
| 273 | const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again."; | |
| 274 | const REPOSITORY_NOT_FOUND = "Repository not found. Check the owner and name and try again."; | |
| 275 | ||
| 276 | /** `notFound` names what was missing, which only the operation knows. */ | |
| 277 | function fromGitHubApiError( | |
| 278 | error: GitHubApi.GitHubApiError, | |
| 279 | notFound = PULL_REQUEST_NOT_FOUND, | |
| 280 | ): GitHubFailure { | |
| 281 | switch (error._tag) { | |
| 282 | case "GitHubCliMissingError": | |
| 283 | return failure( | |
| 284 | "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", | |
| 285 | error, | |
| 286 | ); | |
| 287 | case "GitHubNotSignedInError": | |
| 288 | case "GitHubHostDisabledError": | |
| 289 | // A missing or turned-off credential already says what to do about it. | |
| 290 | return failure(error.message, error); | |
| 291 | case "GitHubApiAuthenticationError": | |
| 292 | return failure( | |
| 293 | "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", | |
| 294 | error, | |
| 295 | ); | |
| 296 | case "GitHubApiRateLimitError": | |
| 297 | case "SourceControlRateLimitPausedError": | |
| 298 | return failure( | |
| 299 | "GitHub API rate limit exceeded. Requests resume when the limit resets.", | |
| 300 | error, | |
| 301 | ); | |
| 302 | case "GitHubApiNotFoundError": | |
| 303 | return failure(notFound, error); | |
| 304 | case "GitHubCliFailedError": | |
| 305 | case "GitHubApiResponseError": | |
| 306 | case "GitHubApiRequestError": | |
| 307 | // GitHub's own reason ("A pull request already exists…") when it gave one. | |
| 308 | return failure(error.message.trim() || "GitHub request failed.", error); | |
| 309 | } | |
| 310 | } | |
| 311 | ||
| 312 | const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause)); | |
| 313 | ||
| 314 | const RawRepositorySchema = Schema.Struct({ | |
| 315 | full_name: TrimmedNonEmptyString, | |
| 316 | html_url: TrimmedNonEmptyString, | |
| 317 | ssh_url: TrimmedNonEmptyString, | |
| 318 | default_branch: Schema.optional(Schema.NullOr(Schema.String)), | |
| 319 | }); | |
| 320 | const decodeRawRepository = decodeJsonResult(RawRepositorySchema); | |
| 321 | ||
| 322 | function repositoryCloneUrls( | |
| 323 | raw: Schema.Schema.Type<typeof RawRepositorySchema>, | |
| 324 | ): SourceControlRepositoryCloneUrls { | |
| 325 | return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; | |
| 326 | } | |
| 327 | ||
| 328 | const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); | |
| 329 | ||
| 330 | type PullRequestListState = "open" | "closed" | "merged" | "all"; | |
| 331 | ||
| 332 | /** The pull request fields every read selects, in GraphQL. */ | |
| 333 | const PULL_REQUEST_NODE_SELECTION = | |
| 334 | "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; | |
| 335 | const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = { | |
| 336 | open: ["OPEN"], | |
| 337 | closed: ["CLOSED"], | |
| 338 | merged: ["MERGED"], | |
| 339 | all: ["OPEN", "CLOSED", "MERGED"], | |
| 340 | }; | |
| 341 | /** | |
| 342 | * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one | |
| 343 | * single lookup, but half that keeps each answer near half a second. | |
| 344 | */ | |
| 345 | const HEAD_LOOKUPS_PER_DOCUMENT = 50; | |
| 346 | /** | |
| 347 | * How long a head lookup waits for company. Branch discovery reaches GitHub only after each | |
| 348 | * branch's own git reads, so lookups started together arrive tens of milliseconds apart. | |
| 349 | * A background sweep's lookups spread over up to ~300ms, and every document costs a point no | |
| 350 | * matter how few heads it holds, so reads without reserve wait longer. | |
| 351 | */ | |
| 352 | const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; | |
| 353 | const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; | |
| 354 | /** | |
| 355 | * Background documents fill up under the longer window, and a failed document fails every head | |
| 356 | * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own | |
| 357 | * processing limit; twenty-five took ~7s. | |
| 358 | */ | |
| 359 | const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; | |
| 360 | /** A full document is 5,000 rows of well under 2 KB each. */ | |
| 361 | const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; | |
| 362 | /** | |
| 363 | * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch | |
| 364 | * named like a busy default (`main`) is the case this bounds; the owner's own row is near the | |
| 365 | * top because the newest come first. | |
| 366 | */ | |
| 367 | const OWNER_HEAD_SCAN_LIMIT = 100; | |
| 368 | ||
| 369 | class PullRequestsByHeadRead extends Request.Class< | |
| 370 | { | |
| 371 | readonly host: string; | |
| 372 | readonly owner: string; | |
| 373 | readonly name: string; | |
| 374 | readonly headRefName: string; | |
| 375 | readonly state: PullRequestListState; | |
| 376 | readonly limit: number; | |
| 377 | readonly allowReserve: boolean; | |
| 378 | }, | |
| 379 | ReadonlyArray<NormalizedGitHubPullRequestRecord>, | |
| 380 | GitHubFailure | |
| 381 | > {} | |
| 382 | ||
| 383 | /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ | |
| 384 | function buildPullRequestsByHeadQuery( | |
| 385 | lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>, | |
| 386 | ): { readonly document: string; readonly variables: Record<string, unknown> } { | |
| 387 | const variables: Record<string, unknown> = {}; | |
| 388 | const declarations: string[] = ["$owner: String!", "$name: String!"]; | |
| 389 | const selections: string[] = []; | |
| 390 | for (const [index, lookup] of lookups.entries()) { | |
| 391 | variables[`h${index}`] = lookup.headRefName; | |
| 392 | variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; | |
| 393 | declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); | |
| 394 | // `gh pr list` orders the same way, so a head with more matches than the limit keeps the | |
| 395 | // same rows. | |
| 396 | selections.push( | |
| 397 | ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, | |
| 398 | ); | |
| 399 | } | |
| 400 | return { | |
| 401 | document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, | |
| 402 | variables, | |
| 403 | }; | |
| 404 | } | |
| 405 | ||
| 406 | const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { | |
| 407 | repository(owner: $owner, name: $name) { | |
| 408 | pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } | |
| 409 | } | |
| 410 | }`; | |
| 411 | ||
| 412 | const decodePullRequestsByHead = decodeJsonResult( | |
| 413 | Schema.Struct({ | |
| 414 | data: Schema.Struct({ | |
| 415 | repository: Schema.NullOr( | |
| 416 | Schema.Record( | |
| 417 | Schema.String, | |
| 418 | Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), | |
| 419 | ), | |
| 420 | ), | |
| 421 | }), | |
| 422 | }), | |
| 423 | ); | |
| 424 | ||
| 425 | const decodePullRequestByNumber = decodeJsonResult( | |
| 426 | Schema.Struct({ | |
| 427 | data: Schema.Struct({ | |
| 428 | repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), | |
| 429 | }), | |
| 430 | }), | |
| 431 | ); | |
| 432 | ||
| 433 | /** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ | |
| 434 | export function pullRequestCheckoutBranchName(input: { | |
| 435 | readonly headRefName: string; | |
| 436 | readonly headOwner: string | null; | |
| 437 | readonly isCrossRepository: boolean; | |
| 438 | readonly defaultBranch: string | null; | |
| 439 | }): string { | |
| 440 | // gh prefixes the owner only where the fork's branch would take over the default branch's | |
| 441 | // name, which is the one collision every fork pull request from `main` would hit. | |
| 442 | return input.isCrossRepository && | |
| 443 | input.headOwner !== null && | |
| 444 | input.defaultBranch !== null && | |
| 445 | input.headRefName === input.defaultBranch | |
| 446 | ? `${input.headOwner}/${input.headRefName}` | |
| 447 | : input.headRefName; | |
| 448 | } | |
| 449 | ||
| 450 | /** The checkout's GitHub API host, from the remote the caller resolved the provider from. */ | |
| 451 | const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => | |
| 452 | context === undefined ? undefined : new URL(context.provider.baseUrl).host; | |
| 453 | ||
| 266 | 454 | export const make = Effect.gen(function* () { |
| 267 | const github = yield* GitHubRepositoryApi.GitHubRepositoryApi; | |
| 268 | 455 | const api = yield* GitHubApi.GitHubApi; |
| 456 | const process = yield* VcsProcess.VcsProcess; | |
| 457 | const environment = yield* HostProcessEnvironment; | |
| 458 | const git = yield* GitVcsDriver.GitVcsDriver; | |
| 459 | const fileSystem = yield* FileSystem.FileSystem; | |
| 460 | ||
| 461 | const gitRead = (cwd: string, args: ReadonlyArray<string>) => | |
| 462 | process.run({ | |
| 463 | operation: "GitHubSourceControlProvider.resolveRepository", | |
| 464 | command: "git", | |
| 465 | args, | |
| 466 | cwd, | |
| 467 | allowNonZeroExit: true, | |
| 468 | timeoutMs: 5_000, | |
| 469 | }); | |
| 470 | ||
| 471 | /** | |
| 472 | * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else | |
| 473 | * the remote the caller resolved the provider from, else the best-ranked GitHub remote. | |
| 474 | */ | |
| 475 | const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( | |
| 476 | function* (input: { readonly cwd: string; readonly host?: string | undefined }) { | |
| 477 | const envRepository = environment.GH_REPO?.trim(); | |
| 478 | const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); | |
| 479 | if (envRepository) { | |
| 480 | const locator = parseGitHubRepositorySelector(envRepository, defaultHost); | |
| 481 | if (locator !== null) return locator; | |
| 482 | } | |
| 483 | const [remotes, resolved] = yield* Effect.all([ | |
| 484 | gitRead(input.cwd, ["remote", "-v"]), | |
| 485 | gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), | |
| 486 | ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); | |
| 487 | const { host, locator } = resolveGitHubRepository({ | |
| 488 | remotes: remotes?.exitCode === 0 ? remotes.stdout : "", | |
| 489 | resolved: resolved?.exitCode === 0 ? resolved.stdout : "", | |
| 490 | hostHint: input.host, | |
| 491 | defaultHost, | |
| 492 | }); | |
| 493 | if (locator !== null) return locator; | |
| 494 | return yield* failure( | |
| 495 | `No GitHub repository on ${host} was found among this checkout's git remotes.`, | |
| 496 | ); | |
| 497 | }, | |
| 498 | ); | |
| 499 | ||
| 500 | const graphqlJson = <A>( | |
| 501 | input: GitHubApi.GitHubGraphQlInput, | |
| 502 | decode: (raw: string) => Result.Result<A, unknown>, | |
| 503 | decodeDetail: string, | |
| 504 | ) => | |
| 505 | api.graphql(input).pipe( | |
| 506 | Effect.mapError(fromGitHubApiError), | |
| 507 | Effect.flatMap((raw) => { | |
| 508 | const decoded = decode(raw); | |
| 509 | return Result.isSuccess(decoded) | |
| 510 | ? Effect.succeed(decoded.success) | |
| 511 | : Effect.fail(failure(decodeDetail, decoded.failure)); | |
| 512 | }), | |
| 513 | ); | |
| 514 | ||
| 515 | const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) => | |
| 516 | api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); | |
| 517 | ||
| 518 | const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({ | |
| 519 | key: ({ request, context }) => | |
| 520 | [ | |
| 521 | request.host, | |
| 522 | request.owner, | |
| 523 | request.name, | |
| 524 | String(request.allowReserve), | |
| 525 | Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) | |
| 526 | ?.credentialFingerprint ?? "", | |
| 527 | Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), | |
| 528 | ].join("\0"), | |
| 529 | resolver: (entries) => { | |
| 530 | const [first] = entries; | |
| 531 | const { host, owner, name, allowReserve } = first.request; | |
| 532 | const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); | |
| 533 | return graphqlJson( | |
| 534 | { | |
| 535 | host, | |
| 536 | operation: "listPullRequestsByHead", | |
| 537 | query: query.document, | |
| 538 | variables: { owner, name, ...query.variables }, | |
| 539 | allowReserve, | |
| 540 | // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred | |
| 541 | // fork pull requests, so the usual cap would cut the answer short. | |
| 542 | maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, | |
| 543 | }, | |
| 544 | decodePullRequestsByHead, | |
| 545 | "GitHub returned an invalid change request list.", | |
| 546 | ).pipe( | |
| 547 | Effect.flatMap((decoded) => | |
| 548 | decoded.data.repository === null | |
| 549 | ? Effect.fail(notFound("The repository could not be read.")) | |
| 550 | : Effect.succeed(decoded.data.repository), | |
| 551 | ), | |
| 552 | Effect.map((aliases) => { | |
| 553 | for (const [index, entry] of entries.entries()) { | |
| 554 | const alias = aliases[`h${index}`]; | |
| 555 | entry.completeUnsafe( | |
| 556 | Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), | |
| 557 | ); | |
| 558 | } | |
| 559 | }), | |
| 560 | Effect.catchCause((cause) => | |
| 561 | Effect.sync(() => { | |
| 562 | for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); | |
| 563 | }), | |
| 564 | ), | |
| 565 | ); | |
| 566 | }, | |
| 567 | }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); | |
| 568 | const interactiveHeadResolver = headResolver.pipe( | |
| 569 | RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), | |
| 570 | ); | |
| 571 | const backgroundHeadResolver = headResolver.pipe( | |
| 572 | RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), | |
| 573 | RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), | |
| 574 | ); | |
| 575 | ||
| 576 | /** | |
| 577 | * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the | |
| 578 | * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive together | |
| 579 | * share one GraphQL document. | |
| 580 | */ | |
| 581 | const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: { | |
| 582 | readonly cwd: string; | |
| 583 | readonly headSelector: string; | |
| 584 | readonly state: PullRequestListState; | |
| 585 | readonly limit: number; | |
| 586 | readonly host?: string | undefined; | |
| 587 | readonly allowReserve: boolean; | |
| 588 | }) { | |
| 589 | const locator = yield* resolveRepository({ cwd: input.cwd, host: input.host }); | |
| 590 | const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); | |
| 591 | // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the | |
| 592 | // owner is matched on the rows it returns. | |
| 593 | const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); | |
| 594 | const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); | |
| 595 | const rows = yield* Effect.request( | |
| 596 | new PullRequestsByHeadRead({ | |
| 597 | ...locator, | |
| 598 | headRefName, | |
| 599 | state: input.state, | |
| 600 | limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, | |
| 601 | allowReserve: input.allowReserve, | |
| 602 | }), | |
| 603 | input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, | |
| 604 | ); | |
| 605 | if (!ownerMatch) return rows; | |
| 606 | const headOwner = ownerMatch[1]!.toLowerCase(); | |
| 607 | return rows | |
| 608 | .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) | |
| 609 | .slice(0, limit); | |
| 610 | }); | |
| 269 | 611 | |
| 270 | const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = | |
| 271 | (input) => { | |
| 272 | if (input.state === "open") { | |
| 273 | return github | |
| 274 | .listOpenPullRequests({ | |
| 612 | const readPullRequest = Effect.fn("GitHubSourceControlProvider.readPullRequest")( | |
| 613 | function* (input: { | |
| 614 | readonly cwd: string; | |
| 615 | readonly reference: string; | |
| 616 | readonly host?: string | undefined; | |
| 617 | }) { | |
| 618 | const parsed = parsePullRequestReference(input.reference); | |
| 619 | if (parsed.kind === "branch") { | |
| 620 | // `gh pr view <branch>` prefers an open pull request, then the newest of any state. | |
| 621 | const lookup = (state: PullRequestListState) => | |
| 622 | listByHead({ | |
| 275 | 623 | cwd: input.cwd, |
| 276 | headSelector: input.headSelector, | |
| 277 | ...(input.context === undefined | |
| 278 | ? {} | |
| 279 | : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), | |
| 280 | ...(input.limit !== undefined ? { limit: input.limit } : {}), | |
| 281 | }) | |
| 282 | .pipe( | |
| 283 | Effect.map((items) => items.map(toChangeRequest)), | |
| 284 | Effect.mapError( | |
| 285 | (error) => | |
| 286 | new SourceControlProviderError({ | |
| 287 | provider: "github", | |
| 288 | operation: "listChangeRequests", | |
| 289 | cwd: input.cwd, | |
| 290 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 291 | input.headSelector, | |
| 292 | ), | |
| 293 | detail: error.message, | |
| 294 | cause: error, | |
| 295 | }), | |
| 296 | ), | |
| 297 | ); | |
| 624 | headSelector: parsed.headSelector, | |
| 625 | state, | |
| 626 | limit: 1, | |
| 627 | host: input.host, | |
| 628 | allowReserve: true, | |
| 629 | }); | |
| 630 | const [open] = yield* lookup("open"); | |
| 631 | const found = open ?? (yield* lookup("all"))[0]; | |
| 632 | if (found === undefined) return yield* notFound("No pull request has this head branch."); | |
| 633 | return found; | |
| 298 | 634 | } |
| 635 | const locator = | |
| 636 | parsed.kind === "url" | |
| 637 | ? parsed.locator | |
| 638 | : yield* resolveRepository({ cwd: input.cwd, host: input.host }); | |
| 639 | const decodeDetail = "GitHub returned an invalid pull request."; | |
| 640 | const decoded = yield* graphqlJson( | |
| 641 | { | |
| 642 | host: locator.host, | |
| 643 | operation: "getPullRequest", | |
| 644 | query: PULL_REQUEST_BY_NUMBER_QUERY, | |
| 645 | variables: { owner: locator.owner, name: locator.name, number: parsed.number }, | |
| 646 | allowReserve: true, | |
| 647 | }, | |
| 648 | decodePullRequestByNumber, | |
| 649 | decodeDetail, | |
| 650 | ); | |
| 651 | const node = decoded.data.repository?.pullRequest; | |
| 652 | if (node == null) return yield* notFound("The pull request does not exist."); | |
| 653 | const [record] = decodeGitHubPullRequestEntries([node]); | |
| 654 | if (record === undefined) | |
| 655 | return yield* failure(decodeDetail, new Error("Malformed pull request.")); | |
| 656 | return record; | |
| 657 | }, | |
| 658 | ); | |
| 659 | ||
| 660 | const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* ( | |
| 661 | locator: GitHubRepositoryLocator, | |
| 662 | ) { | |
| 663 | const response = yield* rest( | |
| 664 | { | |
| 665 | host: locator.host, | |
| 666 | operation: "getRepository", | |
| 667 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, | |
| 668 | allowReserve: true, | |
| 669 | }, | |
| 670 | REPOSITORY_NOT_FOUND, | |
| 671 | ); | |
| 672 | const decoded = decodeRawRepository(response.body); | |
| 673 | if (Result.isFailure(decoded)) { | |
| 674 | return yield* failure("GitHub returned an invalid repository.", decoded.failure); | |
| 675 | } | |
| 676 | return decoded.success; | |
| 677 | }); | |
| 678 | ||
| 679 | const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( | |
| 680 | host: string, | |
| 681 | ) { | |
| 682 | const response = yield* rest({ host, operation: "getViewer", path: "user" }); | |
| 683 | const decoded = decodeViewerLogin(response.body); | |
| 684 | if (Result.isFailure(decoded)) { | |
| 685 | return yield* failure("GitHub request failed.", decoded.failure); | |
| 686 | } | |
| 687 | return decoded.success.login; | |
| 688 | }); | |
| 689 | ||
| 690 | // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client | |
| 691 | // sees is fixed and the raw error stays in `cause`. | |
| 692 | const gitFailure = (cause: unknown) => | |
| 693 | failure("The pull request could not be checked out with git.", cause); | |
| 694 | ||
| 695 | const runGit = (cwd: string, operation: string, args: ReadonlyArray<string>) => | |
| 696 | git.execute({ | |
| 697 | operation: `GitHubSourceControlProvider.checkoutPullRequest.${operation}`, | |
| 698 | cwd, | |
| 699 | args, | |
| 700 | }); | |
| 701 | ||
| 702 | /** | |
| 703 | * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a | |
| 704 | * fork gets a remote of its own), checked out under the name gh would give it, and set to | |
| 705 | * track the head. A head branch that is gone is read from the base's `refs/pull/<n>/head`. | |
| 706 | * An existing branch fast-forwards, or with `force` is reset to the pull request. | |
| 707 | */ | |
| 708 | const checkoutPullRequest = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( | |
| 709 | function* (input: { | |
| 710 | readonly cwd: string; | |
| 711 | readonly reference: string; | |
| 712 | readonly force?: boolean; | |
| 713 | }) { | |
| 714 | const reference = parsePullRequestReference(input.reference); | |
| 715 | const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); | |
| 716 | const base = | |
| 717 | reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); | |
| 718 | const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); | |
| 719 | const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; | |
| 720 | const isCrossRepository = | |
| 721 | pullRequest.isCrossRepository ?? | |
| 722 | (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); | |
| 723 | const headOwner = | |
| 724 | pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; | |
| 725 | ||
| 726 | const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( | |
| 727 | Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), | |
| 728 | Effect.mapError(gitFailure), | |
| 729 | ); | |
| 730 | const remoteFor = (nameWithOwner: string) => | |
| 731 | parseFetchRemotes(remotes).find( | |
| 732 | (remote) => | |
| 733 | normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") === | |
| 734 | nameWithOwner.toLowerCase(), | |
| 735 | )?.name ?? null; | |
| 736 | const baseRemote = Effect.suspend(() => { | |
| 737 | const known = remoteFor(baseNameWithOwner); | |
| 738 | return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); | |
| 739 | }); | |
| 740 | // A fork's branch named like the base's default branch is checked out under the owner's | |
| 741 | // prefix. Without the default branch that collision cannot be ruled out, and the checkout | |
| 742 | // would reset the local default branch to the fork's commit, so it fails instead. | |
| 743 | const defaultBranch = isCrossRepository | |
| 744 | ? yield* readRepository(base).pipe( | |
| 745 | Effect.map((repository) => repository.default_branch ?? null), | |
| 746 | ) | |
| 747 | : null; | |
| 748 | const localBranch = pullRequestCheckoutBranchName({ | |
| 749 | headRefName: pullRequest.headRefName, | |
| 750 | headOwner, | |
| 751 | isCrossRepository, | |
| 752 | defaultBranch, | |
| 753 | }); | |
| 299 | 754 | |
| 300 | return github | |
| 301 | .listPullRequestsByHead({ | |
| 755 | /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ | |
| 756 | const headRemote = Effect.gen(function* () { | |
| 757 | if (!isCrossRepository) return yield* baseRemote; | |
| 758 | if (headNameWithOwner === null) return yield* failure("The fork is gone."); | |
| 759 | const known = remoteFor(headNameWithOwner); | |
| 760 | if (known !== null) return known; | |
| 761 | const [owner, name] = headNameWithOwner.split("/"); | |
| 762 | const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); | |
| 763 | const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); | |
| 764 | return yield* git.ensureRemote({ | |
| 302 | 765 | cwd: input.cwd, |
| 303 | headSelector: input.headSelector, | |
| 304 | state: input.state, | |
| 305 | limit: input.limit ?? 20, | |
| 306 | ...(input.context === undefined | |
| 307 | ? {} | |
| 308 | : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), | |
| 309 | }) | |
| 310 | .pipe( | |
| 311 | Effect.map((items) => | |
| 312 | items.map(({ updatedAt, ...summary }) => ({ | |
| 313 | ...toChangeRequest({ | |
| 314 | ...summary, | |
| 315 | ...(Option.isSome(updatedAt) | |
| 316 | ? { updatedAt: DateTime.formatIso(updatedAt.value) } | |
| 317 | : {}), | |
| 318 | }), | |
| 319 | updatedAt, | |
| 320 | })), | |
| 321 | ), | |
| 322 | Effect.mapError( | |
| 323 | (error) => | |
| 324 | new SourceControlProviderError({ | |
| 325 | provider: "github", | |
| 326 | operation: "listChangeRequests", | |
| 327 | cwd: input.cwd, | |
| 328 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 329 | input.headSelector, | |
| 330 | ), | |
| 331 | detail: error.message, | |
| 332 | cause: error, | |
| 333 | }), | |
| 334 | ), | |
| 335 | ); | |
| 336 | }; | |
| 766 | preferredName: headOwner ?? "fork", | |
| 767 | url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, | |
| 768 | }); | |
| 769 | }); | |
| 770 | ||
| 771 | const exists = (yield* git | |
| 772 | .listLocalBranchNames(input.cwd) | |
| 773 | .pipe(Effect.mapError(gitFailure))).includes(localBranch); | |
| 774 | ||
| 775 | // The commit to check out, fetched from the head branch where it still exists. | |
| 776 | const target = yield* Effect.gen(function* () { | |
| 777 | const remoteName = yield* headRemote; | |
| 778 | yield* git.fetchRemoteTrackingBranch({ | |
| 779 | cwd: input.cwd, | |
| 780 | remoteName, | |
| 781 | remoteBranch: pullRequest.headRefName, | |
| 782 | }); | |
| 783 | return { | |
| 784 | ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, | |
| 785 | upstream: { remoteName, remoteBranch: pullRequest.headRefName }, | |
| 786 | }; | |
| 787 | }).pipe( | |
| 788 | Effect.catch(() => | |
| 789 | Effect.gen(function* () { | |
| 790 | yield* runGit(input.cwd, "fetchPullRef", [ | |
| 791 | "fetch", | |
| 792 | "--quiet", | |
| 793 | "--no-tags", | |
| 794 | yield* baseRemote, | |
| 795 | `refs/pull/${pullRequest.number}/head`, | |
| 796 | ]); | |
| 797 | const { commitSha } = yield* git.resolveCommit({ | |
| 798 | cwd: input.cwd, | |
| 799 | revision: "FETCH_HEAD", | |
| 800 | }); | |
| 801 | return { ref: commitSha, upstream: null }; | |
| 802 | }), | |
| 803 | ), | |
| 804 | Effect.mapError(gitFailure), | |
| 805 | ); | |
| 806 | ||
| 807 | yield* Effect.gen(function* () { | |
| 808 | if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); | |
| 809 | yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); | |
| 810 | if (exists) { | |
| 811 | yield* runGit( | |
| 812 | input.cwd, | |
| 813 | "sync", | |
| 814 | input.force === true | |
| 815 | ? ["reset", "--hard", "--quiet", target.ref] | |
| 816 | : ["merge", "--ff-only", "--quiet", target.ref], | |
| 817 | ); | |
| 818 | } | |
| 819 | // Tracking is set once the branch is the pull request's, so a sync that fails leaves an | |
| 820 | // existing branch's upstream as it was. | |
| 821 | if (target.upstream !== null) { | |
| 822 | yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); | |
| 823 | } | |
| 824 | }).pipe(Effect.mapError(gitFailure)); | |
| 825 | }, | |
| 826 | ); | |
| 827 | ||
| 828 | /** Reports a failure against the operation and request that made it, with transport-safe context. */ | |
| 829 | const providerError = | |
| 830 | ( | |
| 831 | operation: string, | |
| 832 | cwd: string, | |
| 833 | context?: { readonly reference?: string; readonly repository?: string }, | |
| 834 | ) => | |
| 835 | (error: GitHubFailure) => | |
| 836 | new SourceControlProviderError({ | |
| 837 | provider: "github", | |
| 838 | operation, | |
| 839 | cwd, | |
| 840 | ...(context?.reference === undefined | |
| 841 | ? {} | |
| 842 | : { | |
| 843 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 844 | context.reference, | |
| 845 | ), | |
| 846 | }), | |
| 847 | ...(context?.repository === undefined | |
| 848 | ? {} | |
| 849 | : { | |
| 850 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 851 | context.repository, | |
| 852 | ), | |
| 853 | }), | |
| 854 | detail: error.detail, | |
| 855 | cause: error.cause, | |
| 856 | }); | |
| 337 | 857 | |
| 338 | 858 | const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* ( |
| 339 | 859 | input: { readonly cwd: string; readonly url: URL }, |
| ... | ... | @@ -385,121 +905,122 @@ export const make = Effect.gen(function* () { |
| 385 | 905 | if (!match) return undefined; |
| 386 | 906 | return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); |
| 387 | 907 | }, |
| 388 | listChangeRequests, | |
| 908 | listChangeRequests: (input) => | |
| 909 | // An open lookup is a user waiting on a status; the rest may be a background sweep. | |
| 910 | (input.state === "open" ? Effect.succeed(true) : GitHubApi.AllowGitHubReserve).pipe( | |
| 911 | Effect.flatMap((allowReserve) => | |
| 912 | listByHead({ | |
| 913 | cwd: input.cwd, | |
| 914 | headSelector: input.headSelector, | |
| 915 | state: input.state, | |
| 916 | limit: input.limit ?? (input.state === "open" ? 1 : 20), | |
| 917 | host: contextHost(input.context), | |
| 918 | allowReserve, | |
| 919 | }), | |
| 920 | ), | |
| 921 | Effect.map((records) => records.map(toChangeRequest)), | |
| 922 | Effect.mapError( | |
| 923 | providerError("listChangeRequests", input.cwd, { reference: input.headSelector }), | |
| 924 | ), | |
| 925 | ), | |
| 389 | 926 | getChangeRequest: (input) => |
| 390 | github | |
| 391 | .getPullRequest({ | |
| 392 | ...input, | |
| 393 | ...(input.context === undefined | |
| 394 | ? {} | |
| 395 | : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), | |
| 396 | }) | |
| 397 | .pipe( | |
| 398 | Effect.map(toChangeRequest), | |
| 399 | Effect.mapError( | |
| 400 | (error) => | |
| 401 | new SourceControlProviderError({ | |
| 402 | provider: "github", | |
| 403 | operation: "getChangeRequest", | |
| 404 | cwd: input.cwd, | |
| 405 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 406 | input.reference, | |
| 407 | ), | |
| 408 | detail: error.message, | |
| 409 | cause: error, | |
| 410 | }), | |
| 411 | ), | |
| 927 | readPullRequest({ ...input, host: contextHost(input.context) }).pipe( | |
| 928 | Effect.map(toChangeRequest), | |
| 929 | Effect.mapError( | |
| 930 | providerError("getChangeRequest", input.cwd, { reference: input.reference }), | |
| 412 | 931 | ), |
| 932 | ), | |
| 413 | 933 | createChangeRequest: (input) => |
| 414 | github | |
| 415 | .createPullRequest({ | |
| 416 | cwd: input.cwd, | |
| 417 | baseBranch: input.baseRefName, | |
| 418 | headSelector: input.headSelector, | |
| 419 | title: input.title, | |
| 420 | bodyFile: input.bodyFile, | |
| 421 | }) | |
| 422 | .pipe( | |
| 423 | Effect.mapError( | |
| 424 | (error) => | |
| 425 | new SourceControlProviderError({ | |
| 426 | provider: "github", | |
| 427 | operation: "createChangeRequest", | |
| 428 | cwd: input.cwd, | |
| 429 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 430 | input.headSelector, | |
| 431 | ), | |
| 432 | detail: error.message, | |
| 433 | cause: error, | |
| 434 | }), | |
| 435 | ), | |
| 934 | Effect.gen(function* () { | |
| 935 | const locator = yield* resolveRepository({ cwd: input.cwd }); | |
| 936 | const body = yield* fileSystem | |
| 937 | .readFileString(input.bodyFile) | |
| 938 | .pipe( | |
| 939 | Effect.mapError((cause) => | |
| 940 | failure("The pull request description could not be read.", cause), | |
| 941 | ), | |
| 942 | ); | |
| 943 | yield* rest({ | |
| 944 | host: locator.host, | |
| 945 | operation: "createPullRequest", | |
| 946 | method: "POST", | |
| 947 | path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, | |
| 948 | // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. | |
| 949 | // gh allows maintainer edits unless told otherwise; the API's default is not documented. | |
| 950 | body: { | |
| 951 | base: input.baseRefName, | |
| 952 | head: input.headSelector, | |
| 953 | title: input.title, | |
| 954 | body, | |
| 955 | maintainer_can_modify: true, | |
| 956 | }, | |
| 957 | }); | |
| 958 | }).pipe( | |
| 959 | Effect.mapError( | |
| 960 | providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), | |
| 436 | 961 | ), |
| 962 | ), | |
| 437 | 963 | getRepositoryCloneUrls: (input) => |
| 438 | github.getRepositoryCloneUrls(input).pipe( | |
| 964 | Effect.gen(function* () { | |
| 965 | const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( | |
| 966 | Effect.map((locator) => locator.host), | |
| 967 | Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), | |
| 968 | )).toLowerCase(); | |
| 969 | const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); | |
| 970 | if (locator === null) return yield* failure("Repositories are named owner/name."); | |
| 971 | return repositoryCloneUrls(yield* readRepository(locator)); | |
| 972 | }).pipe( | |
| 439 | 973 | Effect.mapError( |
| 440 | (error) => | |
| 441 | new SourceControlProviderError({ | |
| 442 | provider: "github", | |
| 443 | operation: "getRepositoryCloneUrls", | |
| 444 | cwd: input.cwd, | |
| 445 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 446 | input.repository, | |
| 447 | ), | |
| 448 | detail: error.message, | |
| 449 | cause: error, | |
| 450 | }), | |
| 974 | providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), | |
| 451 | 975 | ), |
| 452 | 976 | ), |
| 453 | 977 | createRepository: (input) => |
| 454 | github.createRepository(input).pipe( | |
| 978 | Effect.gen(function* () { | |
| 979 | const locator = parseGitHubRepositorySelector( | |
| 980 | input.repository, | |
| 981 | (environment.GH_HOST ?? "github.com").toLowerCase(), | |
| 982 | ); | |
| 983 | const viewer = locator === null ? null : yield* readViewerLogin(locator.host); | |
| 984 | const owner = locator?.owner ?? viewer; | |
| 985 | const name = locator?.name ?? input.repository.trim(); | |
| 986 | const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); | |
| 987 | const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); | |
| 988 | const response = yield* rest( | |
| 989 | { | |
| 990 | host, | |
| 991 | operation: "createRepository", | |
| 992 | method: "POST", | |
| 993 | path: | |
| 994 | isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, | |
| 995 | body: { name, private: input.visibility === "private" }, | |
| 996 | }, | |
| 997 | // GitHub answers 404 for an organization the account cannot create repositories in. | |
| 998 | `No organization named ${owner ?? "that"} that this account can create repositories in.`, | |
| 999 | ); | |
| 1000 | const decoded = decodeRawRepository(response.body); | |
| 1001 | if (Result.isFailure(decoded)) { | |
| 1002 | return yield* failure("GitHub returned an invalid repository.", decoded.failure); | |
| 1003 | } | |
| 1004 | return repositoryCloneUrls(decoded.success); | |
| 1005 | }).pipe( | |
| 455 | 1006 | Effect.mapError( |
| 456 | (error) => | |
| 457 | new SourceControlProviderError({ | |
| 458 | provider: "github", | |
| 459 | operation: "createRepository", | |
| 460 | cwd: input.cwd, | |
| 461 | repository: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 462 | input.repository, | |
| 463 | ), | |
| 464 | detail: error.message, | |
| 465 | cause: error, | |
| 466 | }), | |
| 1007 | providerError("createRepository", input.cwd, { repository: input.repository }), | |
| 467 | 1008 | ), |
| 468 | 1009 | ), |
| 469 | 1010 | getDefaultBranch: (input) => |
| 470 | github | |
| 471 | .getDefaultBranch({ | |
| 472 | ...input, | |
| 473 | ...(input.context === undefined | |
| 474 | ? {} | |
| 475 | : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), | |
| 476 | }) | |
| 477 | .pipe( | |
| 478 | Effect.mapError( | |
| 479 | (error) => | |
| 480 | new SourceControlProviderError({ | |
| 481 | provider: "github", | |
| 482 | operation: "getDefaultBranch", | |
| 483 | cwd: input.cwd, | |
| 484 | detail: error.message, | |
| 485 | cause: error, | |
| 486 | }), | |
| 487 | ), | |
| 488 | ), | |
| 1011 | Effect.gen(function* () { | |
| 1012 | const locator = yield* resolveRepository({ | |
| 1013 | cwd: input.cwd, | |
| 1014 | host: contextHost(input.context), | |
| 1015 | }); | |
| 1016 | const repository = yield* readRepository(locator); | |
| 1017 | const branch = repository.default_branch?.trim() ?? ""; | |
| 1018 | return branch.length > 0 ? branch : null; | |
| 1019 | }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), | |
| 489 | 1020 | checkoutChangeRequest: (input) => |
| 490 | github.checkoutPullRequest(input).pipe( | |
| 1021 | checkoutPullRequest(input).pipe( | |
| 491 | 1022 | Effect.mapError( |
| 492 | (error) => | |
| 493 | new SourceControlProviderError({ | |
| 494 | provider: "github", | |
| 495 | operation: "checkoutChangeRequest", | |
| 496 | cwd: input.cwd, | |
| 497 | reference: SourceControlProvider.transportSafeSourceControlErrorValue( | |
| 498 | input.reference, | |
| 499 | ), | |
| 500 | detail: error.message, | |
| 501 | cause: error, | |
| 502 | }), | |
| 1023 | providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }), | |
| 503 | 1024 | ), |
| 504 | 1025 | ), |
| 505 | 1026 | }); |
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+2-2| ... | ... | @@ -13,12 +13,12 @@ import { VcsProcessSpawnError } from "@t3tools/contracts"; |
| 13 | 13 | |
| 14 | 14 | import * as ServerSettings from "../serverSettings.ts"; |
| 15 | 15 | import * as ServerConfig from "../config.ts"; |
| 16 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 16 | 17 | import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 17 | 18 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 18 | 19 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 19 | 20 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 20 | 21 | import * as GitHubApi from "./GitHubApi.ts"; |
| 21 | import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; | |
| 22 | 22 | import * as GitLabCli from "./GitLabCli.ts"; |
| 23 | 23 | import * as ForgejoCli from "./ForgejoCli.ts"; |
| 24 | 24 | import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; |
| ... | ... | @@ -40,8 +40,8 @@ const layerSourceControlProviderRegistryTest = (input: { |
| 40 | 40 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 41 | 41 | Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), |
| 42 | 42 | ServerSettings.ServerSettingsService.layerTest(), |
| 43 | Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}), | |
| 44 | 43 | Layer.mock(GitHubApi.GitHubApi)({}), |
| 44 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | |
| 45 | 45 | Layer.mock(GitLabCli.GitLabCli)({}), |
| 46 | 46 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
| 47 | 47 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+2-3| ... | ... | @@ -14,8 +14,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; |
| 14 | 14 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 15 | 15 | import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; |
| 16 | 16 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 17 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | |
| 17 | 18 | import * as GitHubApi from "./GitHubApi.ts"; |
| 18 | import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; | |
| 19 | 19 | import * as GitLabCli from "./GitLabCli.ts"; |
| 20 | 20 | import * as ForgejoCli from "./ForgejoCli.ts"; |
| 21 | 21 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| ... | ... | @@ -42,7 +42,6 @@ function makeRegistry(input: { |
| 42 | 42 | readonly url: string; |
| 43 | 43 | }>; |
| 44 | 44 | readonly process?: Partial<VcsProcess.VcsProcess["Service"]>; |
| 45 | readonly github?: Partial<GitHubRepositoryApi.GitHubRepositoryApi["Service"]>; | |
| 46 | 45 | readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>; |
| 47 | 46 | readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>; |
| 48 | 47 | readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; |
| ... | ... | @@ -98,8 +97,8 @@ function makeRegistry(input: { |
| 98 | 97 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 99 | 98 | Layer.mock(BitbucketApi.BitbucketApi)({}), |
| 100 | 99 | ServerSettings.ServerSettingsService.layerTest(), |
| 101 | Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}), | |
| 102 | 100 | Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), |
| 101 | Layer.mock(GitVcsDriver.GitVcsDriver)({}), | |
| 103 | 102 | Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), |
| 104 | 103 | Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), |
| 105 | 104 | ServerConfig.layerTest(process.cwd(), { |
apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | gitHubApiHostForRemote, | |
| 5 | selectGitHubBaseRepository, | |
| 6 | } from "./gitHubRepositoryResolution.ts"; | |
| 7 | ||
| 8 | describe("selectGitHubBaseRepository", () => { | |
| 9 | const remotes = (...entries: ReadonlyArray<readonly [string, string]>) => | |
| 10 | entries | |
| 11 | .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) | |
| 12 | .join("\n"); | |
| 13 | const select = (input: { remotes: string; resolved?: string }) => | |
| 14 | selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); | |
| 15 | ||
| 16 | it("picks the repository gh reads without a prompt", () => { | |
| 17 | assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { | |
| 18 | owner: "me", | |
| 19 | name: "web", | |
| 20 | }); | |
| 21 | const fork = remotes( | |
| 22 | ["origin", "git@github.com:me/web.git"], | |
| 23 | ["upstream", "https://github.com/Acme/Web.git"], | |
| 24 | ); | |
| 25 | assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); | |
| 26 | assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { | |
| 27 | owner: "me", | |
| 28 | name: "web", | |
| 29 | }); | |
| 30 | assert.deepStrictEqual( | |
| 31 | select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), | |
| 32 | { owner: "acme", name: "other" }, | |
| 33 | ); | |
| 34 | // gh ranks remote names in any case. | |
| 35 | assert.deepStrictEqual( | |
| 36 | select({ | |
| 37 | remotes: remotes( | |
| 38 | ["origin", "git@github.com:me/web.git"], | |
| 39 | ["Upstream", "git@github.com:acme/web.git"], | |
| 40 | ), | |
| 41 | }), | |
| 42 | { owner: "acme", name: "web" }, | |
| 43 | ); | |
| 44 | }); | |
| 45 | ||
| 46 | it("leaves gh to choose when it might weigh the remotes differently", () => { | |
| 47 | for (const input of [ | |
| 48 | { remotes: "" }, | |
| 49 | { | |
| 50 | remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), | |
| 51 | }, | |
| 52 | { | |
| 53 | remotes: remotes( | |
| 54 | ["origin", "git@github.com:me/web.git"], | |
| 55 | ["mirror", "git@gitlab.com:me/web.git"], | |
| 56 | ), | |
| 57 | }, | |
| 58 | { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, | |
| 59 | { | |
| 60 | remotes: remotes( | |
| 61 | ["origin", "git@github.com:me/web.git"], | |
| 62 | ["Origin", "git@github.com:acme/web.git"], | |
| 63 | ), | |
| 64 | }, | |
| 65 | { | |
| 66 | remotes: remotes( | |
| 67 | ["origin", "git@github.com:me/web.git"], | |
| 68 | ["upstream", "git@github.com:acme/web.git"], | |
| 69 | ), | |
| 70 | resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", | |
| 71 | }, | |
| 72 | ]) { | |
| 73 | assert.strictEqual(select(input), null); | |
| 74 | } | |
| 75 | }); | |
| 76 | }); | |
| 77 | ||
| 78 | describe("gitHubApiHostForRemote", () => { | |
| 79 | it("reads an SSH alias through github.com and a real host as itself", () => { | |
| 80 | assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); | |
| 81 | assert.strictEqual( | |
| 82 | gitHubApiHostForRemote("git@github.example.com:a/b.git"), | |
| 83 | "github.example.com", | |
| 84 | ); | |
| 85 | assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); | |
| 86 | }); | |
| 87 | }); |
apps/server/src/sourceControl/gitHubRepositoryResolution.ts created+190| ... | ... | @@ -0,0 +1,190 @@ |
| 1 | import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; | |
| 2 | import { | |
| 3 | detectSourceControlProviderFromRemoteUrl, | |
| 4 | isSshRemoteUrl, | |
| 5 | } from "@t3tools/shared/sourceControl"; | |
| 6 | ||
| 7 | /** A repository on a GitHub host: the API it is read through, and its owner and name. */ | |
| 8 | export interface GitHubRepositoryLocator { | |
| 9 | readonly host: string; | |
| 10 | readonly owner: string; | |
| 11 | readonly name: string; | |
| 12 | } | |
| 13 | ||
| 14 | /** The fetch remotes in `git remote -v` output, by name. */ | |
| 15 | export function parseFetchRemotes( | |
| 16 | remotes: string, | |
| 17 | ): ReadonlyArray<{ readonly name: string; readonly url: string }> { | |
| 18 | return remotes | |
| 19 | .split("\n") | |
| 20 | .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) | |
| 21 | .filter((match): match is RegExpExecArray => match !== null) | |
| 22 | .map((match) => ({ name: match[1]!, url: match[2]! })); | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a | |
| 27 | * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin | |
| 28 | * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of | |
| 29 | * `git config --get-regexp '^remote\..*\.gh-resolved$'`. | |
| 30 | * | |
| 31 | * Null whenever gh might weigh the remotes differently: a remote on another host or under an | |
| 32 | * SSH alias, more than one mark, or several remotes with none of those names. Callers then | |
| 33 | * fall back to the provider's remote, then to the best-ranked remote on the host. | |
| 34 | */ | |
| 35 | export function selectGitHubBaseRepository(input: { | |
| 36 | readonly remotes: string; | |
| 37 | readonly resolved: string; | |
| 38 | readonly host: string; | |
| 39 | }): { readonly owner: string; readonly name: string } | null { | |
| 40 | const host = input.host.toLowerCase(); | |
| 41 | const repositories = new Map<string, { readonly owner: string; readonly name: string }>(); | |
| 42 | for (const remote of parseFetchRemotes(input.remotes)) { | |
| 43 | const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); | |
| 44 | if (remoteHost !== host || !owner || !name || rest.length > 0) return null; | |
| 45 | repositories.set(remote.name, { owner, name }); | |
| 46 | } | |
| 47 | const marks = input.resolved | |
| 48 | .split("\n") | |
| 49 | .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) | |
| 50 | .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); | |
| 51 | if (marks.length > 1) return null; | |
| 52 | const [mark] = marks; | |
| 53 | if (mark) { | |
| 54 | if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; | |
| 55 | const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); | |
| 56 | return owner && name && rest.length === 0 ? { owner, name } : null; | |
| 57 | } | |
| 58 | // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the | |
| 59 | // top place has no defined winner. | |
| 60 | const score = (remoteName: string) => | |
| 61 | ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; | |
| 62 | const ranked = [...repositories.entries()].toSorted( | |
| 63 | ([left], [right]) => score(right) - score(left), | |
| 64 | ); | |
| 65 | const [top, next] = ranked; | |
| 66 | return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) | |
| 67 | ? top[1] | |
| 68 | : null; | |
| 69 | } | |
| 70 | ||
| 71 | /** | |
| 72 | * The repository `gh` would act on in a checkout, from its remotes: the remote `gh` would pick, | |
| 73 | * else the best-ranked remote on the host. `host` is the API host it settled on, which a caller | |
| 74 | * names when no repository was found there. GH_REPO is the caller's to check first. | |
| 75 | */ | |
| 76 | export function resolveGitHubRepository(input: { | |
| 77 | readonly remotes: string; | |
| 78 | readonly resolved: string; | |
| 79 | /** The checkout's GitHub API host, when the caller already knows it. */ | |
| 80 | readonly hostHint: string | undefined; | |
| 81 | readonly defaultHost: string; | |
| 82 | }): { readonly host: string; readonly locator: GitHubRepositoryLocator | null } { | |
| 83 | const fetchRemotes = parseFetchRemotes(input.remotes).map((remote) => ({ | |
| 84 | ...remote, | |
| 85 | host: gitHubApiHostForRemote(remote.url), | |
| 86 | })); | |
| 87 | const host = | |
| 88 | (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint)) ?? | |
| 89 | fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? | |
| 90 | fetchRemotes.find((remote) => remote.host !== null)?.host ?? | |
| 91 | input.defaultHost; | |
| 92 | const selected = selectGitHubBaseRepository({ | |
| 93 | remotes: input.remotes, | |
| 94 | resolved: input.resolved, | |
| 95 | host, | |
| 96 | }); | |
| 97 | if (selected !== null) return { host, locator: { host, ...selected } }; | |
| 98 | // gh's own order without its prompt: upstream, github, origin, then the first remote, among | |
| 99 | // the ones on this host (an SSH alias counts as its API host). | |
| 100 | const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); | |
| 101 | const candidates = fetchRemotes | |
| 102 | .filter((remote) => remote.host === host) | |
| 103 | .toSorted((left, right) => { | |
| 104 | const l = rank(left.name); | |
| 105 | const r = rank(right.name); | |
| 106 | return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); | |
| 107 | }); | |
| 108 | for (const remote of candidates) { | |
| 109 | const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); | |
| 110 | if (owner && name && rest.length === 0) return { host, locator: { host, owner, name } }; | |
| 111 | } | |
| 112 | return { host, locator: null }; | |
| 113 | } | |
| 114 | ||
| 115 | /** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ | |
| 116 | export function parseGitHubRepositorySelector( | |
| 117 | selector: string, | |
| 118 | defaultHost: string, | |
| 119 | ): GitHubRepositoryLocator | null { | |
| 120 | const trimmed = selector.trim().replace(/\.git$/i, ""); | |
| 121 | if (/^https?:\/\//i.test(trimmed)) { | |
| 122 | try { | |
| 123 | const url = new URL(trimmed); | |
| 124 | const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); | |
| 125 | return owner && name && rest.length === 0 | |
| 126 | ? { host: url.host.toLowerCase(), owner, name } | |
| 127 | : null; | |
| 128 | } catch { | |
| 129 | return null; | |
| 130 | } | |
| 131 | } | |
| 132 | const parts = trimmed.split("/").filter(Boolean); | |
| 133 | if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; | |
| 134 | if (parts.length === 3) | |
| 135 | return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; | |
| 136 | return null; | |
| 137 | } | |
| 138 | ||
| 139 | /** | |
| 140 | * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request | |
| 141 | * URL, or a branch name. | |
| 142 | */ | |
| 143 | export function parsePullRequestReference( | |
| 144 | reference: string, | |
| 145 | ): | |
| 146 | | { readonly kind: "number"; readonly number: number } | |
| 147 | | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } | |
| 148 | | { readonly kind: "branch"; readonly headSelector: string } { | |
| 149 | const trimmed = reference.trim(); | |
| 150 | const numbered = /^#?([1-9]\d*)$/.exec(trimmed); | |
| 151 | if (numbered) return { kind: "number", number: Number(numbered[1]) }; | |
| 152 | if (/^https?:\/\//i.test(trimmed)) { | |
| 153 | try { | |
| 154 | const url = new URL(trimmed); | |
| 155 | const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); | |
| 156 | if (match) { | |
| 157 | return { | |
| 158 | kind: "url", | |
| 159 | locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, | |
| 160 | number: Number(match[3]), | |
| 161 | }; | |
| 162 | } | |
| 163 | } catch { | |
| 164 | // Not a URL after all; read it as a branch. | |
| 165 | } | |
| 166 | } | |
| 167 | return { kind: "branch", headSelector: trimmed }; | |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH | |
| 172 | * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through | |
| 173 | * `github.com`, which is what such an alias almost always stands for (issue #6198). | |
| 174 | */ | |
| 175 | export function gitHubApiHostForRemote(remoteUrl: string): string | null { | |
| 176 | const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); | |
| 177 | if (provider === null) return null; | |
| 178 | const host = new URL(provider.baseUrl).host.toLowerCase(); | |
| 179 | // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. | |
| 180 | if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { | |
| 181 | return host.includes("github") ? "github.com" : null; | |
| 182 | } | |
| 183 | return provider.kind === "github" ? host : null; | |
| 184 | } | |
| 185 | ||
| 186 | /** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ | |
| 187 | function apiHostForHint(host: string): string { | |
| 188 | const normalized = host.toLowerCase(); | |
| 189 | return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; | |
| 190 | } |
apps/server/src/ws.ts+2-2| ... | ... | @@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. |
| 229 | 229 | import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; |
| 230 | 230 | import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; |
| 231 | 231 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 232 | import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; | |
| 232 | import * as GitHubApi from "./sourceControl/GitHubApi.ts"; | |
| 233 | 233 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 234 | 234 | import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; |
| 235 | 235 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| ... | ... | @@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap( |
| 3190 | 3190 | Layer.mergeAll( |
| 3191 | 3191 | AzureDevOpsCli.layer, |
| 3192 | 3192 | BitbucketApi.layer, |
| 3193 | GitHubRepositoryApi.layer, | |
| 3193 | GitHubApi.layerWithDependencies, | |
| 3194 | 3194 | GitLabCli.layer, |
| 3195 | 3195 | ForgejoCli.layer, |
| 3196 | 3196 | ), |