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 {...@@ -23,7 +23,7 @@ import {
23} from "../src/textGeneration/ThreadTitleContext.ts";23} from "../src/textGeneration/ThreadTitleContext.ts";
24import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";24import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
26import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts";26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
27import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";27import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
28import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";28import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";
29import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";29import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";
...@@ -152,7 +152,7 @@ await Effect.runPromise(...@@ -152,7 +152,7 @@ await Effect.runPromise(
152 SourceControlProviderRegistry.layer.pipe(152 SourceControlProviderRegistry.layer.pipe(
153 Layer.provide(153 Layer.provide(
154 Layer.mergeAll(154 Layer.mergeAll(
155 GitHubRepositoryApi.layer,155 GitHubApi.layerWithDependencies,
156 GitLabCli.layer,156 GitLabCli.layer,
157 ForgejoCli.layer,157 ForgejoCli.layer,
158 AzureDevOpsCli.layer,158 AzureDevOpsCli.layer,
apps/server/src/git/GitManager.test.ts+130-150
...@@ -24,6 +24,7 @@ import { TestClock } from "effect/testing";...@@ -24,6 +24,7 @@ import { TestClock } from "effect/testing";
24import { ChildProcessSpawner } from "effect/process";24import { ChildProcessSpawner } from "effect/process";
25import { expect } from "vite-plus/test";25import { expect } from "vite-plus/test";
26import type {26import type {
27 ChangeRequest,
27 GitActionProgressEvent,28 GitActionProgressEvent,
28 GitManagerServiceError,29 GitManagerServiceError,
29 GitPreparePullRequestThreadInput,30 GitPreparePullRequestThreadInput,
...@@ -37,12 +38,11 @@ import {...@@ -37,12 +38,11 @@ import {
37 ProjectId,38 ProjectId,
38 ProviderDriverKind,39 ProviderDriverKind,
39 ProviderInstanceId,40 ProviderInstanceId,
41 SourceControlProviderError as SourceControlProviderFailure,
40 TextGenerationError,42 TextGenerationError,
41 ThreadId,43 ThreadId,
42} from "@t3tools/contracts";44} from "@t3tools/contracts";
43import * as DateTime from "effect/DateTime";45import * as DateTime from "effect/DateTime";
44import * as GitHubApi from "../sourceControl/GitHubApi.ts";
45import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts";
46import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";46import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";
47import * as GitLabCli from "../sourceControl/GitLabCli.ts";47import * as GitLabCli from "../sourceControl/GitLabCli.ts";
48import * as TextGeneration from "../textGeneration/TextGeneration.ts";48import * as TextGeneration from "../textGeneration/TextGeneration.ts";
...@@ -53,7 +53,6 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";...@@ -53,7 +53,6 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts";
53import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";53import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
54import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";54import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
55import * as GitWorkflowService from "./GitWorkflowService.ts";55import * as GitWorkflowService from "./GitWorkflowService.ts";
56import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
57import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";56import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
58import {57import {
59 ForgejoPullRequestSchema,58 ForgejoPullRequestSchema,
...@@ -92,7 +91,10 @@ interface FakeGhScenario {...@@ -92,7 +91,10 @@ interface FakeGhScenario {
92 headRepositoryOwnerLogin?: string | null;91 headRepositoryOwnerLogin?: string | null;
93 };92 };
94 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;93 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;
95 failWith?: GitHubRepositoryApi.GitHubRepositoryApiError;94 /** The detail the provider fails with, the way a GitHub failure reaches GitManager. */
95 failWith?: string;
96 /** The raw failure behind `failWith`, which must stay out of anything shown or logged. */
97 failCause?: unknown;
96 /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */98 /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */
97 failAfterCalls?: number;99 failAfterCalls?: number;
98}100}
...@@ -111,9 +113,12 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"];...@@ -111,9 +113,12 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"];
111113
112type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;114type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
113115
114function normalizeFakePullRequestSummary(116type FakePullRequestSummary = Omit<ChangeRequest, "provider" | "state" | "updatedAt"> & {
115 raw: unknown,117 readonly state?: ChangeRequest["state"];
116): GitHubRepositoryApi.GitHubPullRequestSummary | null {118 readonly updatedAt?: string;
119};
120
121function normalizeFakePullRequestSummary(raw: unknown): FakePullRequestSummary | null {
117 if (!raw || typeof raw !== "object") {122 if (!raw || typeof raw !== "object") {
118 return null;123 return null;
119 }124 }
...@@ -383,8 +388,12 @@ function createTextGeneration(...@@ -383,8 +388,12 @@ function createTextGeneration(
383 };388 };
384}389}
385390
386function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {391/**
387 service: GitHubRepositoryApi.GitHubRepositoryApi["Service"];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"];
388 ghCalls: string[];397 ghCalls: string[];
389} {398} {
390 const prListQueue = [...(scenario.prListSequence ?? [])];399 const prListQueue = [...(scenario.prListSequence ?? [])];
...@@ -396,16 +405,20 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -396,16 +405,20 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
396 );405 );
397 const ghCalls: string[] = [];406 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. */
400 const execute = (input: {412 const execute = (input: {
401 readonly cwd: string;413 readonly cwd: string;
402 readonly args: ReadonlyArray<string>;414 readonly args: ReadonlyArray<string>;
403 }): Effect.Effect<VcsProcess.VcsProcessOutput, GitHubRepositoryApi.GitHubRepositoryApiError> => {415 readonly operation: string;
416 }): Effect.Effect<VcsProcess.VcsProcessOutput, SourceControlProviderFailure> => {
404 const args = [...input.args];417 const args = [...input.args];
405 ghCalls.push(args.join(" "));418 ghCalls.push(args.join(" "));
406419
407 if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) {420 if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) {
408 return Effect.fail(scenario.failWith);421 return Effect.fail(fail(input.cwd, scenario.failWith, scenario.failCause, input.operation));
409 }422 }
410423
411 if (args[0] === "pr" && args[1] === "list") {424 if (args[0] === "pr" && args[1] === "list") {
...@@ -487,13 +500,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -487,13 +500,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
487 }500 }
488 return fakeGhOutput("");501 return fakeGhOutput("");
489 },502 },
490 catch: (error) =>503 catch: (error) => fail(input.cwd, "GitHub request failed.", error),
491 GitHubRepositoryApi.isGitHubRepositoryApiError(error)
492 ? error
493 : new GitHubRepositoryApi.GitHubRepositoryCommandError({
494 cwd: input.cwd,
495 cause: error,
496 }),
497 });504 });
498 }505 }
499506
...@@ -502,12 +509,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -502,12 +509,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
502 if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) {509 if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) {
503 const cloneUrls = scenario.repositoryCloneUrls?.[repository];510 const cloneUrls = scenario.repositoryCloneUrls?.[repository];
504 if (!cloneUrls) {511 if (!cloneUrls) {
505 return Effect.fail(512 return Effect.fail(fail(input.cwd, `Unexpected repository lookup: ${repository}`));
506 new GitHubRepositoryApi.GitHubRepositoryCommandError({
507 cwd: input.cwd,
508 cause: new Error(`Unexpected repository lookup: ${repository}`),
509 }),
510 );
511 }513 }
512 return Effect.succeed(514 return Effect.succeed(
513 fakeGhOutput(515 fakeGhOutput(
...@@ -522,73 +524,86 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -522,73 +524,86 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
522 return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`));524 return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`));
523 }525 }
524526
525 return Effect.fail(527 return Effect.fail(fail(input.cwd, `Unexpected gh command: ${args.join(" ")}`));
526 new GitHubRepositoryApi.GitHubRepositoryCommandError({
527 cwd: input.cwd,
528 cause: new Error(`Unexpected gh command: ${args.join(" ")}`),
529 }),
530 );
531 };528 };
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
533 return {543 return {
534 service: {544 service: {
535 // The fake answers the CLI shape, so batched lookups read it the way the fallback does.545 kind: "github",
536 listPullRequestsByHead: (input) =>546 listChangeRequests: (input) =>
537 execute({547 input.state === "open"
538 cwd: input.cwd,548 ? execute({
539 args: [549 operation: "listChangeRequests",
540 "pr",550 cwd: input.cwd,
541 "list",551 args: [
542 "--head",552 "pr",
543 input.headSelector,553 "list",
544 "--state",554 "--head",
545 input.state,555 input.headSelector,
546 "--limit",556 "--state",
547 String(input.limit),557 "open",
548 "--json",558 "--limit",
549 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",559 String(input.limit ?? 1),
550 ],560 "--json",
551 }).pipe(561 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
552 Effect.map((result) => {562 ],
553 const raw = result.stdout.trim();563 }).pipe(
554 if (raw.length === 0) return [];564 Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
555 const decoded = decodeGitHubPullRequestListJson(raw);565 Effect.map((raw) =>
556 return Result.isSuccess(decoded) ? decoded.success : [];566 raw
557 }),567 .map((entry) => normalizeFakePullRequestSummary(entry))
558 ),568 .filter((entry): entry is FakePullRequestSummary => entry !== null)
559 listOpenPullRequests: (input) =>569 .map(toChangeRequest),
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,
581 ),570 ),
582 ),571 )
583 ),572 : // The fake answers the CLI shape, so batched lookups read it the way the fallback does.
584 createPullRequest: (input) =>573 execute({
574 operation: "listChangeRequests",
575 cwd: input.cwd,
576 args: [
577 "pr",
578 "list",
579 "--head",
580 input.headSelector,
581 "--state",
582 input.state,
583 "--limit",
584 String(input.limit ?? 20),
585 "--json",
586 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
587 ],
588 }).pipe(
589 Effect.map((result) => {
590 const raw = result.stdout.trim();
591 if (raw.length === 0) return [];
592 const decoded = decodeGitHubPullRequestListJson(raw);
593 return Result.isSuccess(decoded)
594 ? decoded.success.map((record) => ({ provider: "github" as const, ...record }))
595 : [];
596 }),
597 ),
598 createChangeRequest: (input) =>
585 execute({599 execute({
600 operation: "createChangeRequest",
586 cwd: input.cwd,601 cwd: input.cwd,
587 args: [602 args: [
588 "pr",603 "pr",
589 "create",604 "create",
590 "--base",605 "--base",
591 input.baseBranch,606 input.baseRefName,
592 "--head",607 "--head",
593 input.headSelector,608 input.headSelector,
594 "--title",609 "--title",
...@@ -599,6 +614,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -599,6 +614,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
599 }).pipe(Effect.asVoid),614 }).pipe(Effect.asVoid),
600 getDefaultBranch: (input) =>615 getDefaultBranch: (input) =>
601 execute({616 execute({
617 operation: "getDefaultBranch",
602 cwd: input.cwd,618 cwd: input.cwd,
603 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],619 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
604 }).pipe(620 }).pipe(
...@@ -607,8 +623,9 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -607,8 +623,9 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
607 return value.length > 0 ? value : null;623 return value.length > 0 ? value : null;
608 }),624 }),
609 ),625 ),
610 getPullRequest: (input) =>626 getChangeRequest: (input) =>
611 execute({627 execute({
628 operation: "getChangeRequest",
612 cwd: input.cwd,629 cwd: input.cwd,
613 args: [630 args: [
614 "pr",631 "pr",
...@@ -618,24 +635,21 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -618,24 +635,21 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): {
618 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",635 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
619 ],636 ],
620 }).pipe(637 }).pipe(
621 Effect.map(638 Effect.map((result) =>
622 (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary,639 toChangeRequest(JSON.parse(result.stdout) as FakePullRequestSummary),
623 ),640 ),
624 ),641 ),
625 getRepositoryCloneUrls: (input) =>642 getRepositoryCloneUrls: (input) =>
626 execute({643 execute({
644 operation: "getRepositoryCloneUrls",
627 cwd: input.cwd,645 cwd: input.cwd,
628 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],646 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
629 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),647 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),
630 createRepository: (input) =>648 createRepository: (input) =>
631 Effect.fail(649 Effect.fail(fail(input.cwd, `Unexpected repository create: ${input.repository}`)),
632 new GitHubRepositoryApi.GitHubRepositoryCommandError({650 checkoutChangeRequest: (input) =>
633 cwd: input.cwd,
634 cause: new Error(`Unexpected repository create: ${input.repository}`),
635 }),
636 ),
637 checkoutPullRequest: (input) =>
638 execute({651 execute({
652 operation: "checkoutChangeRequest",
639 cwd: input.cwd,653 cwd: input.cwd,
640 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],654 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
641 }).pipe(Effect.asVoid),655 }).pipe(Effect.asVoid),
...@@ -693,7 +707,7 @@ function makeManager(input?: {...@@ -693,7 +707,7 @@ function makeManager(input?: {
693 ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2707 ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2
694 >;708 >;
695}) {709}) {
696 const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh(710 const { service: fakeGitHubProvider, ghCalls } = createGitHubProviderWithFakeGh(
697 input?.ghScenario,711 input?.ghScenario,
698 );712 );
699 const textGeneration = createTextGeneration(input?.textGeneration);713 const textGeneration = createTextGeneration(input?.textGeneration);
...@@ -729,10 +743,7 @@ function makeManager(input?: {...@@ -729,10 +743,7 @@ function makeManager(input?: {
729 );743 );
730 const layerSourceControlRegistry = Layer.effect(744 const layerSourceControlRegistry = Layer.effect(
731 SourceControlProviderRegistry.SourceControlProviderRegistry,745 SourceControlProviderRegistry.SourceControlProviderRegistry,
732 (input?.sourceControlProvider === undefined746 Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe(
733 ? GitHubSourceControlProvider.make
734 : Effect.succeed(input.sourceControlProvider)
735 ).pipe(
736 Effect.map((provider) =>747 Effect.map((provider) =>
737 SourceControlProviderRegistry.SourceControlProviderRegistry.of({748 SourceControlProviderRegistry.SourceControlProviderRegistry.of({
738 resolveLink: (input) => provider.resolveLink?.(input),749 resolveLink: (input) => provider.resolveLink?.(input),
...@@ -742,12 +753,6 @@ function makeManager(input?: {...@@ -742,12 +753,6 @@ function makeManager(input?: {
742 discover: Effect.succeed([]),753 discover: Effect.succeed([]),
743 }),754 }),
744 ),755 ),
745 Effect.provide(
746 Layer.merge(
747 Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi),
748 Layer.mock(GitHubApi.GitHubApi)({}),
749 ),
750 ),
751 ),756 ),
752 );757 );
753758
...@@ -1300,10 +1305,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1300,10 +1305,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
1300 yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]);1305 yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]);
1301 const { manager, ghCalls } = yield* makeManager({1306 const { manager, ghCalls } = yield* makeManager({
1302 ghScenario: {1307 ghScenario: {
1303 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({1308 failWith:
1304 cwd: repoDir,1309 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
1305 cause: new Error("rate limited"),
1306 }),
1307 },1310 },
1308 });1311 });
1309 yield* manager.remoteStatus({ cwd: repoDir });1312 yield* manager.remoteStatus({ cwd: repoDir });
...@@ -1782,10 +1785,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -1782,10 +1785,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
17821785
1783 const { manager, ghCalls } = yield* makeManager({1786 const { manager, ghCalls } = yield* makeManager({
1784 ghScenario: {1787 ghScenario: {
1785 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({1788 failWith:
1786 cwd: repoDir,1789 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
1787 cause: new Error("gh is not available on PATH"),
1788 }),
1789 },1790 },
1790 });1791 });
17911792
...@@ -2642,10 +2643,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2642,10 +2643,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2642 },2643 },
2643 ]),2644 ]),
2644 },2645 },
2645 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({2646 failWith:
2646 cwd: repoDir,2647 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
2647 cause: new Error("rate limited"),
2648 }),
2649 failAfterCalls: 1,2648 failAfterCalls: 1,
2650 },2649 },
2651 });2650 });
...@@ -2718,10 +2717,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2718,10 +2717,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
27182717
2719 const { manager } = yield* makeManager({2718 const { manager } = yield* makeManager({
2720 ghScenario: {2719 ghScenario: {
2721 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({2720 failWith:
2722 cwd: repoDir,2721 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
2723 cause: new Error("gh is not available on PATH"),
2724 }),
2725 },2722 },
2726 });2723 });
27272724
...@@ -2743,10 +2740,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2743,10 +2740,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2743 const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value";2740 const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value";
2744 const { manager } = yield* makeManager({2741 const { manager } = yield* makeManager({
2745 ghScenario: {2742 ghScenario: {
2746 failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({2743 failWith: "GitHub API rate limit exceeded. Requests resume when the limit resets.",
2747 cwd: repoDir,2744 failCause: new Error(upstreamCause),
2748 cause: new Error(upstreamCause),
2749 }),
2750 },2745 },
2751 });2746 });
2752 const logs: Array<{ message: string; annotations: Record<string, unknown> }> = [];2747 const logs: Array<{ message: string; annotations: Record<string, unknown> }> = [];
...@@ -2799,10 +2794,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2799,10 +2794,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2799 const { manager } = yield* makeManager({2794 const { manager } = yield* makeManager({
2800 ghScenario: {2795 ghScenario: {
2801 prListSequence: [JSON.stringify([existingPr])],2796 prListSequence: [JSON.stringify([existingPr])],
2802 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({2797 failWith:
2803 cwd: repoDir,2798 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
2804 cause: new Error("rate limited"),
2805 }),
2806 failAfterCalls: 1,2799 failAfterCalls: 1,
2807 },2800 },
2808 });2801 });
...@@ -2841,10 +2834,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2841,10 +2834,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2841 const { manager } = yield* makeManager({2834 const { manager } = yield* makeManager({
2842 ghScenario: {2835 ghScenario: {
2843 prListSequence: [JSON.stringify([existingPr])],2836 prListSequence: [JSON.stringify([existingPr])],
2844 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({2837 failWith:
2845 cwd: repoDir,2838 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
2846 cause: new Error("rate limited"),
2847 }),
2848 failAfterCalls: 1,2839 failAfterCalls: 1,
2849 },2840 },
2850 });2841 });
...@@ -2888,10 +2879,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2888,10 +2879,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2888 const { manager } = yield* makeManager({2879 const { manager } = yield* makeManager({
2889 ghScenario: {2880 ghScenario: {
2890 prListSequence: [JSON.stringify([existingPr])],2881 prListSequence: [JSON.stringify([existingPr])],
2891 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({2882 failWith:
2892 cwd: repoDir,2883 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
2893 cause: new Error("rate limited"),
2894 }),
2895 failAfterCalls: 1,2884 failAfterCalls: 1,
2896 },2885 },
2897 });2886 });
...@@ -2926,10 +2915,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2926,10 +2915,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2926 const { manager } = yield* makeManager({2915 const { manager } = yield* makeManager({
2927 ghScenario: {2916 ghScenario: {
2928 prListSequence: [JSON.stringify([existingPr])],2917 prListSequence: [JSON.stringify([existingPr])],
2929 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({2918 failWith:
2930 cwd: repoDir,2919 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
2931 cause: new Error("rate limited"),
2932 }),
2933 failAfterCalls: 1,2920 failAfterCalls: 1,
2934 },2921 },
2935 });2922 });
...@@ -2967,10 +2954,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2967,10 +2954,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2967 const { manager } = yield* makeManager({2954 const { manager } = yield* makeManager({
2968 ghScenario: {2955 ghScenario: {
2969 prListSequence: [JSON.stringify([existingPr])],2956 prListSequence: [JSON.stringify([existingPr])],
2970 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({2957 failWith:
2971 cwd: repoDir,2958 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
2972 cause: new Error("rate limited"),
2973 }),
2974 failAfterCalls: 1,2959 failAfterCalls: 1,
2975 },2960 },
2976 });2961 });
...@@ -4611,10 +4596,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -4611,10 +4596,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
46114596
4612 const { manager } = yield* makeManager({4597 const { manager } = yield* makeManager({
4613 ghScenario: {4598 ghScenario: {
4614 failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({4599 failWith:
4615 cwd: repoDir,4600 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.",
4616 cause: new Error("gh is not available on PATH"),
4617 }),
4618 },4601 },
4619 });4602 });
46204603
...@@ -4640,10 +4623,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -4640,10 +4623,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
46404623
4641 const { manager } = yield* makeManager({4624 const { manager } = yield* makeManager({
4642 ghScenario: {4625 ghScenario: {
4643 failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({4626 failWith: "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.",
4644 cwd: repoDir,
4645 cause: new Error("gh is not authenticated"),
4646 }),
4647 },4627 },
4648 });4628 });
46494629
apps/server/src/orchestration-v2/ThreadPullRequestService.ts+1-1
...@@ -345,7 +345,7 @@ export const make = Effect.gen(function* () {...@@ -345,7 +345,7 @@ export const make = Effect.gen(function* () {
345 }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))),345 }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))),
346 ),346 ),
347 ),347 ),
348 // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one348 // Wide enough that a sweep's GitHub branch lookups reach the GitHub provider together and share one
349 // GraphQL document, instead of one `gh pr list` per branch.349 // GraphQL document, instead of one `gh pr list` per branch.
350 { concurrency: 32, discard: true },350 { concurrency: 32, discard: true },
351 );351 );
apps/server/src/pullRequest/GitHubPullRequestApi.test.ts+1-1
...@@ -12,7 +12,7 @@ import * as Redacted from "effect/Redacted";...@@ -12,7 +12,7 @@ import * as Redacted from "effect/Redacted";
1212
13import { HttpClient, HttpClientResponse } from "effect/http";13import { HttpClient, HttpClientResponse } from "effect/http";
1414
15import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts";15import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts";
16import * as GitHubApi from "../sourceControl/GitHubApi.ts";16import * as GitHubApi from "../sourceControl/GitHubApi.ts";
17import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";17import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
18import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";18import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
apps/server/src/pullRequest/GitHubPullRequestApi.ts+1-2
...@@ -42,7 +42,6 @@ import {...@@ -42,7 +42,6 @@ import {
42 type PullRequestPreview,42 type PullRequestPreview,
43} from "@t3tools/contracts";43} from "@t3tools/contracts";
4444
45import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts";
46import * as GitHubApi from "../sourceControl/GitHubApi.ts";45import * as GitHubApi from "../sourceControl/GitHubApi.ts";
47import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts";46import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts";
48import * as VcsProcess from "../vcs/VcsProcess.ts";47import * as VcsProcess from "../vcs/VcsProcess.ts";
...@@ -1524,7 +1523,7 @@ export const make = Effect.gen(function* () {...@@ -1524,7 +1523,7 @@ export const make = Effect.gen(function* () {
15241523
1525 const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => {1524 const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => {
1526 const { owner, name } = parseRepositorySelector(input.repository);1525 const { owner, name } = parseRepositorySelector(input.repository);
1527 return AllowGitHubReserve.pipe(1526 return GitHubApi.AllowGitHubReserve.pipe(
1528 Effect.flatMap((allowReserve) =>1527 Effect.flatMap((allowReserve) =>
1529 graphqlRead({1528 graphqlRead({
1530 allowReserve,1529 allowReserve,
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+1-2
...@@ -7,7 +7,6 @@ import type { PullRequestReaction } from "@t3tools/contracts";...@@ -7,7 +7,6 @@ import type { PullRequestReaction } from "@t3tools/contracts";
77
8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
9import * as GitHubApi from "../sourceControl/GitHubApi.ts";9import * as GitHubApi from "../sourceControl/GitHubApi.ts";
10import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts";
11import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";10import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
12import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts";11import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts";
13import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";12import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
...@@ -726,7 +725,7 @@ describe("getViewerPermissions", () => {...@@ -726,7 +725,7 @@ describe("getViewerPermissions", () => {
726 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({725 Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({
727 revalidateChecks: (_input, read) => read,726 revalidateChecks: (_input, read) => read,
728 getPullRequestDetail: () =>727 getPullRequestDetail: () =>
729 GitHubRepositoryApi.AllowGitHubReserve.pipe(728 GitHubApi.AllowGitHubReserve.pipe(
730 Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))),729 Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))),
731 Effect.flatMap(() => detail),730 Effect.flatMap(() => detail),
732 ),731 ),
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+2-2
...@@ -7,7 +7,7 @@ import type {...@@ -7,7 +7,7 @@ import type {
7 PullRequestViewerPermissions,7 PullRequestViewerPermissions,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
99
10import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts";10import * as GitHubApi from "../sourceControl/GitHubApi.ts";
11import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";11import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
12import {12import {
13 PullRequestProviderError,13 PullRequestProviderError,
...@@ -526,7 +526,7 @@ export const make = Effect.gen(function* () {...@@ -526,7 +526,7 @@ export const make = Effect.gen(function* () {
526 // comparison, so one read usually answers what used to take three. When that heavier read526 // comparison, so one read usually answers what used to take three. When that heavier read
527 // fails, the light access read still answers, withholding only update-branch.527 // fails, the light access read still answers, withholding only update-branch.
528 return cli.getPullRequestDetail(input).pipe(528 return cli.getPullRequestDetail(input).pipe(
529 Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true),529 Effect.provideService(GitHubApi.AllowGitHubReserve, true),
530 Effect.map((pullRequest) =>530 Effect.map((pullRequest) =>
531 gitHubViewerPermissions({531 gitHubViewerPermissions({
532 ...pullRequest.viewerAccess,532 ...pullRequest.viewerAccess,
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+2-9
...@@ -6,9 +6,6 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";...@@ -6,9 +6,6 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";
6import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";6import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
7import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";7import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
8import * as GitHubApi from "../sourceControl/GitHubApi.ts";8import * 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";
12import * as GitLabCli from "../sourceControl/GitLabCli.ts";9import * as GitLabCli from "../sourceControl/GitLabCli.ts";
13import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";10import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
14import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";11import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
...@@ -63,12 +60,8 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(...@@ -63,12 +60,8 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
63 Layer.provide(60 Layer.provide(
64 GitHubPullRequestApi.layer.pipe(61 GitHubPullRequestApi.layer.pipe(
65 Layer.provide(62 Layer.provide(
66 GitHubApi.layer.pipe(63 // Built here too, so a registry built alone still has one budget and pause per host.
67 Layer.provide(GitHubCredentials.layer),64 GitHubApi.layerWithDependencies,
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 ),
72 ),65 ),
73 ),66 ),
74 ),67 ),
apps/server/src/pullRequest/PullRequestService.ts+1-1
...@@ -77,7 +77,7 @@ import {...@@ -77,7 +77,7 @@ import {
77import { resolveProjectSettings } from "@t3tools/shared/projectSettings";77import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
78import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";78import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
7979
80import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts";80import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts";
81import * as ProjectService from "../project/ProjectService.ts";81import * as ProjectService from "../project/ProjectService.ts";
82import * as ServerSettings from "../serverSettings.ts";82import * as ServerSettings from "../serverSettings.ts";
83import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";83import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts";
apps/server/src/server.ts+3-3
...@@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";...@@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
50import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";50import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
51import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";51import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
52import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";52import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
53import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts";53import * as GitHubApi from "./sourceControl/GitHubApi.ts";
54import * as GitLabCli from "./sourceControl/GitLabCli.ts";54import * as GitLabCli from "./sourceControl/GitLabCli.ts";
55import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";55import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
56import * as TextGeneration from "./textGeneration/TextGeneration.ts";56import * as TextGeneration from "./textGeneration/TextGeneration.ts";
...@@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p...@@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p
270 Layer.mergeAll(270 Layer.mergeAll(
271 AzureDevOpsCli.layer,271 AzureDevOpsCli.layer,
272 BitbucketApi.layer,272 BitbucketApi.layer,
273 GitHubRepositoryApi.layer,273 GitHubApi.layerWithDependencies,
274 GitLabCli.layer,274 GitLabCli.layer,
275 ForgejoCli.layer,275 ForgejoCli.layer,
276 ),276 ),
...@@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(...@@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
565 Layer.provideMerge(layerServerSettings),565 Layer.provideMerge(layerServerSettings),
566 // The asset route uses the registry's GitHub credential for private PR media.566 // The asset route uses the registry's GitHub credential for private PR media.
567 Layer.provideMerge(layerSourceControlProviderRegistry),567 Layer.provideMerge(layerSourceControlProviderRegistry),
568 Layer.provideMerge(GitHubRepositoryApi.layer),568 Layer.provideMerge(GitHubApi.layerWithDependencies),
569 Layer.provideMerge(layerGit),569 Layer.provideMerge(layerGit),
570 Layer.provideMerge(layerVcs),570 Layer.provideMerge(layerVcs),
571 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),571 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),
apps/server/src/sourceControl/GitHubApi.ts+10
...@@ -598,3 +598,13 @@ export const make = Effect.gen(function* () {...@@ -598,3 +598,13 @@ export const make = Effect.gen(function* () {
598});598});
599599
600export const layer = Layer.effect(GitHubApi, make);600export 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 =...@@ -81,10 +81,6 @@ export type GitHubCredentialUnavailableError =
81 | GitHubHostDisabledError81 | GitHubHostDisabledError
82 | GitHubCliFailedError;82 | GitHubCliFailedError;
8383
84export const isGitHubCredentialUnavailableError = Schema.is(
85 Schema.Union([GitHubCliMissingError, GitHubNotSignedInError, GitHubHostDisabledError]),
86);
87
88/**84/**
89 * Where GitHub tokens come from. Callers ask per host and never see how the token was found,85 * Where GitHub tokens come from. Callers ask per host and never see how the token was found,
90 * so another source (an in-app OAuth login) slots in here without touching any of them.86 * so another source (an in-app OAuth login) slots in here without touching any of them.
apps/server/src/sourceControl/GitHubRepositoryApi.test.ts deleted-651
...@@ -1,651 +0,0 @@
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 @@...@@ -1,17 +1,23 @@
1import { assert, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";7import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
7import { ChildProcessSpawner } from "effect/process";9import { ChildProcessSpawner } from "effect/process";
10import * as TestClock from "effect/testing/TestClock";
811
12import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
9import * as VcsProcess from "../vcs/VcsProcess.ts";13import * as VcsProcess from "../vcs/VcsProcess.ts";
10import * as GitHubApi from "./GitHubApi.ts";14import * as GitHubApi from "./GitHubApi.ts";
11import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";15import * as GitHubCredentials from "./GitHubCredentials.ts";
12import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";16import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
13import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";17import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1418
19const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
20
15const processResult = (21const processResult = (
16 stdout: string,22 stdout: string,
17 options?: {23 options?: {
...@@ -26,191 +32,611 @@ const processResult = (...@@ -26,191 +32,611 @@ const processResult = (
26 stderrTruncated: false,32 stderrTruncated: false,
27});33});
2834
29function makeProvider(35const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({
30 github: Partial<GitHubRepositoryApi.GitHubRepositoryApi["Service"]>,36 exitCode: ChildProcessSpawner.ExitCode(exitCode),
31 api: Partial<GitHubApi.GitHubApi["Service"]> = {},37 stdout,
32) {38 stderr: "",
33 return GitHubSourceControlProvider.make.pipe(39 stdoutTruncated: false,
34 Effect.provide(40 stderrTruncated: false,
35 Layer.merge(41});
36 Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github),
37 Layer.mock(GitHubApi.GitHubApi)(api),
38 ),
39 ),
40 );
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 => ({48const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({
46 status: 200,49 status,
47 headers: {},50 headers: {},
48 body,51 body: body === undefined ? "" : encodeJson(body),
49 truncated: false,52 truncated: false,
50 invalidUtf8: false,53 invalidUtf8: false,
51});54});
5255
53it.effect("maps GitHub PR summaries into provider-neutral change requests", () =>56const node = (number: number, headRefName: string, owner = "acme") => ({
54 Effect.gen(function* () {57 number,
55 const provider = yield* makeProvider({58 title: `PR ${number}`,
56 getPullRequest: () =>59 url: `https://github.com/acme/web/pull/${number}`,
57 Effect.succeed({60 baseRefName: "main",
58 number: 42,61 headRefName,
59 title: "Add GitHub provider",62 state: "OPEN",
60 url: "https://github.com/pingdotgg/t3code/pull/42",63 isCrossRepository: owner !== "acme",
61 baseRefName: "main",64 updatedAt: "2026-01-02T00:00:00Z",
62 headRefName: "feature/source-control",65 headRepository: { name: "web", nameWithOwner: `${owner}/web` },
63 state: "open",66 headRepositoryOwner: { login: owner },
64 isCrossRepository: true,67});
65 headRepositoryNameWithOwner: "fork/t3code",68
66 headRepositoryOwnerLogin: "fork",69/**
67 }),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 },
68 });156 });
157 return Effect.gen(function* () {
158 const gh = yield* GitHubSourceControlProvider.make;
159 assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk");
160 assert.deepStrictEqual(paths, ["github.com repos/acme/web"]);
161 }).pipe(Effect.provide(layer));
162 });
69163
70 const changeRequest = yield* provider.getChangeRequest({164 it.effect("reads an SSH alias remote through github.com", () => {
71 cwd: "/repo",165 const hosts: string[] = [];
72 reference: "42",166 const { layer } = harness({
167 remotes: remotesOutput(["origin", "git@github:acme/web.git"]),
168 api: {
169 rest: (input) =>
170 Effect.sync(() => {
171 hosts.push(`${input.host} ${input.path}`);
172 return restResponse({
173 full_name: "acme/web",
174 html_url: "https://github.com/acme/web",
175 ssh_url: "git@github.com:acme/web.git",
176 default_branch: "main",
177 });
178 }),
179 },
73 });180 });
181 return Effect.gen(function* () {
182 const gh = yield* GitHubSourceControlProvider.make;
183 yield* gh.getDefaultBranch({ cwd: "/repo" });
184 // A provider's host hint for the same alias (`github` here) resolves the same way.
185 yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("github") });
186 assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]);
187 }).pipe(Effect.provide(layer));
188 });
74189
75 assert.deepStrictEqual(changeRequest, {190 it.effect("names a missing repository rather than a pull request", () => {
76 provider: "github",191 const { layer } = harness({
77 number: 42,192 remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]),
78 title: "Add GitHub provider",193 api: {
79 url: "https://github.com/pingdotgg/t3code/pull/42",194 rest: (input) =>
80 baseRefName: "main",195 Effect.fail(
81 headRefName: "feature/source-control",196 new GitHubApi.GitHubApiNotFoundError({ host: input.host, operation: input.operation }),
82 state: "open",197 ),
83 closedAt: null,198 },
84 mergedAt: null,
85 updatedAt: Option.none(),
86 isCrossRepository: true,
87 headRepositoryNameWithOwner: "fork/t3code",
88 headRepositoryOwnerLogin: "fork",
89 });199 });
90 }),200 return Effect.gen(function* () {
91);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", () =>207 it.effect("fails clearly when no remote is on GitHub", () => {
94 Effect.gen(function* () {208 const { layer } = harness({
95 const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({209 remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]),
96 cwd: "/repo",210 api: {},
97 cause: new Error("raw upstream detail that should remain in the cause"),
98 });211 });
99 const provider = yield* makeProvider({212 return Effect.gen(function* () {
100 getPullRequest: () => Effect.fail(cause),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 },
101 });242 });
243 return Effect.gen(function* () {
244 const gh = yield* GitHubSourceControlProvider.make;
245 const lookup = (headSelector: string) =>
246 gh
247 .listChangeRequests({
248 cwd: "/repo",
249 headSelector,
250 state: "all",
251 limit: 100,
252 context: githubContext("github.com"),
253 })
254 .pipe(Effect.forkChild);
255 // Each branch's own git reads come first, so a sweep's lookups arrive spread out.
256 const firstLookup = yield* lookup("feature/a");
257 yield* TestClock.adjust("200 millis");
258 const secondLookup = yield* lookup("feature/b");
259 yield* TestClock.adjust("300 millis");
260 const first = yield* Fiber.join(firstLookup);
261 const second = yield* Fiber.join(secondLookup);
262 assert.deepStrictEqual(
263 first?.map((pr) => pr.number),
264 [7],
265 );
266 assert.strictEqual(first?.[0]?.headSha, "a".repeat(40));
267 assert.deepStrictEqual(second, []);
268 assert.strictEqual(documents.length, 1);
269 assert.include(documents[0]!.query, "headRefOid");
270 assert.deepStrictEqual(documents[0]!.variables, {
271 owner: "acme",
272 name: "web",
273 h0: "feature/a",
274 s0: ["OPEN", "CLOSED", "MERGED"],
275 h1: "feature/b",
276 s1: ["OPEN", "CLOSED", "MERGED"],
277 });
278 }).pipe(Effect.provide(layer));
279 });
102280
103 const error = yield* provider281 it.effect("caps a background document at twenty-five heads", () => {
104 .getChangeRequest({282 const headCounts: Array<number> = [];
105 cwd: "/repo",283 const { layer } = harness({
106 reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff",284 remotes,
107 })285 api: {
108 .pipe(Effect.flip);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(319 it.effect("matches an owner:branch selector on the head owner", () => {
111 {320 const { layer } = harness({
112 provider: error.provider,321 remotes,
113 operation: error.operation,322 api: {
114 cwd: error.cwd,323 graphql: (input) =>
115 reference: error.reference,324 Effect.succeed(
116 detail: error.detail,325 encodeJson({
326 data: {
327 repository: {
328 h0: {
329 nodes:
330 input.variables?.h0 === "main"
331 ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")]
332 : [],
333 },
334 },
335 },
336 }),
337 ),
117 },338 },
118 {339 });
119 provider: "github",340 return Effect.gen(function* () {
120 operation: "getChangeRequest",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({
121 cwd: "/repo",407 cwd: "/repo",
122 reference: "https://github.com/pingdotgg/t3code/pull/42",408 reference: "https://github.com/other/thing/pull/42",
123 detail: "Pull request not found. Check the PR number or URL and try again.",409 });
410 assert.deepStrictEqual(variables, [
411 { owner: "acme", name: "web", number: 42 },
412 { owner: "other", name: "thing", number: 42 },
413 ]);
414 }).pipe(Effect.provide(layer));
415 });
416
417 it.effect("fails a missing pull request as not found", () => {
418 const { layer } = harness({
419 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
420 api: {
421 graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })),
124 },422 },
125 );423 });
126 assert.strictEqual(error.cause, cause);424 return Effect.gen(function* () {
127 assert.equal(error.message.includes("raw upstream detail"), false);425 const gh = yield* GitHubSourceControlProvider.make;
128 }),426 const error = yield* gh.getChangeRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip);
129);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", () =>432describe("GitHubSourceControlProvider writes", () => {
132 Effect.gen(function* () {433 it.effect("creates a cross-repository pull request with an owner:branch head", () => {
133 let lookup:434 const requests: Array<GitHubApi.GitHubRestInput> = [];
134 | Parameters<GitHubRepositoryApi.GitHubRepositoryApi["Service"]["listPullRequestsByHead"]>[0]435 const { layer } = harness({
135 | null = null;436 remotes: remotesOutput(
136 const provider = yield* makeProvider({437 ["origin", "git@github.com:me/web.git"],
137 listPullRequestsByHead: (input) => {438 ["upstream", "git@github.com:acme/web.git"],
138 lookup = input;439 ),
139 return Effect.succeed([440 api: {
140 {441 rest: (input) =>
141 number: 7,442 Effect.sync(() => {
142 title: "Merged work",443 requests.push(input);
143 url: "https://enterprise.test/acme/web/pull/7",444 return restResponse({ number: 1 }, 201);
144 baseRefName: "main",445 }),
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 ]);
152 },446 },
153 });447 });
448 return Effect.gen(function* () {
449 const fs = yield* FileSystem.FileSystem;
450 const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" });
451 yield* fs.writeFileString(bodyFile, "Body");
452 const gh = yield* GitHubSourceControlProvider.make;
453 yield* gh.createChangeRequest({
454 cwd: "/repo",
455 baseRefName: "main",
456 headSelector: "me:feature",
457 title: "Title",
458 bodyFile,
459 });
460 assert.strictEqual(requests[0]!.method, "POST");
461 assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls");
462 assert.deepStrictEqual(requests[0]!.body, {
463 base: "main",
464 head: "me:feature",
465 title: "Title",
466 body: "Body",
467 maintainer_can_modify: true,
468 });
469 }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped);
470 });
154471
155 const changeRequests = yield* provider.listChangeRequests({472 it.effect("creates a repository under an organization the viewer is not", () => {
156 cwd: "/repo",473 const requests: Array<string> = [];
157 context: {474 const { layer } = harness({
158 provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" },475 remotes: "",
159 remoteName: "origin",476 api: {
160 remoteUrl: "https://enterprise.test/acme/web.git",477 rest: (input) =>
478 Effect.sync(() => {
479 requests.push(`${input.method ?? "GET"} ${input.path}`);
480 return input.path === "user"
481 ? restResponse({ login: "me" })
482 : restResponse({
483 full_name: "acme/new",
484 html_url: "https://github.com/acme/new",
485 ssh_url: "git@github.com:acme/new.git",
486 });
487 }),
161 },488 },
162 headSelector: "feature/merged",489 });
163 state: "all",490 return Effect.gen(function* () {
164 limit: 10,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,
165 });514 });
166515
167 assert.deepStrictEqual(lookup, {516 it.effect("checks a same-repository pull request out from its head branch", () => {
168 cwd: "/repo",517 const { layer, git } = harness({
169 headSelector: "feature/merged",518 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
170 state: "all",519 api: {
171 limit: 10,520 graphql: () =>
172 rateLimitHost: "enterprise.test",521 Effect.succeed(
522 encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }),
523 ),
524 },
173 });525 });
174 assert.strictEqual(changeRequests[0]?.provider, "github");526 return Effect.gen(function* () {
175 assert.strictEqual(changeRequests[0]?.state, "merged");527 const gh = yield* GitHubSourceControlProvider.make;
176 assert.strictEqual(changeRequests[0]?.headSha, "a".repeat(40));528 yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "5" });
177 assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z");529 assert.deepStrictEqual(git, [
178 assert.deepStrictEqual(530 [
179 changeRequests[0]?.updatedAt,531 "fetchRemoteTrackingBranch",
180 Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),532 { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" },
181 );533 ],
182 }),534 ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]],
183);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", () =>544 it.effect("refuses a fork checkout when the base's default branch cannot be read", () => {
186 Effect.gen(function* () {545 const { layer, git } = harness({
187 let createInput:546 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
188 | Parameters<GitHubRepositoryApi.GitHubRepositoryApi["Service"]["createPullRequest"]>[0]547 localBranches: ["main"],
189 | null = null;548 api: {
190 const provider = yield* makeProvider({549 graphql: () =>
191 createPullRequest: (input) => {550 Effect.succeed(
192 createInput = input;551 encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }),
193 return Effect.void;552 ),
553 rest: () =>
554 Effect.fail(
555 new GitHubApi.GitHubApiRequestError({
556 host: "github.com",
557 operation: "x",
558 cause: "offline",
559 }),
560 ),
194 },561 },
195 });562 });
563 return Effect.gen(function* () {
564 const gh = yield* GitHubSourceControlProvider.make;
565 yield* Effect.flip(gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true }));
566 // Nothing touched the local branches: `main` must not be reset to the fork's commit.
567 assert.deepStrictEqual(git, []);
568 }).pipe(Effect.provide(layer));
569 });
196570
197 yield* provider.createChangeRequest({571 it.effect("adds a remote for a fork and names a default-branch head after its owner", () => {
198 cwd: "/repo",572 const { layer, git } = harness({
199 baseRefName: "main",573 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
200 headSelector: "owner:feature/provider",574 localBranches: ["someone/main"],
201 title: "Provider PR",575 api: {
202 bodyFile: "/tmp/body.md",576 graphql: () =>
577 Effect.succeed(
578 encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }),
579 ),
580 rest: (input) =>
581 Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")),
582 },
203 });583 });
584 return Effect.gen(function* () {
585 const gh = yield* GitHubSourceControlProvider.make;
586 yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true });
587 assert.deepStrictEqual(git, [
588 [
589 "ensureRemote",
590 { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" },
591 ],
592 [
593 "fetchRemoteTrackingBranch",
594 { cwd: "/repo", remoteName: "someone", remoteBranch: "main" },
595 ],
596 ["switchRef", { cwd: "/repo", refName: "someone/main" }],
597 ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]],
598 [
599 "setBranchUpstream",
600 { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" },
601 ],
602 ]);
603 }).pipe(Effect.provide(layer));
604 });
204605
205 assert.deepStrictEqual(createInput, {606 it.effect("keeps git's own output out of what a failed checkout reports", () => {
206 cwd: "/repo",607 const { layer } = harness({
207 baseBranch: "main",608 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
208 headSelector: "owner:feature/provider",609 gitFailure: new Error("fatal: /home/me/secret-path: permission denied"),
209 title: "Provider PR",610 api: {
210 bodyFile: "/tmp/body.md",611 graphql: () =>
612 Effect.succeed(
613 encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }),
614 ),
615 },
211 });616 });
212 }),617 return Effect.gen(function* () {
213);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
215it("accepts active authenticated GitHub accounts when another account fails", () => {641it("accepts active authenticated GitHub accounts when another account fails", () => {
216 const auth = GitHubSourceControlProvider.discovery.parseAuth(642 const auth = GitHubSourceControlProvider.discovery.parseAuth(
...@@ -409,20 +835,15 @@ it.effect.each(["pull", "issues"])(...@@ -409,20 +835,15 @@ it.effect.each(["pull", "issues"])(
409 "resolves %s subjects on the linked host without using the checkout",835 "resolves %s subjects on the linked host without using the checkout",
410 (kind) =>836 (kind) =>
411 Effect.gen(function* () {837 Effect.gen(function* () {
412 const provider = yield* makeProvider(838 const provider = yield* makeProvider({
413 {},839 rest: (input) => {
414 {840 assert.strictEqual(input.host, "github.com");
415 rest: (input) => {841 assert.strictEqual(input.path, "repos/owner/repo/issues/42");
416 assert.strictEqual(input.host, "github.com");842 return Effect.succeed(
417 assert.strictEqual(input.path, "repos/owner/repo/issues/42");843 restResponse({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }),
418 return Effect.succeed(844 );
419 restResponse(
420 encodeJson({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }),
421 ),
422 );
423 },
424 },845 },
425 );846 });
426 const lookup = provider.resolveLink?.({847 const lookup = provider.resolveLink?.({
427 cwd: "/unrelated",848 cwd: "/unrelated",
428 url: new URL(`https://github.com/owner/repo/${kind}/42`),849 url: new URL(`https://github.com/owner/repo/${kind}/42`),
...@@ -451,15 +872,12 @@ it.effect.each(["read", "decode"] as const)(...@@ -451,15 +872,12 @@ it.effect.each(["read", "decode"] as const)(
451 operation: "resolveLink",872 operation: "resolveLink",
452 status: 500,873 status: 500,
453 });874 });
454 const provider = yield* makeProvider(875 const provider = yield* makeProvider({
455 {},876 rest: () =>
456 {877 stage === "read"
457 rest: () =>878 ? Effect.fail(cause)
458 stage === "read"879 : Effect.succeed({ ...restResponse(undefined), body: "private response text" }),
459 ? Effect.fail(cause)880 });
460 : Effect.succeed(restResponse("private response text")),
461 },
462 );
463 const lookup = provider.resolveLink?.({881 const lookup = provider.resolveLink?.({
464 cwd: "/repo",882 cwd: "/repo",
465 url: new URL("https://github.com/owner/repo/issues/42"),883 url: new URL("https://github.com/owner/repo/issues/42"),
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+716-195
...@@ -1,21 +1,42 @@...@@ -1,21 +1,42 @@
1import * as Schema from "effect/Schema";1import * as Context from "effect/Context";
2import * as DateTime from "effect/DateTime";2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
4import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Request from "effect/Request";
8import * as RequestResolver from "effect/RequestResolver";
5import * as Result from "effect/Result";9import * as Result from "effect/Result";
10import * as Schema from "effect/Schema";
6import {11import {
7 DEFAULT_SERVER_SETTINGS,12 DEFAULT_SERVER_SETTINGS,
8 SourceControlProviderError,13 SourceControlProviderError,
14 TrimmedNonEmptyString,
9 type ChangeRequest,15 type ChangeRequest,
10 type GitHubSettings,16 type GitHubSettings,
11 type SourceControlProviderDiscoveryItem,17 type SourceControlProviderDiscoveryItem,
18 type SourceControlRepositoryCloneUrls,
12} from "@t3tools/contracts";19} from "@t3tools/contracts";
1320import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
14import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";21import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
22import { decodeJsonResult } from "@t3tools/shared/schemaJson";
23import { isSshRemoteUrl } from "@t3tools/shared/sourceControl";
1524
16import * as ServerSettings from "../serverSettings.ts";25import * as ServerSettings from "../serverSettings.ts";
26import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
17import * as GitHubApi from "./GitHubApi.ts";27import * 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";
19import {40import {
20 effectiveGitHubAccount,41 effectiveGitHubAccount,
21 findAuthenticatedGitHubAccount,42 findAuthenticatedGitHubAccount,
...@@ -40,33 +61,8 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(...@@ -40,33 +61,8 @@ const decodeLinkSubject = Schema.decodeUnknownEffect(
40 ),61 ),
41);62);
4263
43function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest {64function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest {
44 return {65 return { provider: "github", ...record };
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 };
70}66}
7167
72function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) {68function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) {
...@@ -263,77 +259,601 @@ export const makeDiscovery = Effect.gen(function* () {...@@ -263,77 +259,601 @@ export const makeDiscovery = Effect.gen(function* () {
263 } satisfies SourceControlManagedCliDiscoverySpec;259 } satisfies SourceControlManagedCliDiscoverySpec;
264});260});
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
266export const make = Effect.gen(function* () {454export const make = Effect.gen(function* () {
267 const github = yield* GitHubRepositoryApi.GitHubRepositoryApi;
268 const api = yield* GitHubApi.GitHubApi;455 const api = yield* GitHubApi.GitHubApi;
456 const process = yield* VcsProcess.VcsProcess;
457 const environment = yield* HostProcessEnvironment;
458 const git = yield* GitVcsDriver.GitVcsDriver;
459 const fileSystem = yield* FileSystem.FileSystem;
460
461 const gitRead = (cwd: string, args: ReadonlyArray<string>) =>
462 process.run({
463 operation: "GitHubSourceControlProvider.resolveRepository",
464 command: "git",
465 args,
466 cwd,
467 allowNonZeroExit: true,
468 timeoutMs: 5_000,
469 });
470
471 /**
472 * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else
473 * the remote the caller resolved the provider from, else the best-ranked GitHub remote.
474 */
475 const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")(
476 function* (input: { readonly cwd: string; readonly host?: string | undefined }) {
477 const envRepository = environment.GH_REPO?.trim();
478 const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase();
479 if (envRepository) {
480 const locator = parseGitHubRepositorySelector(envRepository, defaultHost);
481 if (locator !== null) return locator;
482 }
483 const [remotes, resolved] = yield* Effect.all([
484 gitRead(input.cwd, ["remote", "-v"]),
485 gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]),
486 ]).pipe(Effect.orElseSucceed(() => [null, null] as const));
487 const { host, locator } = resolveGitHubRepository({
488 remotes: remotes?.exitCode === 0 ? remotes.stdout : "",
489 resolved: resolved?.exitCode === 0 ? resolved.stdout : "",
490 hostHint: input.host,
491 defaultHost,
492 });
493 if (locator !== null) return locator;
494 return yield* failure(
495 `No GitHub repository on ${host} was found among this checkout's git remotes.`,
496 );
497 },
498 );
499
500 const graphqlJson = <A>(
501 input: GitHubApi.GitHubGraphQlInput,
502 decode: (raw: string) => Result.Result<A, unknown>,
503 decodeDetail: string,
504 ) =>
505 api.graphql(input).pipe(
506 Effect.mapError(fromGitHubApiError),
507 Effect.flatMap((raw) => {
508 const decoded = decode(raw);
509 return Result.isSuccess(decoded)
510 ? Effect.succeed(decoded.success)
511 : Effect.fail(failure(decodeDetail, decoded.failure));
512 }),
513 );
514
515 const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) =>
516 api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail)));
517
518 const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({
519 key: ({ request, context }) =>
520 [
521 request.host,
522 request.owner,
523 request.name,
524 String(request.allowReserve),
525 Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
526 ?.credentialFingerprint ?? "",
527 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
528 ].join("\0"),
529 resolver: (entries) => {
530 const [first] = entries;
531 const { host, owner, name, allowReserve } = first.request;
532 const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request));
533 return graphqlJson(
534 {
535 host,
536 operation: "listPullRequestsByHead",
537 query: query.document,
538 variables: { owner, name, ...query.variables },
539 allowReserve,
540 // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred
541 // fork pull requests, so the usual cap would cut the answer short.
542 maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES,
543 },
544 decodePullRequestsByHead,
545 "GitHub returned an invalid change request list.",
546 ).pipe(
547 Effect.flatMap((decoded) =>
548 decoded.data.repository === null
549 ? Effect.fail(notFound("The repository could not be read."))
550 : Effect.succeed(decoded.data.repository),
551 ),
552 Effect.map((aliases) => {
553 for (const [index, entry] of entries.entries()) {
554 const alias = aliases[`h${index}`];
555 entry.completeUnsafe(
556 Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)),
557 );
558 }
559 }),
560 Effect.catchCause((cause) =>
561 Effect.sync(() => {
562 for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause));
563 }),
564 ),
565 );
566 },
567 }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT));
568 const interactiveHeadResolver = headResolver.pipe(
569 RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW),
570 );
571 const backgroundHeadResolver = headResolver.pipe(
572 RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT),
573 RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW),
574 );
575
576 /**
577 * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the
578 * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive together
579 * share one GraphQL document.
580 */
581 const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: {
582 readonly cwd: string;
583 readonly headSelector: string;
584 readonly state: PullRequestListState;
585 readonly limit: number;
586 readonly host?: string | undefined;
587 readonly allowReserve: boolean;
588 }) {
589 const locator = yield* resolveRepository({ cwd: input.cwd, host: input.host });
590 const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100);
591 // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the
592 // owner is matched on the rows it returns.
593 const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());
594 const headRefName = ownerMatch?.[2] ?? input.headSelector.trim();
595 const rows = yield* Effect.request(
596 new PullRequestsByHeadRead({
597 ...locator,
598 headRefName,
599 state: input.state,
600 limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit,
601 allowReserve: input.allowReserve,
602 }),
603 input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver,
604 );
605 if (!ownerMatch) return rows;
606 const headOwner = ownerMatch[1]!.toLowerCase();
607 return rows
608 .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner)
609 .slice(0, limit);
610 });
269611
270 const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] =612 const readPullRequest = Effect.fn("GitHubSourceControlProvider.readPullRequest")(
271 (input) => {613 function* (input: {
272 if (input.state === "open") {614 readonly cwd: string;
273 return github615 readonly reference: string;
274 .listOpenPullRequests({616 readonly host?: string | undefined;
617 }) {
618 const parsed = parsePullRequestReference(input.reference);
619 if (parsed.kind === "branch") {
620 // `gh pr view <branch>` prefers an open pull request, then the newest of any state.
621 const lookup = (state: PullRequestListState) =>
622 listByHead({
275 cwd: input.cwd,623 cwd: input.cwd,
276 headSelector: input.headSelector,624 headSelector: parsed.headSelector,
277 ...(input.context === undefined625 state,
278 ? {}626 limit: 1,
279 : { rateLimitHost: new URL(input.context.provider.baseUrl).host }),627 host: input.host,
280 ...(input.limit !== undefined ? { limit: input.limit } : {}),628 allowReserve: true,
281 })629 });
282 .pipe(630 const [open] = yield* lookup("open");
283 Effect.map((items) => items.map(toChangeRequest)),631 const found = open ?? (yield* lookup("all"))[0];
284 Effect.mapError(632 if (found === undefined) return yield* notFound("No pull request has this head branch.");
285 (error) =>633 return found;
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 );
298 }634 }
635 const locator =
636 parsed.kind === "url"
637 ? parsed.locator
638 : yield* resolveRepository({ cwd: input.cwd, host: input.host });
639 const decodeDetail = "GitHub returned an invalid pull request.";
640 const decoded = yield* graphqlJson(
641 {
642 host: locator.host,
643 operation: "getPullRequest",
644 query: PULL_REQUEST_BY_NUMBER_QUERY,
645 variables: { owner: locator.owner, name: locator.name, number: parsed.number },
646 allowReserve: true,
647 },
648 decodePullRequestByNumber,
649 decodeDetail,
650 );
651 const node = decoded.data.repository?.pullRequest;
652 if (node == null) return yield* notFound("The pull request does not exist.");
653 const [record] = decodeGitHubPullRequestEntries([node]);
654 if (record === undefined)
655 return yield* failure(decodeDetail, new Error("Malformed pull request."));
656 return record;
657 },
658 );
659
660 const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* (
661 locator: GitHubRepositoryLocator,
662 ) {
663 const response = yield* rest(
664 {
665 host: locator.host,
666 operation: "getRepository",
667 path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`,
668 allowReserve: true,
669 },
670 REPOSITORY_NOT_FOUND,
671 );
672 const decoded = decodeRawRepository(response.body);
673 if (Result.isFailure(decoded)) {
674 return yield* failure("GitHub returned an invalid repository.", decoded.failure);
675 }
676 return decoded.success;
677 });
678
679 const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* (
680 host: string,
681 ) {
682 const response = yield* rest({ host, operation: "getViewer", path: "user" });
683 const decoded = decodeViewerLogin(response.body);
684 if (Result.isFailure(decoded)) {
685 return yield* failure("GitHub request failed.", decoded.failure);
686 }
687 return decoded.success.login;
688 });
689
690 // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client
691 // sees is fixed and the raw error stays in `cause`.
692 const gitFailure = (cause: unknown) =>
693 failure("The pull request could not be checked out with git.", cause);
694
695 const runGit = (cwd: string, operation: string, args: ReadonlyArray<string>) =>
696 git.execute({
697 operation: `GitHubSourceControlProvider.checkoutPullRequest.${operation}`,
698 cwd,
699 args,
700 });
701
702 /**
703 * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a
704 * fork gets a remote of its own), checked out under the name gh would give it, and set to
705 * track the head. A head branch that is gone is read from the base's `refs/pull/<n>/head`.
706 * An existing branch fast-forwards, or with `force` is reset to the pull request.
707 */
708 const checkoutPullRequest = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")(
709 function* (input: {
710 readonly cwd: string;
711 readonly reference: string;
712 readonly force?: boolean;
713 }) {
714 const reference = parsePullRequestReference(input.reference);
715 const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference });
716 const base =
717 reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd });
718 const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase();
719 const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null;
720 const isCrossRepository =
721 pullRequest.isCrossRepository ??
722 (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner);
723 const headOwner =
724 pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null;
725
726 const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe(
727 Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")),
728 Effect.mapError(gitFailure),
729 );
730 const remoteFor = (nameWithOwner: string) =>
731 parseFetchRemotes(remotes).find(
732 (remote) =>
733 normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") ===
734 nameWithOwner.toLowerCase(),
735 )?.name ?? null;
736 const baseRemote = Effect.suspend(() => {
737 const known = remoteFor(baseNameWithOwner);
738 return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known);
739 });
740 // A fork's branch named like the base's default branch is checked out under the owner's
741 // prefix. Without the default branch that collision cannot be ruled out, and the checkout
742 // would reset the local default branch to the fork's commit, so it fails instead.
743 const defaultBranch = isCrossRepository
744 ? yield* readRepository(base).pipe(
745 Effect.map((repository) => repository.default_branch ?? null),
746 )
747 : null;
748 const localBranch = pullRequestCheckoutBranchName({
749 headRefName: pullRequest.headRefName,
750 headOwner,
751 isCrossRepository,
752 defaultBranch,
753 });
299754
300 return github755 /** The remote the head branch lives on; a fork the checkout does not know yet is added. */
301 .listPullRequestsByHead({756 const headRemote = Effect.gen(function* () {
757 if (!isCrossRepository) return yield* baseRemote;
758 if (headNameWithOwner === null) return yield* failure("The fork is gone.");
759 const known = remoteFor(headNameWithOwner);
760 if (known !== null) return known;
761 const [owner, name] = headNameWithOwner.split("/");
762 const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! });
763 const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url");
764 return yield* git.ensureRemote({
302 cwd: input.cwd,765 cwd: input.cwd,
303 headSelector: input.headSelector,766 preferredName: headOwner ?? "fork",
304 state: input.state,767 url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url,
305 limit: input.limit ?? 20,768 });
306 ...(input.context === undefined769 });
307 ? {}770
308 : { rateLimitHost: new URL(input.context.provider.baseUrl).host }),771 const exists = (yield* git
309 })772 .listLocalBranchNames(input.cwd)
310 .pipe(773 .pipe(Effect.mapError(gitFailure))).includes(localBranch);
311 Effect.map((items) =>774
312 items.map(({ updatedAt, ...summary }) => ({775 // The commit to check out, fetched from the head branch where it still exists.
313 ...toChangeRequest({776 const target = yield* Effect.gen(function* () {
314 ...summary,777 const remoteName = yield* headRemote;
315 ...(Option.isSome(updatedAt)778 yield* git.fetchRemoteTrackingBranch({
316 ? { updatedAt: DateTime.formatIso(updatedAt.value) }779 cwd: input.cwd,
317 : {}),780 remoteName,
318 }),781 remoteBranch: pullRequest.headRefName,
319 updatedAt,782 });
320 })),783 return {
321 ),784 ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`,
322 Effect.mapError(785 upstream: { remoteName, remoteBranch: pullRequest.headRefName },
323 (error) =>786 };
324 new SourceControlProviderError({787 }).pipe(
325 provider: "github",788 Effect.catch(() =>
326 operation: "listChangeRequests",789 Effect.gen(function* () {
327 cwd: input.cwd,790 yield* runGit(input.cwd, "fetchPullRef", [
328 reference: SourceControlProvider.transportSafeSourceControlErrorValue(791 "fetch",
329 input.headSelector,792 "--quiet",
330 ),793 "--no-tags",
331 detail: error.message,794 yield* baseRemote,
332 cause: error,795 `refs/pull/${pullRequest.number}/head`,
333 }),796 ]);
334 ),797 const { commitSha } = yield* git.resolveCommit({
335 );798 cwd: input.cwd,
336 };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
338 const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* (858 const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* (
339 input: { readonly cwd: string; readonly url: URL },859 input: { readonly cwd: string; readonly url: URL },
...@@ -385,121 +905,122 @@ export const make = Effect.gen(function* () {...@@ -385,121 +905,122 @@ export const make = Effect.gen(function* () {
385 if (!match) return undefined;905 if (!match) return undefined;
386 return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`);906 return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`);
387 },907 },
388 listChangeRequests,908 listChangeRequests: (input) =>
909 // An open lookup is a user waiting on a status; the rest may be a background sweep.
910 (input.state === "open" ? Effect.succeed(true) : GitHubApi.AllowGitHubReserve).pipe(
911 Effect.flatMap((allowReserve) =>
912 listByHead({
913 cwd: input.cwd,
914 headSelector: input.headSelector,
915 state: input.state,
916 limit: input.limit ?? (input.state === "open" ? 1 : 20),
917 host: contextHost(input.context),
918 allowReserve,
919 }),
920 ),
921 Effect.map((records) => records.map(toChangeRequest)),
922 Effect.mapError(
923 providerError("listChangeRequests", input.cwd, { reference: input.headSelector }),
924 ),
925 ),
389 getChangeRequest: (input) =>926 getChangeRequest: (input) =>
390 github927 readPullRequest({ ...input, host: contextHost(input.context) }).pipe(
391 .getPullRequest({928 Effect.map(toChangeRequest),
392 ...input,929 Effect.mapError(
393 ...(input.context === undefined930 providerError("getChangeRequest", input.cwd, { reference: input.reference }),
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 ),
412 ),931 ),
932 ),
413 createChangeRequest: (input) =>933 createChangeRequest: (input) =>
414 github934 Effect.gen(function* () {
415 .createPullRequest({935 const locator = yield* resolveRepository({ cwd: input.cwd });
416 cwd: input.cwd,936 const body = yield* fileSystem
417 baseBranch: input.baseRefName,937 .readFileString(input.bodyFile)
418 headSelector: input.headSelector,938 .pipe(
419 title: input.title,939 Effect.mapError((cause) =>
420 bodyFile: input.bodyFile,940 failure("The pull request description could not be read.", cause),
421 })941 ),
422 .pipe(942 );
423 Effect.mapError(943 yield* rest({
424 (error) =>944 host: locator.host,
425 new SourceControlProviderError({945 operation: "createPullRequest",
426 provider: "github",946 method: "POST",
427 operation: "createChangeRequest",947 path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`,
428 cwd: input.cwd,948 // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`.
429 reference: SourceControlProvider.transportSafeSourceControlErrorValue(949 // gh allows maintainer edits unless told otherwise; the API's default is not documented.
430 input.headSelector,950 body: {
431 ),951 base: input.baseRefName,
432 detail: error.message,952 head: input.headSelector,
433 cause: error,953 title: input.title,
434 }),954 body,
435 ),955 maintainer_can_modify: true,
956 },
957 });
958 }).pipe(
959 Effect.mapError(
960 providerError("createChangeRequest", input.cwd, { reference: input.headSelector }),
436 ),961 ),
962 ),
437 getRepositoryCloneUrls: (input) =>963 getRepositoryCloneUrls: (input) =>
438 github.getRepositoryCloneUrls(input).pipe(964 Effect.gen(function* () {
965 const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe(
966 Effect.map((locator) => locator.host),
967 Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"),
968 )).toLowerCase();
969 const locator = parseGitHubRepositorySelector(input.repository, fallbackHost);
970 if (locator === null) return yield* failure("Repositories are named owner/name.");
971 return repositoryCloneUrls(yield* readRepository(locator));
972 }).pipe(
439 Effect.mapError(973 Effect.mapError(
440 (error) =>974 providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }),
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 }),
451 ),975 ),
452 ),976 ),
453 createRepository: (input) =>977 createRepository: (input) =>
454 github.createRepository(input).pipe(978 Effect.gen(function* () {
979 const locator = parseGitHubRepositorySelector(
980 input.repository,
981 (environment.GH_HOST ?? "github.com").toLowerCase(),
982 );
983 const viewer = locator === null ? null : yield* readViewerLogin(locator.host);
984 const owner = locator?.owner ?? viewer;
985 const name = locator?.name ?? input.repository.trim();
986 const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com");
987 const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase();
988 const response = yield* rest(
989 {
990 host,
991 operation: "createRepository",
992 method: "POST",
993 path:
994 isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`,
995 body: { name, private: input.visibility === "private" },
996 },
997 // GitHub answers 404 for an organization the account cannot create repositories in.
998 `No organization named ${owner ?? "that"} that this account can create repositories in.`,
999 );
1000 const decoded = decodeRawRepository(response.body);
1001 if (Result.isFailure(decoded)) {
1002 return yield* failure("GitHub returned an invalid repository.", decoded.failure);
1003 }
1004 return repositoryCloneUrls(decoded.success);
1005 }).pipe(
455 Effect.mapError(1006 Effect.mapError(
456 (error) =>1007 providerError("createRepository", input.cwd, { repository: input.repository }),
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 }),
467 ),1008 ),
468 ),1009 ),
469 getDefaultBranch: (input) =>1010 getDefaultBranch: (input) =>
470 github1011 Effect.gen(function* () {
471 .getDefaultBranch({1012 const locator = yield* resolveRepository({
472 ...input,1013 cwd: input.cwd,
473 ...(input.context === undefined1014 host: contextHost(input.context),
474 ? {}1015 });
475 : { rateLimitHost: new URL(input.context.provider.baseUrl).host }),1016 const repository = yield* readRepository(locator);
476 })1017 const branch = repository.default_branch?.trim() ?? "";
477 .pipe(1018 return branch.length > 0 ? branch : null;
478 Effect.mapError(1019 }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))),
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 ),
489 checkoutChangeRequest: (input) =>1020 checkoutChangeRequest: (input) =>
490 github.checkoutPullRequest(input).pipe(1021 checkoutPullRequest(input).pipe(
491 Effect.mapError(1022 Effect.mapError(
492 (error) =>1023 providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }),
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 }),
503 ),1024 ),
504 ),1025 ),
505 });1026 });
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+2-2
...@@ -13,12 +13,12 @@ import { VcsProcessSpawnError } from "@t3tools/contracts";...@@ -13,12 +13,12 @@ import { VcsProcessSpawnError } from "@t3tools/contracts";
1313
14import * as ServerSettings from "../serverSettings.ts";14import * as ServerSettings from "../serverSettings.ts";
15import * as ServerConfig from "../config.ts";15import * as ServerConfig from "../config.ts";
16import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
16import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";17import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
17import * as VcsProcess from "../vcs/VcsProcess.ts";18import * as VcsProcess from "../vcs/VcsProcess.ts";
18import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";19import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
19import * as BitbucketApi from "./BitbucketApi.ts";20import * as BitbucketApi from "./BitbucketApi.ts";
20import * as GitHubApi from "./GitHubApi.ts";21import * as GitHubApi from "./GitHubApi.ts";
21import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
22import * as GitLabCli from "./GitLabCli.ts";22import * as GitLabCli from "./GitLabCli.ts";
23import * as ForgejoCli from "./ForgejoCli.ts";23import * as ForgejoCli from "./ForgejoCli.ts";
24import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";24import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
...@@ -40,8 +40,8 @@ const layerSourceControlProviderRegistryTest = (input: {...@@ -40,8 +40,8 @@ const layerSourceControlProviderRegistryTest = (input: {
40 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),40 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
41 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),41 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
42 ServerSettings.ServerSettingsService.layerTest(),42 ServerSettings.ServerSettingsService.layerTest(),
43 Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}),
44 Layer.mock(GitHubApi.GitHubApi)({}),43 Layer.mock(GitHubApi.GitHubApi)({}),
44 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
45 Layer.mock(GitLabCli.GitLabCli)({}),45 Layer.mock(GitLabCli.GitLabCli)({}),
46 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),46 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
47 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),47 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+2-3
...@@ -14,8 +14,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";...@@ -14,8 +14,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
14import * as VcsProcess from "../vcs/VcsProcess.ts";14import * as VcsProcess from "../vcs/VcsProcess.ts";
15import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";15import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
16import * as BitbucketApi from "./BitbucketApi.ts";16import * as BitbucketApi from "./BitbucketApi.ts";
17import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
17import * as GitHubApi from "./GitHubApi.ts";18import * as GitHubApi from "./GitHubApi.ts";
18import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts";
19import * as GitLabCli from "./GitLabCli.ts";19import * as GitLabCli from "./GitLabCli.ts";
20import * as ForgejoCli from "./ForgejoCli.ts";20import * as ForgejoCli from "./ForgejoCli.ts";
21import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";21import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
...@@ -42,7 +42,6 @@ function makeRegistry(input: {...@@ -42,7 +42,6 @@ function makeRegistry(input: {
42 readonly url: string;42 readonly url: string;
43 }>;43 }>;
44 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;44 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;
45 readonly github?: Partial<GitHubRepositoryApi.GitHubRepositoryApi["Service"]>;
46 readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>;45 readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>;
47 readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>;46 readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>;
48 readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];47 readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];
...@@ -98,8 +97,8 @@ function makeRegistry(input: {...@@ -98,8 +97,8 @@ function makeRegistry(input: {
98 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),97 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
99 Layer.mock(BitbucketApi.BitbucketApi)({}),98 Layer.mock(BitbucketApi.BitbucketApi)({}),
100 ServerSettings.ServerSettingsService.layerTest(),99 ServerSettings.ServerSettingsService.layerTest(),
101 Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}),
102 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),100 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),
101 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
103 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),102 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
104 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),103 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
105 ServerConfig.layerTest(process.cwd(), {104 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....@@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.
229import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";229import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
230import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";230import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
231import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";231import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
232import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts";232import * as GitHubApi from "./sourceControl/GitHubApi.ts";
233import * as GitLabCli from "./sourceControl/GitLabCli.ts";233import * as GitLabCli from "./sourceControl/GitLabCli.ts";
234import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";234import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
235import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";235import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
...@@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap(...@@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap(
3190 Layer.mergeAll(3190 Layer.mergeAll(
3191 AzureDevOpsCli.layer,3191 AzureDevOpsCli.layer,
3192 BitbucketApi.layer,3192 BitbucketApi.layer,
3193 GitHubRepositoryApi.layer,3193 GitHubApi.layerWithDependencies,
3194 GitLabCli.layer,3194 GitLabCli.layer,
3195 ForgejoCli.layer,3195 ForgejoCli.layer,
3196 ),3196 ),