authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 17:26:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 17:26:25-07:00
log42d92ec59775c187af69071c81b2b91180810bdd
tree4932688041805162f1918e6842c912574e7a0168
parenta21ac5fb153e3bf418ccd2a4abef2787c241813c
signature Signed by PGP key B5690EEEBB952194

refactor(server): GitHub source control reads GitHubApi directly (#16982)

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 {
2323} from "../src/textGeneration/ThreadTitleContext.ts";
2424import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
2525import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
26import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts";
26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
2727import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
2828import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";
2929import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";
......@@ -152,7 +152,7 @@ await Effect.runPromise(
152152 SourceControlProviderRegistry.layer.pipe(
153153 Layer.provide(
154154 Layer.mergeAll(
155 GitHubRepositoryApi.layer,
155 GitHubApi.layerWithDependencies,
156156 GitLabCli.layer,
157157 ForgejoCli.layer,
158158 AzureDevOpsCli.layer,
apps/server/src/git/GitManager.test.ts+130-150
......@@ -24,6 +24,7 @@ import { TestClock } from "effect/testing";
2424import { ChildProcessSpawner } from "effect/process";
2525import { expect } from "vite-plus/test";
2626import type {
27 ChangeRequest,
2728 GitActionProgressEvent,
2829 GitManagerServiceError,
2930 GitPreparePullRequestThreadInput,
......@@ -37,12 +38,11 @@ import {
3738 ProjectId,
3839 ProviderDriverKind,
3940 ProviderInstanceId,
41 SourceControlProviderError as SourceControlProviderFailure,
4042 TextGenerationError,
4143 ThreadId,
4244} from "@t3tools/contracts";
4345import * as DateTime from "effect/DateTime";
44import * as GitHubApi from "../sourceControl/GitHubApi.ts";
45import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts";
4646import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";
4747import * as GitLabCli from "../sourceControl/GitLabCli.ts";
4848import * as TextGeneration from "../textGeneration/TextGeneration.ts";
......@@ -53,7 +53,6 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
5353import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
5454import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
5555import * as GitWorkflowService from "./GitWorkflowService.ts";
56import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
5756import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
5857import {
5958 ForgejoPullRequestSchema,
......@@ -92,7 +91,10 @@ interface FakeGhScenario {
9291 headRepositoryOwnerLogin?: string | null;
9392 };
9493 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;
9698 /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */
9799 failAfterCalls?: number;
98100}
......@@ -111,9 +113,12 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"];
111113
112114type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
113115
114function normalizeFakePullRequestSummary(
115 raw: unknown,
116): GitHubRepositoryApi.GitHubPullRequestSummary | null {
116type FakePullRequestSummary = Omit<ChangeRequest, "provider" | "state" | "updatedAt"> & {
117 readonly state?: ChangeRequest["state"];
118 readonly updatedAt?: string;
119};
120
121function normalizeFakePullRequestSummary(raw: unknown): FakePullRequestSummary | null {
117122 if (!raw || typeof raw !== "object") {
118123 return null;
119124 }
......@@ -383,8 +388,12 @@ function createTextGeneration(
383388 };
384389}
385390
386function 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 */
395function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): {
396 service: SourceControlProvider["Service"];
388397 ghCalls: string[];
389398} {
390399 const prListQueue = [...(scenario.prListSequence ?? [])];
......@@ -396,16 +405,20 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
396405 );
397406 const ghCalls: string[] = [];
398407
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. */
400412 const execute = (input: {
401413 readonly cwd: string;
402414 readonly args: ReadonlyArray<string>;
403 }): Effect.Effect<VcsProcess.VcsProcessOutput, GitHubRepositoryApi.GitHubRepositoryApiError> => {
415 readonly operation: string;
416 }): Effect.Effect<VcsProcess.VcsProcessOutput, SourceControlProviderFailure> => {
404417 const args = [...input.args];
405418 ghCalls.push(args.join(" "));
406419
407420 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));
409422 }
410423
411424 if (args[0] === "pr" && args[1] === "list") {
......@@ -487,13 +500,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
487500 }
488501 return fakeGhOutput("");
489502 },
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),
497504 });
498505 }
499506
......@@ -502,12 +509,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
502509 if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) {
503510 const cloneUrls = scenario.repositoryCloneUrls?.[repository];
504511 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}`));
511513 }
512514 return Effect.succeed(
513515 fakeGhOutput(
......@@ -522,73 +524,86 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
522524 return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`));
523525 }
524526
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(" ")}`));
531528 };
532529
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
533543 return {
534544 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),
581570 ),
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) =>
585599 execute({
600 operation: "createChangeRequest",
586601 cwd: input.cwd,
587602 args: [
588603 "pr",
589604 "create",
590605 "--base",
591 input.baseBranch,
606 input.baseRefName,
592607 "--head",
593608 input.headSelector,
594609 "--title",
......@@ -599,6 +614,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
599614 }).pipe(Effect.asVoid),
600615 getDefaultBranch: (input) =>
601616 execute({
617 operation: "getDefaultBranch",
602618 cwd: input.cwd,
603619 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
604620 }).pipe(
......@@ -607,8 +623,9 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
607623 return value.length > 0 ? value : null;
608624 }),
609625 ),
610 getPullRequest: (input) =>
626 getChangeRequest: (input) =>
611627 execute({
628 operation: "getChangeRequest",
612629 cwd: input.cwd,
613630 args: [
614631 "pr",
......@@ -618,24 +635,21 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
618635 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
619636 ],
620637 }).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),
623640 ),
624641 ),
625642 getRepositoryCloneUrls: (input) =>
626643 execute({
644 operation: "getRepositoryCloneUrls",
627645 cwd: input.cwd,
628646 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
629647 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),
630648 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) =>
638651 execute({
652 operation: "checkoutChangeRequest",
639653 cwd: input.cwd,
640654 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
641655 }).pipe(Effect.asVoid),
......@@ -693,7 +707,7 @@ function makeManager(input?: {
693707 ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2
694708 >;
695709}) {
696 const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh(
710 const { service: fakeGitHubProvider, ghCalls } = createGitHubProviderWithFakeGh(
697711 input?.ghScenario,
698712 );
699713 const textGeneration = createTextGeneration(input?.textGeneration);
......@@ -729,10 +743,7 @@ function makeManager(input?: {
729743 );
730744 const layerSourceControlRegistry = Layer.effect(
731745 SourceControlProviderRegistry.SourceControlProviderRegistry,
732 (input?.sourceControlProvider === undefined
733 ? GitHubSourceControlProvider.make
734 : Effect.succeed(input.sourceControlProvider)
735 ).pipe(
746 Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe(
736747 Effect.map((provider) =>
737748 SourceControlProviderRegistry.SourceControlProviderRegistry.of({
738749 resolveLink: (input) => provider.resolveLink?.(input),
......@@ -742,12 +753,6 @@ function makeManager(input?: {
742753 discover: Effect.succeed([]),
743754 }),
744755 ),
745 Effect.provide(
746 Layer.merge(
747 Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi),
748 Layer.mock(GitHubApi.GitHubApi)({}),
749 ),
750 ),
751756 ),
752757 );
753758
......@@ -1300,10 +1305,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
13001305 yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]);
13011306 const { manager, ghCalls } = yield* makeManager({
13021307 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`.",
13071310 },
13081311 });
13091312 yield* manager.remoteStatus({ cwd: repoDir });
......@@ -1782,10 +1785,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
17821785
17831786 const { manager, ghCalls } = yield* makeManager({
17841787 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`.",
17891790 },
17901791 });
17911792
......@@ -2642,10 +2643,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
26422643 },
26432644 ]),
26442645 },
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`.",
26492648 failAfterCalls: 1,
26502649 },
26512650 });
......@@ -2718,10 +2717,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
27182717
27192718 const { manager } = yield* makeManager({
27202719 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`.",
27252722 },
27262723 });
27272724
......@@ -2743,10 +2740,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
27432740 const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value";
27442741 const { manager } = yield* makeManager({
27452742 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),
27502745 },
27512746 });
27522747 const logs: Array<{ message: string; annotations: Record<string, unknown> }> = [];
......@@ -2799,10 +2794,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
27992794 const { manager } = yield* makeManager({
28002795 ghScenario: {
28012796 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`.",
28062799 failAfterCalls: 1,
28072800 },
28082801 });
......@@ -2841,10 +2834,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
28412834 const { manager } = yield* makeManager({
28422835 ghScenario: {
28432836 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`.",
28482839 failAfterCalls: 1,
28492840 },
28502841 });
......@@ -2888,10 +2879,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
28882879 const { manager } = yield* makeManager({
28892880 ghScenario: {
28902881 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`.",
28952884 failAfterCalls: 1,
28962885 },
28972886 });
......@@ -2926,10 +2915,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
29262915 const { manager } = yield* makeManager({
29272916 ghScenario: {
29282917 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`.",
29332920 failAfterCalls: 1,
29342921 },
29352922 });
......@@ -2967,10 +2954,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
29672954 const { manager } = yield* makeManager({
29682955 ghScenario: {
29692956 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`.",
29742959 failAfterCalls: 1,
29752960 },
29762961 });
......@@ -4611,10 +4596,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
46114596
46124597 const { manager } = yield* makeManager({
46134598 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`.",
46184601 },
46194602 });
46204603
......@@ -4640,10 +4623,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
46404623
46414624 const { manager } = yield* makeManager({
46424625 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.",
46474627 },
46484628 });
46494629
apps/server/src/orchestration-v2/ThreadPullRequestService.ts+1-1
......@@ -345,7 +345,7 @@ export const make = Effect.gen(function* () {
345345 }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))),
346346 ),
347347 ),
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
349349 // GraphQL document, instead of one `gh pr list` per branch.
350350 { concurrency: 32, discard: true },
351351 );
apps/server/src/pullRequest/GitHubPullRequestApi.test.ts+1-1
......@@ -12,7 +12,7 @@ import * as Redacted from "effect/Redacted";
1212
1313import { HttpClient, HttpClientResponse } from "effect/http";
1414
15import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts";
15import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts";
1616import * as GitHubApi from "../sourceControl/GitHubApi.ts";
1717import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
1818import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
apps/server/src/pullRequest/GitHubPullRequestApi.ts+1-2
......@@ -42,7 +42,6 @@ import {
4242 type PullRequestPreview,
4343} from "@t3tools/contracts";
4444
45import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts";
4645import * as GitHubApi from "../sourceControl/GitHubApi.ts";
4746import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts";
4847import * as VcsProcess from "../vcs/VcsProcess.ts";
......@@ -1524,7 +1523,7 @@ export const make = Effect.gen(function* () {
15241523
15251524 const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => {
15261525 const { owner, name } = parseRepositorySelector(input.repository);
1527 return AllowGitHubReserve.pipe(
1526 return GitHubApi.AllowGitHubReserve.pipe(
15281527 Effect.flatMap((allowReserve) =>
15291528 graphqlRead({
15301529 allowReserve,
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+1-2
......@@ -7,7 +7,6 @@ import type { PullRequestReaction } from "@t3tools/contracts";
77
88import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
99import * as GitHubApi from "../sourceControl/GitHubApi.ts";
10import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts";
1110import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
1211import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts";
1312import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
......@@ -726,7 +725,7 @@ describe("getViewerPermissions", () => {
726725 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
727726 revalidateChecks: (_input, read) => read,
728727 getPullRequestDetail: () =>
729 GitHubRepositoryApi.AllowGitHubReserve.pipe(
728 GitHubApi.AllowGitHubReserve.pipe(
730729 Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))),
731730 Effect.flatMap(() => detail),
732731 ),
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+2-2
......@@ -7,7 +7,7 @@ import type {
77 PullRequestViewerPermissions,
88} from "@t3tools/contracts";
99
10import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts";
10import * as GitHubApi from "../sourceControl/GitHubApi.ts";
1111import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
1212import {
1313 PullRequestProviderError,
......@@ -526,7 +526,7 @@ export const make = Effect.gen(function* () {
526526 // comparison, so one read usually answers what used to take three. When that heavier read
527527 // fails, the light access read still answers, withholding only update-branch.
528528 return cli.getPullRequestDetail(input).pipe(
529 Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true),
529 Effect.provideService(GitHubApi.AllowGitHubReserve, true),
530530 Effect.map((pullRequest) =>
531531 gitHubViewerPermissions({
532532 ...pullRequest.viewerAccess,
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+2-9
......@@ -6,9 +6,6 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";
66import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
77import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
88import * as GitHubApi from "../sourceControl/GitHubApi.ts";
9import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
10import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
11import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
129import * as GitLabCli from "../sourceControl/GitLabCli.ts";
1310import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
1411import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
......@@ -63,12 +60,8 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
6360 Layer.provide(
6461 GitHubPullRequestApi.layer.pipe(
6562 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,
7265 ),
7366 ),
7467 ),
apps/server/src/pullRequest/PullRequestService.ts+1-1
......@@ -77,7 +77,7 @@ import {
7777import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
7878import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
7979
80import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts";
80import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts";
8181import * as ProjectService from "../project/ProjectService.ts";
8282import * as ServerSettings from "../serverSettings.ts";
8383import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
apps/server/src/server.ts+3-3
......@@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
5050import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
5151import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
5252import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
53import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts";
53import * as GitHubApi from "./sourceControl/GitHubApi.ts";
5454import * as GitLabCli from "./sourceControl/GitLabCli.ts";
5555import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
5656import * as TextGeneration from "./textGeneration/TextGeneration.ts";
......@@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p
270270 Layer.mergeAll(
271271 AzureDevOpsCli.layer,
272272 BitbucketApi.layer,
273 GitHubRepositoryApi.layer,
273 GitHubApi.layerWithDependencies,
274274 GitLabCli.layer,
275275 ForgejoCli.layer,
276276 ),
......@@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
565565 Layer.provideMerge(layerServerSettings),
566566 // The asset route uses the registry's GitHub credential for private PR media.
567567 Layer.provideMerge(layerSourceControlProviderRegistry),
568 Layer.provideMerge(GitHubRepositoryApi.layer),
568 Layer.provideMerge(GitHubApi.layerWithDependencies),
569569 Layer.provideMerge(layerGit),
570570 Layer.provideMerge(layerVcs),
571571 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),
apps/server/src/sourceControl/GitHubApi.ts+10
......@@ -598,3 +598,13 @@ export const make = Effect.gen(function* () {
598598});
599599
600600export 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 */
606export 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 =
8181 | GitHubHostDisabledError
8282 | GitHubCliFailedError;
8383
84export const isGitHubCredentialUnavailableError = Schema.is(
85 Schema.Union([GitHubCliMissingError, GitHubNotSignedInError, GitHubHostDisabledError]),
86);
87
8884/**
8985 * Where GitHub tokens come from. Callers ask per host and never see how the token was found,
9086 * 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 @@
1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as TestClock from "effect/testing/TestClock";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9import { ChildProcessSpawner } from "effect/process";
10
11import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
12import * as VcsProcess from "../vcs/VcsProcess.ts";
13import * as GitHubApi from "./GitHubApi.ts";
14import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
15import * as GitHubCredentials from "./GitHubCredentials.ts";
16
17const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
18
19const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({
20 exitCode: ChildProcessSpawner.ExitCode(exitCode),
21 stdout,
22 stderr: "",
23 stdoutTruncated: false,
24 stderrTruncated: false,
25});
26
27const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) =>
28 entries
29 .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
30 .join("\n");
31
32const 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
40const 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 */
57function 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
111describe("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
181describe("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
257describe("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
423describe("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
465describe("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
540describe("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 @@
1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Request from "effect/Request";
9import * as RequestResolver from "effect/RequestResolver";
10import * as Result from "effect/Result";
11import * as Schema from "effect/Schema";
12
13import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
14import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
15import { decodeJsonResult } from "@t3tools/shared/schemaJson";
16import {
17 detectSourceControlProviderFromRemoteUrl,
18 isSshRemoteUrl,
19} from "@t3tools/shared/sourceControl";
20
21import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
22import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
23
24import * as VcsProcess from "../vcs/VcsProcess.ts";
25import * as GitHubApi from "./GitHubApi.ts";
26import * as GitHubCredentials from "./GitHubCredentials.ts";
27import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
28import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
29import {
30 decodeGitHubPullRequestEntries,
31 type NormalizedGitHubPullRequestRecord,
32} from "./gitHubPullRequests.ts";
33
34export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve;
35
36const gitHubFailureFields = {
37 cwd: Schema.String,
38 cause: Schema.Defect(),
39} as const;
40
41export 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
50export 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
62export 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
71export 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
80export 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
94const gitHubDecodeFields = {
95 cwd: Schema.String,
96 cause: Schema.Defect(),
97} as const;
98
99export 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
108export 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
117export 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
126export 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
135export const GitHubRepositoryApiError = Schema.Union([
136 GitHubRepositoryUnavailableError,
137 GitHubRepositoryAuthenticationError,
138 GitHubRepositoryRateLimitError,
139 GitHubPullRequestNotFoundError,
140 GitHubRepositoryCommandError,
141 GitHubPullRequestListDecodeError,
142 GitHubChangeRequestListDecodeError,
143 GitHubPullRequestDecodeError,
144 GitHubRepositoryDecodeError,
145]);
146export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type;
147
148export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError);
149
150/** Maps a GitHub API failure onto the errors callers of this service already handle. */
151function 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
181export 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
198function 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
206export interface GitHubRepositoryCloneUrls {
207 readonly nameWithOwner: string;
208 readonly url: string;
209 readonly sshUrl: string;
210}
211
212export 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 */
284export 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
322const 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});
328const decodeRawRepository = decodeJsonResult(RawRepositorySchema);
329
330function 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
336const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString }));
337
338type PullRequestListState = "open" | "closed" | "merged" | "all";
339
340/** The pull request fields every read selects, in GraphQL. */
341const PULL_REQUEST_NODE_SELECTION =
342 "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
343const 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 */
353const 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 */
360const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
361const 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 */
367const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25;
368/** A full document is 5,000 rows of well under 2 KB each. */
369const 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 */
375const OWNER_HEAD_SCAN_LIMIT = 100;
376
377class 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. */
393function 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
415const 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
421const 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
434const 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. */
443export 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. */
450function 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 */
477function 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 */
509export 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. */
521function 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. */
527export 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. */
544export 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
1081export 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 @@
1import { assert, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";
1import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
33import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as FileSystem from "effect/FileSystem";
46import * as Layer from "effect/Layer";
57import * as Option from "effect/Option";
68import * as Schema from "effect/Schema";
79import { ChildProcessSpawner } from "effect/process";
10import * as TestClock from "effect/testing/TestClock";
811
12import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
913import * as VcsProcess from "../vcs/VcsProcess.ts";
1014import * as GitHubApi from "./GitHubApi.ts";
11import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
15import * as GitHubCredentials from "./GitHubCredentials.ts";
1216import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
1317import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1418
19const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
20
1521const processResult = (
1622 stdout: string,
1723 options?: {
......@@ -26,191 +32,611 @@ const processResult = (
2632 stderrTruncated: false,
2733});
2834
29function 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}
35const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({
36 exitCode: ChildProcessSpawner.ExitCode(exitCode),
37 stdout,
38 stderr: "",
39 stdoutTruncated: false,
40 stderrTruncated: false,
41});
4242
43const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
43const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) =>
44 entries
45 .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
46 .join("\n");
4447
45const restResponse = (body: string): GitHubApi.GitHubRestResponse => ({
46 status: 200,
48const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({
49 status,
4750 headers: {},
48 body,
51 body: body === undefined ? "" : encodeJson(body),
4952 truncated: false,
5053 invalidUtf8: false,
5154});
5255
53it.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 }),
56const 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 */
73function 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. */
127const makeProvider = (api: Partial<GitHubApi.GitHubApi["Service"]>) =>
128 GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer));
129
130const 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
136describe("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 },
68156 });
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 });
69163
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 },
73180 });
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 });
74189
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 },
89199 });
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 });
92206
93it.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: {},
98211 });
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
221describe("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 },
101242 });
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 });
102280
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 });
109318
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 ),
117338 },
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
387describe("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({
121407 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 } } })),
124422 },
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});
130431
131it.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 ]);
432describe("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 }),
152446 },
153447 });
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 });
154471
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 }),
161488 },
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
507describe("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,
165514 });
166515
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 },
173525 });
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 });
184543
185it.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 ),
194561 },
195562 });
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 });
196570
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 },
203583 });
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 });
204605
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 },
211616 });
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});
214640
215641it("accepts active authenticated GitHub accounts when another account fails", () => {
216642 const auth = GitHubSourceControlProvider.discovery.parseAuth(
......@@ -409,20 +835,15 @@ it.effect.each(["pull", "issues"])(
409835 "resolves %s subjects on the linked host without using the checkout",
410836 (kind) =>
411837 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 );
424845 },
425 );
846 });
426847 const lookup = provider.resolveLink?.({
427848 cwd: "/unrelated",
428849 url: new URL(`https://github.com/owner/repo/${kind}/42`),
......@@ -451,15 +872,12 @@ it.effect.each(["read", "decode"] as const)(
451872 operation: "resolveLink",
452873 status: 500,
453874 });
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 });
463881 const lookup = provider.resolveLink?.({
464882 cwd: "/repo",
465883 url: new URL("https://github.com/owner/repo/issues/42"),
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+716-195
......@@ -1,21 +1,42 @@
1import * as Schema from "effect/Schema";
2import * as DateTime from "effect/DateTime";
1import * as Context from "effect/Context";
2import * as Data from "effect/Data";
33import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
46import * as Option from "effect/Option";
7import * as Request from "effect/Request";
8import * as RequestResolver from "effect/RequestResolver";
59import * as Result from "effect/Result";
10import * as Schema from "effect/Schema";
611import {
712 DEFAULT_SERVER_SETTINGS,
813 SourceControlProviderError,
14 TrimmedNonEmptyString,
915 type ChangeRequest,
1016 type GitHubSettings,
1117 type SourceControlProviderDiscoveryItem,
18 type SourceControlRepositoryCloneUrls,
1219} from "@t3tools/contracts";
13
20import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
1421import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
22import { decodeJsonResult } from "@t3tools/shared/schemaJson";
23import { isSshRemoteUrl } from "@t3tools/shared/sourceControl";
1524
1625import * as ServerSettings from "../serverSettings.ts";
26import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1727import * as GitHubApi from "./GitHubApi.ts";
18import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
28import {
29 decodeGitHubPullRequestEntries,
30 type NormalizedGitHubPullRequestRecord,
31} from "./gitHubPullRequests.ts";
32import {
33 parseFetchRemotes,
34 parseGitHubRepositorySelector,
35 parsePullRequestReference,
36 resolveGitHubRepository,
37 type GitHubRepositoryLocator,
38} from "./gitHubRepositoryResolution.ts";
39import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
1940import {
2041 effectiveGitHubAccount,
2142 findAuthenticatedGitHubAccount,
......@@ -40,33 +61,8 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(
4061 ),
4162);
4263
43function 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 };
64function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest {
65 return { provider: "github", ...record };
7066}
7167
7268function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) {
......@@ -263,77 +259,601 @@ export const makeDiscovery = Effect.gen(function* () {
263259 } satisfies SourceControlManagedCliDiscoverySpec;
264260});
265261
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 */
266class GitHubFailure extends Data.TaggedError("GitHubFailure")<{
267 readonly detail: string;
268 readonly cause: unknown;
269}> {}
270
271const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause });
272
273const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again.";
274const 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. */
277function 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
312const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause));
313
314const 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});
320const decodeRawRepository = decodeJsonResult(RawRepositorySchema);
321
322function 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
328const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString }));
329
330type PullRequestListState = "open" | "closed" | "merged" | "all";
331
332/** The pull request fields every read selects, in GraphQL. */
333const PULL_REQUEST_NODE_SELECTION =
334 "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
335const 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 */
345const 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 */
352const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
353const 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 */
359const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25;
360/** A full document is 5,000 rows of well under 2 KB each. */
361const 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 */
367const OWNER_HEAD_SCAN_LIMIT = 100;
368
369class 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. */
384function 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
406const 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
412const 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
425const 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. */
434export 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. */
451const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) =>
452 context === undefined ? undefined : new URL(context.provider.baseUrl).host;
453
266454export const make = Effect.gen(function* () {
267 const github = yield* GitHubRepositoryApi.GitHubRepositoryApi;
268455 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 });
269611
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({
275623 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;
298634 }
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 });
299754
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({
302765 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 });
337857
338858 const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* (
339859 input: { readonly cwd: string; readonly url: URL },
......@@ -385,121 +905,122 @@ export const make = Effect.gen(function* () {
385905 if (!match) return undefined;
386906 return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`);
387907 },
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 ),
389926 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 }),
412931 ),
932 ),
413933 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 }),
436961 ),
962 ),
437963 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(
439973 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 }),
451975 ),
452976 ),
453977 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(
4551006 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 }),
4671008 ),
4681009 ),
4691010 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))),
4891020 checkoutChangeRequest: (input) =>
490 github.checkoutPullRequest(input).pipe(
1021 checkoutPullRequest(input).pipe(
4911022 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 }),
5031024 ),
5041025 ),
5051026 });
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+2-2
......@@ -13,12 +13,12 @@ import { VcsProcessSpawnError } from "@t3tools/contracts";
1313
1414import * as ServerSettings from "../serverSettings.ts";
1515import * as ServerConfig from "../config.ts";
16import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1617import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1718import * as VcsProcess from "../vcs/VcsProcess.ts";
1819import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
1920import * as BitbucketApi from "./BitbucketApi.ts";
2021import * as GitHubApi from "./GitHubApi.ts";
21import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
2222import * as GitLabCli from "./GitLabCli.ts";
2323import * as ForgejoCli from "./ForgejoCli.ts";
2424import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
......@@ -40,8 +40,8 @@ const layerSourceControlProviderRegistryTest = (input: {
4040 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
4141 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
4242 ServerSettings.ServerSettingsService.layerTest(),
43 Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}),
4443 Layer.mock(GitHubApi.GitHubApi)({}),
44 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
4545 Layer.mock(GitLabCli.GitLabCli)({}),
4646 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
4747 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+2-3
......@@ -14,8 +14,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1414import * as VcsProcess from "../vcs/VcsProcess.ts";
1515import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
1616import * as BitbucketApi from "./BitbucketApi.ts";
17import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1718import * as GitHubApi from "./GitHubApi.ts";
18import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
1919import * as GitLabCli from "./GitLabCli.ts";
2020import * as ForgejoCli from "./ForgejoCli.ts";
2121import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
......@@ -42,7 +42,6 @@ function makeRegistry(input: {
4242 readonly url: string;
4343 }>;
4444 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;
45 readonly github?: Partial<GitHubRepositoryApi.GitHubRepositoryApi["Service"]>;
4645 readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>;
4746 readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>;
4847 readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];
......@@ -98,8 +97,8 @@ function makeRegistry(input: {
9897 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
9998 Layer.mock(BitbucketApi.BitbucketApi)({}),
10099 ServerSettings.ServerSettingsService.layerTest(),
101 Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}),
102100 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),
101 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
103102 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
104103 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
105104 ServerConfig.layerTest(process.cwd(), {
apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts created+87
......@@ -0,0 +1,87 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import {
4 gitHubApiHostForRemote,
5 selectGitHubBaseRepository,
6} from "./gitHubRepositoryResolution.ts";
7
8describe("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
78describe("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 @@
1import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
2import {
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. */
8export 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. */
15export 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 */
35export 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 */
76export 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. */
116export 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 */
143export 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 */
175export 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. */
187function 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.
229229import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
230230import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
231231import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
232import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts";
232import * as GitHubApi from "./sourceControl/GitHubApi.ts";
233233import * as GitLabCli from "./sourceControl/GitLabCli.ts";
234234import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
235235import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
......@@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap(
31903190 Layer.mergeAll(
31913191 AzureDevOpsCli.layer,
31923192 BitbucketApi.layer,
3193 GitHubRepositoryApi.layer,
3193 GitHubApi.layerWithDependencies,
31943194 GitLabCli.layer,
31953195 ForgejoCli.layer,
31963196 ),