authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 13:14:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 13:14:13-07:00
log649418f01fcb4ae5ebbc07739e0bfee8fae3aaec
tree815149eaaca43ac17850ab696d344215a3d5413c
parentadc3c9327abedda6a36694e2344e620fe0d62ae4
signature Signed by PGP key B5690EEEBB952194

feat(server): source control, media and discovery use GitHub's API instead of gh (#16321)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

13 files changed, 1340 insertions(+), 1714 deletions(-)

apps/server/src/assets/AssetAccess.test.ts+15-13
...@@ -16,12 +16,12 @@ import * as DateTime from "effect/DateTime";...@@ -16,12 +16,12 @@ import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
17import * as FileSystem from "effect/FileSystem";17import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";18import * as Layer from "effect/Layer";
19import * as Redacted from "effect/Redacted";
19import * as Path from "effect/Path";20import * as Path from "effect/Path";
20import * as PlatformError from "effect/PlatformError";21import * as PlatformError from "effect/PlatformError";
21import * as Schema from "effect/Schema";22import * as Schema from "effect/Schema";
22import * as TestClock from "effect/testing/TestClock";23import * as TestClock from "effect/testing/TestClock";
23import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http";24import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http";
24import { ChildProcessSpawner } from "effect/process";
25import { vi } from "vite-plus/test";25import { vi } from "vite-plus/test";
2626
27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";27import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
...@@ -35,7 +35,7 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t...@@ -35,7 +35,7 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t
35import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";35import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
36import { openMediaFile } from "./MediaFile.ts";36import { openMediaFile } from "./MediaFile.ts";
37import { symlinksSupported } from "@t3tools/shared/testing/symlinks";37import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
38import * as GitHubCli from "../sourceControl/GitHubCli.ts";38import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
39import { githubMediaResponse } from "./GitHubMediaFetch.ts";39import { githubMediaResponse } from "./GitHubMediaFetch.ts";
4040
41vi.mock("node:fs/promises", async (importOriginal) => {41vi.mock("node:fs/promises", async (importOriginal) => {
...@@ -126,7 +126,7 @@ const layerTest = Layer.mergeAll(...@@ -126,7 +126,7 @@ const layerTest = Layer.mergeAll(
126).pipe(Layer.provideMerge(NodeServices.layer));126).pipe(Layer.provideMerge(NodeServices.layer));
127127
128describe("AssetAccess", () => {128describe("AssetAccess", () => {
129 it.effect("loads private media immediately after login and reuses the found credential", () => {129 it.effect("loads private media immediately after login with the GitHub credential", () => {
130 let lookups = 0;130 let lookups = 0;
131 const authorizations: Array<string | undefined> = [];131 const authorizations: Array<string | undefined> = [];
132 return Effect.gen(function* () {132 return Effect.gen(function* () {
...@@ -138,19 +138,21 @@ describe("AssetAccess", () => {...@@ -138,19 +138,21 @@ describe("AssetAccess", () => {
138 expect((yield* githubMediaResponse(asset, {})).status).toBe(404);138 expect((yield* githubMediaResponse(asset, {})).status).toBe(404);
139 expect((yield* githubMediaResponse(asset, {})).status).toBe(200);139 expect((yield* githubMediaResponse(asset, {})).status).toBe(200);
140 expect((yield* githubMediaResponse(asset, {})).status).toBe(200);140 expect((yield* githubMediaResponse(asset, {})).status).toBe(200);
141 expect(lookups).toBe(2);141 // Caching the token is GitHubCredentials' job; this asks it every time.
142 expect(lookups).toBe(3);
142 expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]);143 expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]);
143 }).pipe(144 }).pipe(
144 Effect.provide(145 Effect.provide(
145 Layer.mock(GitHubCli.GitHubCli)({146 Layer.mock(GitHubCredentials.GitHubCredentials)({
146 execute: () =>147 get: (host) =>
147 Effect.sync(() => ({148 ++lookups === 1
148 exitCode: ChildProcessSpawner.ExitCode(0),149 ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host }))
149 stdout: ++lookups === 1 ? "" : "signed-in",150 : Effect.succeed({
150 stderr: "",151 host,
151 stdoutTruncated: false,152 token: Redacted.make("signed-in"),
152 stderrTruncated: false,153 source: "gh" as const,
153 })),154 fingerprint: "fingerprint",
155 }),
154 }),156 }),
155 ),157 ),
156 Effect.provideService(158 Effect.provideService(
apps/server/src/assets/GitHubMediaFetch.ts+9-42
...@@ -12,7 +12,7 @@ import {...@@ -12,7 +12,7 @@ import {
12 type HttpClientResponse,12 type HttpClientResponse,
13} from "effect/http";13} from "effect/http";
1414
15import * as GitHubCli from "../sourceControl/GitHubCli.ts";15import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
1616
17/**17/**
18 * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned18 * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned
...@@ -37,8 +37,6 @@ const isCredentialedHost = (url: string) => {...@@ -37,8 +37,6 @@ const isCredentialedHost = (url: string) => {
37const MAX_REDIRECTS = 3;37const MAX_REDIRECTS = 3;
38/** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */38/** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */
39const MANUAL_REDIRECT: RequestInit = { redirect: "manual" };39const MANUAL_REDIRECT: RequestInit = { redirect: "manual" };
40const TOKEN_CACHE_TTL_MS = 5 * 60_000;
41const TOKEN_CACHE_MAX_ENTRIES = 32;
42/** Passed through so a seek in a long video costs one upstream range request, not a full download. */40/** Passed through so a seek in a long video costs one upstream range request, not a full download. */
43const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const;41const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const;
44const FORWARDED_RESPONSE_HEADERS = [42const FORWARDED_RESPONSE_HEADERS = [
...@@ -57,45 +55,14 @@ const SVG_CONTENT_TYPE = "image/svg+xml";...@@ -57,45 +55,14 @@ const SVG_CONTENT_TYPE = "image/svg+xml";
57const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";55const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
5856
59/**57/**
60 * A media request per image and one per video seek, each of which would otherwise spawn `gh`.58 * The github.com credential, or null without one: a public asset still loads, and a private one
61 * The token is what `gh auth token` would print again on the next call, and it is held no longer59 * fails the way it does in a browser that is not signed in.
62 * than a signed asset URL lives.
63 */60 */
64const tokenCache = new Map<string, { readonly at: number; readonly token: Redacted.Redacted }>();61const githubToken = GitHubCredentials.GitHubCredentials.pipe(
6562 Effect.flatMap((credentials) => credentials.get("github.com")),
66const githubToken = Effect.fn("GitHubMediaFetch.githubToken")(function* (input: {63 Effect.map((credential) => credential.token),
67 readonly cwd: string;64 Effect.orElseSucceed(() => null),
68 readonly host: string;65);
69}) {
70 // `gh` stores a token per host, not per repository, so the directory it runs in is not part
71 // of the answer and must not fragment the cache a client could otherwise churn. This route
72 // pins no credential; if it ever does, the pin belongs in this key.
73 const key = input.host;
74 const now = yield* Clock.currentTimeMillis;
75 const cached = tokenCache.get(key);
76 if (cached !== undefined && now - cached.at < TOKEN_CACHE_TTL_MS) return cached.token;
77 const github = yield* GitHubCli.GitHubCli;
78 // No credential is a normal state: a public asset still loads, and a private one fails the way
79 // it does in a browser that is not signed in.
80 const token = yield* github
81 .execute({
82 cwd: input.cwd,
83 args: ["auth", "token", "--hostname", input.host],
84 env: { GH_DEBUG: "" },
85 })
86 .pipe(
87 Effect.map((output) => output.stdout.trim()),
88 Effect.orElseSucceed(() => ""),
89 );
90 // A login or recovered CLI failure must take effect on the next media request.
91 if (token.length === 0) return null;
92 if (tokenCache.size >= TOKEN_CACHE_MAX_ENTRIES) {
93 tokenCache.delete(tokenCache.keys().next().value!);
94 }
95 const redacted = Redacted.make(token);
96 tokenCache.set(key, { at: now, token: redacted });
97 return redacted;
98});
9966
100/**67/**
101 * Follows GitHub's redirect to the signed object itself, and never carries the credential off68 * Follows GitHub's redirect to the signed object itself, and never carries the credential off
...@@ -146,7 +113,7 @@ export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaRespon...@@ -146,7 +113,7 @@ export const githubMediaResponse = Effect.fn("GitHubMediaFetch.githubMediaRespon
146 requestHeaders: Record<string, string | undefined>,113 requestHeaders: Record<string, string | undefined>,
147) {114) {
148 // Both media hosts are served by github.com's account, which is the host `gh` stores it under.115 // Both media hosts are served by github.com's account, which is the host `gh` stores it under.
149 const token = yield* githubToken({ cwd: asset.cwd, host: "github.com" });116 const token = yield* githubToken;
150 const forwarded: Record<string, string> = {};117 const forwarded: Record<string, string> = {};
151 for (const name of FORWARDED_REQUEST_HEADERS) {118 for (const name of FORWARDED_REQUEST_HEADERS) {
152 const value = requestHeaders[name];119 const value = requestHeaders[name];
apps/server/src/git/GitManager.test.ts+13-5
...@@ -36,6 +36,7 @@ import {...@@ -36,6 +36,7 @@ import {
36 ThreadId,36 ThreadId,
37} from "@t3tools/contracts";37} from "@t3tools/contracts";
38import * as DateTime from "effect/DateTime";38import * as DateTime from "effect/DateTime";
39import * as GitHubApi from "../sourceControl/GitHubApi.ts";
39import * as GitHubCli from "../sourceControl/GitHubCli.ts";40import * as GitHubCli from "../sourceControl/GitHubCli.ts";
40import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";41import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";
41import * as GitLabCli from "../sourceControl/GitLabCli.ts";42import * as GitLabCli from "../sourceControl/GitLabCli.ts";
...@@ -383,7 +384,11 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -383,7 +384,11 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
383 );384 );
384 const ghCalls: string[] = [];385 const ghCalls: string[] = [];
385386
386 const execute: GitHubCli.GitHubCli["Service"]["execute"] = (input) => {387 // The fake still speaks in gh's command shapes; the service methods below translate to them.
388 const execute = (input: {
389 readonly cwd: string;
390 readonly args: ReadonlyArray<string>;
391 }): Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCli.GitHubCliError> => {
387 const args = [...input.args];392 const args = [...input.args];
388 ghCalls.push(args.join(" "));393 ghCalls.push(args.join(" "));
389394
...@@ -518,7 +523,6 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {...@@ -518,7 +523,6 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
518523
519 return {524 return {
520 service: {525 service: {
521 execute,
522 // The fake answers the CLI shape, so batched lookups read it the way the fallback does.526 // The fake answers the CLI shape, so batched lookups read it the way the fallback does.
523 listPullRequestsByHead: (input) =>527 listPullRequestsByHead: (input) =>
524 execute({528 execute({
...@@ -724,7 +728,12 @@ function makeManager(input?: {...@@ -724,7 +728,12 @@ function makeManager(input?: {
724 discover: Effect.succeed([]),728 discover: Effect.succeed([]),
725 }),729 }),
726 ),730 ),
727 Effect.provide(Layer.succeed(GitHubCli.GitHubCli, gitHubCli)),731 Effect.provide(
732 Layer.merge(
733 Layer.succeed(GitHubCli.GitHubCli, gitHubCli),
734 Layer.mock(GitHubApi.GitHubApi)({}),
735 ),
736 ),
728 ),737 ),
729 );738 );
730739
...@@ -2660,8 +2669,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -2660,8 +2669,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
2660 provider: "github",2669 provider: "github",
2661 providerOperation: "listChangeRequests",2670 providerOperation: "listChangeRequests",
2662 providerCommand: "gh",2671 providerCommand: "gh",
2663 errorDetail:2672 errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.",
2664 "GitHub API rate limit exceeded. For the GraphQL quota and reset time, run `gh api graphql -f query='{rateLimit{remaining resetAt}}'`; `gh api rate_limit` reports REST.",
2665 });2673 });
2666 const loggedText = [2674 const loggedText = [
2667 warning?.message ?? "",2675 warning?.message ?? "",
apps/server/src/server.ts+2-1
...@@ -562,7 +562,8 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(...@@ -562,7 +562,8 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
562 Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),562 Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),
563 Layer.provideMerge(layerServerSettings),563 Layer.provideMerge(layerServerSettings),
564 // The asset route uses the registry's GitHub credential for private PR media.564 // The asset route uses the registry's GitHub credential for private PR media.
565 Layer.provideMerge(Layer.mergeAll(layerSourceControlProviderRegistry, GitHubCli.layer)),565 Layer.provideMerge(layerSourceControlProviderRegistry),
566 Layer.provideMerge(GitHubCli.layer),
566 Layer.provideMerge(layerGit),567 Layer.provideMerge(layerGit),
567 Layer.provideMerge(layerVcs),568 Layer.provideMerge(layerVcs),
568 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),569 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),
apps/server/src/sourceControl/GitHubApi.ts+2-1
...@@ -145,6 +145,7 @@ export interface GitHubGraphQlInput {...@@ -145,6 +145,7 @@ export interface GitHubGraphQlInput {
145 readonly query: string;145 readonly query: string;
146 readonly variables?: Readonly<Record<string, unknown>>;146 readonly variables?: Readonly<Record<string, unknown>>;
147 readonly allowReserve?: boolean;147 readonly allowReserve?: boolean;
148 readonly maxResponseBytes?: number;
148}149}
149150
150export class GitHubApi extends Context.Service<151export class GitHubApi extends Context.Service<
...@@ -567,7 +568,7 @@ export const make = Effect.gen(function* () {...@@ -567,7 +568,7 @@ export const make = Effect.gen(function* () {
567 HttpClientRequest.acceptJson,568 HttpClientRequest.acceptJson,
568 HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }),569 HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }),
569 ),570 ),
570 maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES,571 maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
571 allowReserve,572 allowReserve,
572 acceptNotModified: false,573 acceptNotModified: false,
573 graphql: true,574 graphql: true,
apps/server/src/sourceControl/GitHubCli.test.ts+431-831
...@@ -1,157 +1,109 @@...@@ -1,157 +1,109 @@
1import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";1import { assert, it, describe } from "@effect/vitest";
2import * as Cache from "effect/Cache";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as TestClock from "effect/testing/TestClock";3import * as TestClock from "effect/testing/TestClock";
4import * as Clock from "effect/Clock";
5import * as DateTime from "effect/DateTime";
6import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
7import * as Fiber from "effect/Fiber";5import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
9import * as PlatformError from "effect/PlatformError";
10import * as Redacted from "effect/Redacted";
11import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
12import { ChildProcessSpawner } from "effect/process";9import { ChildProcessSpawner } from "effect/process";
13import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
1410
11import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
15import * as VcsProcess from "../vcs/VcsProcess.ts";12import * as VcsProcess from "../vcs/VcsProcess.ts";
13import * as GitHubApi from "./GitHubApi.ts";
16import * as GitHubCli from "./GitHubCli.ts";14import * as GitHubCli from "./GitHubCli.ts";
17import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";15import * as GitHubCredentials from "./GitHubCredentials.ts";
18import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
1916
20const encodeGitHubCliError = Schema.encodeEffect(Schema.fromJsonString(GitHubCli.GitHubCliError));17const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
2118
22const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({19const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({
23 exitCode: ChildProcessSpawner.ExitCode(0),20 exitCode: ChildProcessSpawner.ExitCode(exitCode),
24 stdout,21 stdout,
25 stderr: "",22 stderr: "",
26 stdoutTruncated: false,23 stdoutTruncated: false,
27 stderrTruncated: false,24 stderrTruncated: false,
28});25});
2926
30const quotaOutput = (remaining = 5000, resetAt = "2099-01-01T00:00:00Z") =>27const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) =>
31 processOutput(28 entries
32 JSON.stringify({ data: { rateLimit: { cost: 1, limit: 5000, remaining, resetAt } } }),29 .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
33 );30 .join("\n");
3431
35const isBudgetReading = (input: VcsProcess.VcsProcessInput) =>32const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({
36 input.args[0] === "api" &&33 status,
37 input.args[1] === "graphql" &&34 headers: {},
38 input.args.at(-1)?.includes("rateLimit");35 body: body === undefined ? "" : encodeJson(body),
3936 truncated: false,
40const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();37 invalidUtf8: false,
4138});
42// Budget readings are answered here, so `mockRun` sees only the commands under test.
43const layer = GitHubCli.layer.pipe(
44 Layer.provide(
45 Layer.mock(VcsProcess.VcsProcess)({
46 run: (input) => (isBudgetReading(input) ? Effect.succeed(quotaOutput()) : mockRun(input)),
47 }),
48 ),
49);
5039
51afterEach(() => {40const node = (number: number, headRefName: string, owner = "acme") => ({
52 mockRun.mockReset();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 },
53});51});
5452
55it.effect("reads the GraphQL budget once per window, preserves the reserve, and resumes", () =>53/**
56 Effect.gen(function* () {54 * A GitHubCli over a mocked GitHubApi, git driver and process. `remotes` is what
57 let readings = 0;55 * `git remote -v` prints; `git` records every driver call.
58 const commands: string[] = [];56 */
59 let remaining = 501;57function harness(input: {
60 let resetAt = DateTime.formatIso(58 readonly remotes: string;
61 DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000),59 readonly api: Partial<GitHubApi.GitHubApi["Service"]>;
62 );60 readonly localBranches?: ReadonlyArray<string>;
63 const gh = yield* GitHubCli.make.pipe(61}) {
64 Effect.provideService(VcsProcess.VcsProcess, {62 const git: Array<readonly [string, unknown]> = [];
65 run: (input) =>63 const record =
66 Effect.sync(() => {64 <A>(name: string, value: A) =>
67 if (isBudgetReading(input)) {65 (args: unknown) =>
68 readings++;66 Effect.sync(() => {
69 assert.strictEqual(input.args[3], "enterprise.test");67 git.push([name, args]);
70 return quotaOutput(remaining, resetAt);68 return value;
71 }
72 commands.push(input.args.slice(0, 2).join(" "));
73 return processOutput("[]");
74 }),
75 }),
76 );
77 const read = (command: string) =>
78 gh.execute({
79 cwd: "/repo",
80 args:
81 command === "repo"
82 ? ["repo", "view", "enterprise.test/acme/web", "--json", "name"]
83 : ["pr", command, "--repo=enterprise.test/acme/web", "--json", "number"],
84 });69 });
85 yield* read("list");70 const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({
86 const failure = yield* read("view").pipe(Effect.flip);71 execute: (args) =>
87 assert.strictEqual(failure._tag, "GitHubCliRateLimitError");72 Effect.sync(() => {
88 assert.deepStrictEqual(commands, ["pr list"]);73 git.push(["execute", args.args]);
89 yield* read("view").pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true));74 return processOutput("");
90 yield* gh.execute({ cwd: "/repo", args: ["pr", "merge", "1"] });
91 assert.deepStrictEqual(commands, ["pr list", "pr view", "pr merge"]);
92 // One reading covers the whole window.
93 assert.strictEqual(readings, 1);
94 yield* TestClock.adjust("1 minute");
95 remaining = 5000;
96 resetAt = DateTime.formatIso(DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000));
97 yield* Effect.all([read("list"), read("repo")], { concurrency: 2 });
98 assert.strictEqual(readings, 2);
99 assert.deepStrictEqual(commands.slice(3).toSorted(), ["pr list", "repo view"]);
100 }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
101);
102
103it.effect("reads the budget again at a near reset, and every ten minutes in a long window", () =>
104 Effect.gen(function* () {
105 let readings = 0;
106 const startedAt = yield* Clock.currentTimeMillis;
107 let resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000));
108 const gh = yield* GitHubCli.make.pipe(
109 Effect.provideService(VcsProcess.VcsProcess, {
110 run: (input) =>
111 Effect.sync(() => {
112 if (!isBudgetReading(input)) return processOutput("[]");
113 readings++;
114 return quotaOutput(5000, resetAt);
115 }),
116 }),75 }),
117 );76 resolvePrimaryRemoteName: () => Effect.succeed("origin"),
118 const read = gh.execute({ cwd: "/repo", args: ["pr", "list"] });77 readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"),
119 yield* read;78 ensureRemote: (args) =>
120 // The window resets ten seconds in, so the reading expires with it.79 Effect.sync(() => {
121 resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000 + 3_600_000));80 git.push(["ensureRemote", args]);
122 yield* TestClock.adjust("10 seconds");81 return args.preferredName;
123 yield* read;
124 assert.strictEqual(readings, 2);
125 yield* TestClock.adjust("9 minutes");
126 yield* read;
127 assert.strictEqual(readings, 2);
128 yield* TestClock.adjust("1 minute");
129 yield* read;
130 assert.strictEqual(readings, 3);
131 }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
132);
133
134it.effect("reads anyway when the budget reading fails", () =>
135 Effect.gen(function* () {
136 const gh = yield* GitHubCli.make.pipe(
137 Effect.provideService(VcsProcess.VcsProcess, {
138 run: (input) =>
139 isBudgetReading(input)
140 ? Effect.fail(
141 new VcsProcessSpawnError({
142 operation: "GitHubCli.execute",
143 command: "gh",
144 cwd: "/gone",
145 cause: new Error("ENOENT"),
146 }),
147 )
148 : Effect.succeed(processOutput("[]")),
149 }),82 }),
150 );83 fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args),
151 const result = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] });84 setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args),
152 assert.strictEqual(result.stdout, "[]");85 switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never,
153 }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),86 listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]),
154);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(GitHubCli.GitHubCli, GitHubCli.make).pipe(
96 Layer.provide(
97 Layer.mergeAll(
98 driver,
99 process,
100 Layer.mock(GitHubApi.GitHubApi)(input.api),
101 NodeServices.layer,
102 ),
103 ),
104 );
105 return { layer, git };
106}
155107
156describe("selectGitHubBaseRepository", () => {108describe("selectGitHubBaseRepository", () => {
157 const remotes = (...entries: ReadonlyArray<readonly [string, string]>) =>109 const remotes = (...entries: ReadonlyArray<readonly [string, string]>) =>
...@@ -223,50 +175,97 @@ describe("selectGitHubBaseRepository", () => {...@@ -223,50 +175,97 @@ describe("selectGitHubBaseRepository", () => {
223 });175 });
224});176});
225177
226describe("GitHubCli.listPullRequestsByHead", () => {178describe("GitHubCli repository resolution", () => {
227 const remoteOutput =179 it.effect("reads the repository gh would pick from the remotes", () => {
228 "origin\tgit@github.com:acme/web.git (fetch)\norigin\tgit@github.com:acme/web.git (push)\n";180 const paths: string[] = [];
229 const node = (number: number, headRefName: string) => ({181 const { layer } = harness({
230 number,182 remotes: remotesOutput(
231 title: `PR ${number}`,183 ["origin", "git@github.com:me/web.git"],
232 url: `https://github.com/acme/web/pull/${number}`,184 ["upstream", "https://github.com/acme/web.git"],
233 baseRefName: "main",185 ),
234 headRefName,186 api: {
235 state: "MERGED",187 rest: (input) =>
236 mergedAt: "2026-01-01T00:00:00Z",188 Effect.sync(() => {
237 updatedAt: "2026-01-02T00:00:00Z",189 paths.push(`${input.host} ${input.path}`);
238 headRepository: { name: "web", nameWithOwner: "acme/web" },190 return restResponse({
239 headRepositoryOwner: { login: "acme" },191 full_name: "acme/web",
192 html_url: "https://github.com/acme/web",
193 ssh_url: "git@github.com:acme/web.git",
194 default_branch: "trunk",
195 });
196 }),
197 },
198 });
199 return Effect.gen(function* () {
200 const gh = yield* GitHubCli.GitHubCli;
201 assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk");
202 assert.deepStrictEqual(paths, ["github.com repos/acme/web"]);
203 }).pipe(Effect.provide(layer));
240 });204 });
241 const decodeRequest = Schema.decodeSync(
242 Schema.fromJsonString(
243 Schema.Struct({
244 query: Schema.String,
245 variables: Schema.Record(Schema.String, Schema.Unknown),
246 }),
247 ),
248 );
249 const jsonOutput = (value: unknown) => processOutput(JSON.stringify(value));
250 const git = (input: VcsProcess.VcsProcessInput) =>
251 input.args[0] === "remote"
252 ? processOutput(remoteOutput)
253 : { ...processOutput(""), exitCode: ChildProcessSpawner.ExitCode(1) };
254205
255 it.effect("reads heads on one repository in one GraphQL document", () =>206 it.effect("reads an SSH alias remote through github.com", () => {
256 Effect.gen(function* () {207 const hosts: string[] = [];
257 const documents: Array<{ query: string; variables: Record<string, unknown> }> = [];208 const { layer } = harness({
258 mockRun.mockImplementation((input) =>209 remotes: remotesOutput(["origin", "git@github:acme/web.git"]),
259 Effect.sync(() => {210 api: {
260 if (input.command === "git") return git(input);211 rest: (input) =>
261 documents.push(decodeRequest(input.stdin ?? ""));212 Effect.sync(() => {
262 return jsonOutput({213 hosts.push(`${input.host} ${input.path}`);
263 data: {214 return restResponse({
264 repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } },215 full_name: "acme/web",
265 rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" },216 html_url: "https://github.com/acme/web",
266 },217 ssh_url: "git@github.com:acme/web.git",
267 });218 default_branch: "main",
268 }),219 });
220 }),
221 },
222 });
223 return Effect.gen(function* () {
224 const gh = yield* GitHubCli.GitHubCli;
225 yield* gh.getDefaultBranch({ cwd: "/repo" });
226 // A provider's host hint for the same alias (`github` here) resolves the same way.
227 yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" });
228 assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]);
229 assert.strictEqual(
230 GitHubCli.gitHubApiHostForRemote("git@github.example.com:a/b.git"),
231 "github.example.com",
269 );232 );
233 assert.strictEqual(GitHubCli.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null);
234 }).pipe(Effect.provide(layer));
235 });
236
237 it.effect("fails clearly when no remote is on GitHub", () => {
238 const { layer } = harness({
239 remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]),
240 api: {},
241 });
242 return Effect.gen(function* () {
243 const gh = yield* GitHubCli.GitHubCli;
244 const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip);
245 assert.strictEqual(error._tag, "GitHubCliCommandError");
246 assert.include(String((error.cause as Error).message), "No GitHub repository");
247 }).pipe(Effect.provide(layer));
248 });
249});
250
251describe("GitHubCli.listPullRequestsByHead", () => {
252 const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]);
253
254 it.effect("reads heads on one repository in one GraphQL document", () => {
255 const documents: Array<GitHubApi.GitHubGraphQlInput> = [];
256 const { layer } = harness({
257 remotes,
258 api: {
259 graphql: (input) =>
260 Effect.sync(() => {
261 documents.push(input);
262 return encodeJson({
263 data: { repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } } },
264 });
265 }),
266 },
267 });
268 return Effect.gen(function* () {
270 const gh = yield* GitHubCli.GitHubCli;269 const gh = yield* GitHubCli.GitHubCli;
271 const lookups = yield* Effect.all(270 const lookups = yield* Effect.all(
272 ["feature/a", "feature/b"].map((headSelector) =>271 ["feature/a", "feature/b"].map((headSelector) =>
...@@ -283,12 +282,11 @@ describe("GitHubCli.listPullRequestsByHead", () => {...@@ -283,12 +282,11 @@ describe("GitHubCli.listPullRequestsByHead", () => {
283 yield* TestClock.adjust("50 millis");282 yield* TestClock.adjust("50 millis");
284 const [first, second] = yield* Fiber.join(lookups);283 const [first, second] = yield* Fiber.join(lookups);
285 assert.deepStrictEqual(284 assert.deepStrictEqual(
286 first?.map((pr) => [pr.number, pr.state, pr.headRepositoryNameWithOwner]),285 first?.map((pr) => pr.number),
287 [[7, "merged", "acme/web"]],286 [7],
288 );287 );
289 assert.deepStrictEqual(second, []);288 assert.deepStrictEqual(second, []);
290 assert.strictEqual(documents.length, 1);289 assert.strictEqual(documents.length, 1);
291 assert.include(documents[0]!.query, "rateLimit");
292 assert.deepStrictEqual(documents[0]!.variables, {290 assert.deepStrictEqual(documents[0]!.variables, {
293 owner: "acme",291 owner: "acme",
294 name: "web",292 name: "web",
...@@ -297,701 +295,303 @@ describe("GitHubCli.listPullRequestsByHead", () => {...@@ -297,701 +295,303 @@ describe("GitHubCli.listPullRequestsByHead", () => {
297 h1: "feature/b",295 h1: "feature/b",
298 s1: ["OPEN", "CLOSED", "MERGED"],296 s1: ["OPEN", "CLOSED", "MERGED"],
299 });297 });
300 }).pipe(Effect.provide(layer)),298 }).pipe(Effect.provide(layer));
301 );299 });
302300
303 it.effect("asks gh pr list when gh could read another repository", () =>301 it.effect("matches an owner:branch selector on the head owner", () => {
304 Effect.gen(function* () {302 const { layer } = harness({
305 const commands: Array<ReadonlyArray<string>> = [];303 remotes,
306 mockRun.mockImplementation((input) =>304 api: {
307 Effect.sync(() => {305 graphql: (input) =>
308 commands.push([input.command, ...input.args]);306 Effect.succeed(
309 if (input.command === "git") {307 encodeJson({
310 return processOutput(308 data: {
311 input.args[0] === "remote"309 repository: {
312 ? "a\tgit@github.com:me/web.git (fetch)\nb\tgit@github.com:acme/web.git (fetch)\n"310 h0: {
313 : "",311 nodes:
314 );312 input.variables?.h0 === "main"
315 }313 ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")]
316 return input.args[3] === "feature/empty"314 : [],
317 ? processOutput("")315 },
318 : jsonOutput([node(8, "feature/a")]);316 },
319 }),317 },
320 );318 }),
319 ),
320 },
321 });
322 return Effect.gen(function* () {
321 const gh = yield* GitHubCli.GitHubCli;323 const gh = yield* GitHubCli.GitHubCli;
322 const pullRequests = yield* gh.listPullRequestsByHead({324 const open = yield* gh
323 cwd: "/repo",325 .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 })
324 headSelector: "feature/a",326 .pipe(Effect.forkChild);
325 state: "all",327 yield* TestClock.adjust("50 millis");
326 limit: 100,
327 rateLimitHost: "github.com",
328 });
329 assert.deepStrictEqual(328 assert.deepStrictEqual(
330 pullRequests.map((pr) => pr.number),329 (yield* Fiber.join(open)).map((pr) => pr.number),
331 [8],330 [8],
332 );331 );
333 assert.deepStrictEqual(commands.at(-1), [332 }).pipe(Effect.provide(layer));
334 "gh",333 });
335 "pr",
336 "list",
337 "--head",
338 "feature/a",
339 "--state",
340 "all",
341 "--limit",
342 "100",
343 "--json",
344 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
345 ]);
346 const empty = yield* gh.listPullRequestsByHead({
347 cwd: "/repo",
348 headSelector: "feature/empty",
349 state: "all",
350 limit: 100,
351 rateLimitHost: "github.com",
352 });
353 assert.deepStrictEqual(empty, []);
354 }).pipe(Effect.provide(layer)),
355 );
356334
357 it.effect("fails a rate-limited document whole instead of asking head by head", () =>335 it.effect("maps API failures onto the errors callers handle", () => {
358 Effect.gen(function* () {336 const { layer } = harness({
359 let ghCalls = 0;337 remotes,
360 mockRun.mockImplementation((input) => {338 api: {
361 if (input.command === "git") return Effect.succeed(git(input));339 graphql: (input) =>
362 ghCalls++;340 Effect.fail(
363 return Effect.fail(341 input.variables?.h0 === "missing"
364 new VcsProcessExitError({342 ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" })
365 operation: "GitHubCli.execute",343 : new GitHubApi.GitHubApiRateLimitError({
366 command: "gh",344 host: "github.com",
367 cwd: "/repo",345 operation: "x",
368 exitCode: 1,346 retryAt: 123,
369 failureKind: "rate-limited",347 }),
370 detail: "API rate limit exceeded.",348 ),
371 stderrLength: 24,349 },
372 stderrTruncated: false,350 });
373 }),351 return Effect.gen(function* () {
374 );
375 });
376 const gh = yield* GitHubCli.GitHubCli;352 const gh = yield* GitHubCli.GitHubCli;
377 const lookups = yield* Effect.all(353 const read = (headSelector: string) =>
378 ["feature/a", "feature/b"].map((headSelector) =>354 gh
379 gh355 .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 })
380 .listPullRequestsByHead({356 .pipe(Effect.flip, Effect.forkChild);
381 cwd: "/repo",357 const missing = yield* read("missing");
382 headSelector,
383 state: "all",
384 limit: 100,
385 rateLimitHost: "github.com",
386 })
387 .pipe(Effect.flip),
388 ),
389 { concurrency: "unbounded" },
390 ).pipe(Effect.forkChild);
391 yield* TestClock.adjust("50 millis");358 yield* TestClock.adjust("50 millis");
392 const errors = yield* Fiber.join(lookups);359 assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError");
393 assert.deepStrictEqual(360 const limited = yield* read("limited");
394 errors.map((error) => error._tag),361 yield* TestClock.adjust("50 millis");
395 ["GitHubCliRateLimitError", "GitHubCliRateLimitError"],362 const error = yield* Fiber.join(limited);
396 );
397 assert.strictEqual(ghCalls, 1);
398 }).pipe(Effect.provide(layer)),
399 );
400});
401
402describe("GitHubCli.layer", () => {
403 it.effect("shares the registry budget with CLI reads through nested layer providers", () =>
404 Effect.gen(function* () {
405 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
406 const gh = yield* GitHubCli.GitHubCli;
407 yield* budget.observe("github.com", quotaOutput(0).stdout);
408 const error = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] }).pipe(Effect.flip);
409 assert.strictEqual(error._tag, "GitHubCliRateLimitError");363 assert.strictEqual(error._tag, "GitHubCliRateLimitError");
410 expect(mockRun).not.toHaveBeenCalled();364 assert.propertyVal(error, "retryAt", 123);
411 }).pipe(Effect.provide(layer.pipe(Layer.provide(GitHubGraphQlBudget.layer)))),365 }).pipe(Effect.provide(layer));
412 );366 });
367});
413368
414 it.effect("keeps quota snapshots separate for verified credentials on the same host", () =>369describe("GitHubCli.getPullRequest", () => {
415 Effect.gen(function* () {370 it.effect("reads a pull request by number, and by URL on its own repository", () => {
416 let reads = 0;371 const variables: Array<unknown> = [];
417 const gh = yield* GitHubCli.make.pipe(372 const { layer } = harness({
418 Effect.provideService(VcsProcess.VcsProcess, {373 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
419 run: (input) =>374 api: {
420 Effect.sync(() => {375 graphql: (input) =>
421 if (isBudgetReading(input)) {376 Effect.sync(() => {
422 return quotaOutput(input.env?.GH_TOKEN === "empty" ? 0 : 5000);377 variables.push(input.variables);
423 }378 return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } });
424 reads++;
425 return processOutput("[]");
426 }),
427 }),
428 );
429 const read = (token: string) =>
430 gh.execute({ cwd: "/repo", args: ["pr", "list", "--repo", "github.com/acme/web"] }).pipe(
431 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
432 host: "github.com",
433 token: Redacted.make(token),
434 credentialFingerprint: token,
435 }),379 }),
436 );380 },
437 yield* read("empty").pipe(Effect.flip);381 });
438 yield* read("healthy");382 return Effect.gen(function* () {
439 yield* read("empty").pipe(Effect.flip);
440 assert.strictEqual(reads, 1);
441 }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
442 );
443
444 it.effect("pins concurrent cached commands to their own verified credentials", () =>
445 Effect.gen(function* () {
446 mockRun.mockImplementation((input) =>
447 Effect.succeed(processOutput(input.env?.GH_TOKEN ?? "ambient")),
448 );
449 const gh = yield* GitHubCli.GitHubCli;383 const gh = yield* GitHubCli.GitHubCli;
450 // Constructed outside either request, like the PR service's read caches.384 assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42);
451 const cache = yield* Cache.make({385 yield* gh.getPullRequest({
452 lookup: (host: string) =>386 cwd: "/repo",
453 gh.execute({387 reference: "https://github.com/other/thing/pull/42",
454 cwd: "/repo",
455 args: ["api", "user", "--hostname", host],
456 env: { GH_DEBUG: "api", GH_TOKEN: "changed-after-verification" },
457 }),
458 capacity: 2,
459 timeToLive: "1 minute",
460 });388 });
461 const results = yield* Effect.forEach(389 assert.deepStrictEqual(variables, [
462 ["github.com", "github.example.test"],390 { owner: "acme", name: "web", number: 42 },
463 (host, index) =>391 { owner: "other", name: "thing", number: 42 },
464 Cache.get(cache, host).pipe(392 ]);
465 Effect.provideService(GitHubCli.PinnedGitHubCredential, {393 }).pipe(Effect.provide(layer));
466 host,394 });
467 token: Redacted.make(`credential-${index}`),
468 credentialFingerprint: `fingerprint-${index}`,
469 }),
470 ),
471 { concurrency: 2 },
472 );
473 expect(results.map((result) => result.stdout)).toEqual(["credential-0", "credential-1"]);
474 for (const [input] of mockRun.mock.calls) {
475 expect(input.env).toMatchObject({
476 GH_HOST: input.args[3],
477 GH_DEBUG: "",
478 GH_TOKEN: input.env?.GITHUB_TOKEN,
479 GH_ENTERPRISE_TOKEN: input.env?.GH_TOKEN,
480 GITHUB_ENTERPRISE_TOKEN: input.env?.GH_TOKEN,
481 });
482 }
483 expect((yield* gh.execute({ cwd: "/repo", args: ["api", "user"] })).stdout).toBe("ambient");
484 }).pipe(Effect.provide(layer)),
485 );
486395
487 it.effect("refuses other or implicit hosts before exposing a scoped credential to gh", () =>396 it.effect("fails a missing pull request as not found", () => {
488 Effect.gen(function* () {397 const { layer } = harness({
398 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
399 api: {
400 graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })),
401 },
402 });
403 return Effect.gen(function* () {
489 const gh = yield* GitHubCli.GitHubCli;404 const gh = yield* GitHubCli.GitHubCli;
490 for (const args of [405 const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip);
491 ["api", "user", "--hostname", "other.example.test"],406 assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError");
492 ["api", "user", "--hostname=other.example.test"],407 }).pipe(Effect.provide(layer));
493 ["pr", "view", "1", "--repo", "other.example.test/owner/repo"],408 });
494 ["repo", "view", "other.example.test/owner/repo", "--json", "name"],409});
495 ["api", "https://other.example.test/user", "--hostname", "github.com"],
496 ["api", "user"],
497 ]) {
498 const failure = yield* gh.execute({ cwd: "/repo", args }).pipe(
499 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
500 host: "github.com",
501 token: Redacted.make("secret-credential"),
502 credentialFingerprint: "fingerprint",
503 }),
504 Effect.flip,
505 );
506 expect(failure._tag).toBe("GitHubCliCommandError");
507 expect(yield* encodeGitHubCliError(failure)).not.toContain("secret-credential");
508 }
509 expect(mockRun).not.toHaveBeenCalled();
510 }).pipe(Effect.provide(layer)),
511 );
512410
513 it.effect("pins repository-targeted writes on enterprise hosts", () =>411describe("GitHubCli writes", () => {
514 Effect.gen(function* () {412 it.effect("creates a cross-repository pull request with an owner:branch head", () => {
515 mockRun.mockReturnValue(Effect.succeed(processOutput("")));413 const requests: Array<GitHubApi.GitHubRestInput> = [];
516 const gh = yield* GitHubCli.GitHubCli;414 const { layer } = harness({
517 yield* gh415 remotes: remotesOutput(
518 .execute({416 ["origin", "git@github.com:me/web.git"],
519 cwd: "/repo",417 ["upstream", "git@github.com:acme/web.git"],
520 args: ["pr", "merge", "1", "--repo", "github.example.test/owner/repo"],418 ),
521 })419 api: {
522 .pipe(420 rest: (input) =>
523 Effect.provideService(GitHubCli.PinnedGitHubCredential, {421 Effect.sync(() => {
524 host: "github.example.test",422 requests.push(input);
525 token: Redacted.make("enterprise-credential"),423 return restResponse({ number: 1 }, 201);
526 credentialFingerprint: "fingerprint",
527 }),
528 );
529 yield* gh
530 .execute({
531 cwd: "/repo",
532 args: ["repo", "view", "github.example.test/owner/repo", "--json", "name"],
533 })
534 .pipe(
535 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
536 host: "github.example.test",
537 token: Redacted.make("enterprise-credential"),
538 credentialFingerprint: "fingerprint",
539 }),424 }),
540 );425 },
541 expect(mockRun.mock.calls[0]?.[0].env).toMatchObject({
542 GH_HOST: "github.example.test",
543 GH_ENTERPRISE_TOKEN: "enterprise-credential",
544 GH_DEBUG: "",
545 });
546 }).pipe(Effect.provide(layer)),
547 );
548
549 it("does not classify a missing cwd as an unavailable gh executable", () => {
550 const context = { command: "gh", cwd: "/repo" } as const;
551 const missingCwd = new VcsProcessSpawnError({
552 operation: "GitHubCli.execute",
553 command: "gh",
554 cwd: context.cwd,
555 cause: PlatformError.systemError({
556 _tag: "NotFound",
557 module: "FileSystem",
558 method: "access",
559 pathOrDescriptor: context.cwd,
560 }),
561 });426 });
562427 return Effect.gen(function* () {
563 const commandFailure = GitHubCli.fromVcsError(context, missingCwd);428 const fs = yield* FileSystem.FileSystem;
564429 const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" });
565 assert.equal(commandFailure._tag, "GitHubCliCommandError");430 yield* fs.writeFileString(bodyFile, "Body");
566 assert.strictEqual(commandFailure.cause, missingCwd);
567 assert.notProperty(commandFailure, "operation");
568 });
569
570 it.effect("parses pull request view output", () =>
571 Effect.gen(function* () {
572 mockRun.mockReturnValueOnce(
573 Effect.succeed(
574 processOutput(
575 // @effect-diagnostics-next-line preferSchemaOverJson:off
576 JSON.stringify({
577 number: 42,
578 title: "Add PR thread creation",
579 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
580 baseRefName: "main",
581 headRefName: "feature/pr-threads",
582 state: "OPEN",
583 isDraft: true,
584 mergedAt: null,
585 updatedAt: "2026-08-24T12:34:56Z",
586 isCrossRepository: true,
587 headRepository: {
588 nameWithOwner: "octocat/codething-mvp",
589 },
590 headRepositoryOwner: {
591 login: "octocat",
592 },
593 }),
594 ),
595 ),
596 );
597
598 const gh = yield* GitHubCli.GitHubCli;431 const gh = yield* GitHubCli.GitHubCli;
599 const result = yield* gh.getPullRequest({432 yield* gh.createPullRequest({
600 cwd: "/repo",433 cwd: "/repo",
601 reference: "#42",434 baseBranch: "main",
435 headSelector: "me:feature",
436 title: "Title",
437 bodyFile,
602 });438 });
603439 assert.strictEqual(requests[0]!.method, "POST");
604 assert.deepStrictEqual(result, {440 assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls");
605 number: 42,441 assert.deepStrictEqual(requests[0]!.body, {
606 title: "Add PR thread creation",442 base: "main",
607 url: "https://github.com/pingdotgg/codething-mvp/pull/42",443 head: "me:feature",
608 baseRefName: "main",444 title: "Title",
609 headRefName: "feature/pr-threads",445 body: "Body",
610 state: "open",446 maintainer_can_modify: true,
611 closedAt: null,
612 mergedAt: null,
613 isDraft: true,
614 updatedAt: "2026-08-24T12:34:56.000Z",
615 isCrossRepository: true,
616 headRepositoryNameWithOwner: "octocat/codething-mvp",
617 headRepositoryOwnerLogin: "octocat",
618 });
619 expect(mockRun).toHaveBeenCalledWith({
620 operation: "GitHubCli.execute",
621 command: "gh",
622 args: [
623 "pr",
624 "view",
625 "#42",
626 "--json",
627 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
628 ],
629 cwd: "/repo",
630 timeoutMs: 30_000,
631 });447 });
632 }).pipe(Effect.provide(layer)),448 }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped);
633 );449 });
634
635 it.effect("trims pull request fields decoded from gh json", () =>
636 Effect.gen(function* () {
637 mockRun.mockReturnValueOnce(
638 Effect.succeed(
639 processOutput(
640 // @effect-diagnostics-next-line preferSchemaOverJson:off
641 JSON.stringify({
642 number: 42,
643 title: " Add PR thread creation \n",
644 url: " https://github.com/pingdotgg/codething-mvp/pull/42 ",
645 baseRefName: " main ",
646 headRefName: "\tfeature/pr-threads\t",
647 state: "OPEN",
648 mergedAt: null,
649 isCrossRepository: true,
650 headRepository: {
651 nameWithOwner: " octocat/codething-mvp ",
652 },
653 headRepositoryOwner: {
654 login: " octocat ",
655 },
656 }),
657 ),
658 ),
659 );
660450
451 it.effect("creates a repository under an organization the viewer is not", () => {
452 const requests: Array<string> = [];
453 const { layer } = harness({
454 remotes: "",
455 api: {
456 rest: (input) =>
457 Effect.sync(() => {
458 requests.push(`${input.method ?? "GET"} ${input.path}`);
459 return input.path === "user"
460 ? restResponse({ login: "me" })
461 : restResponse({
462 full_name: "acme/new",
463 html_url: "https://github.com/acme/new",
464 ssh_url: "git@github.com:acme/new.git",
465 });
466 }),
467 },
468 });
469 return Effect.gen(function* () {
661 const gh = yield* GitHubCli.GitHubCli;470 const gh = yield* GitHubCli.GitHubCli;
662 const result = yield* gh.getPullRequest({471 const urls = yield* gh.createRepository({
663 cwd: "/repo",472 cwd: "/repo",
664 reference: "#42",473 repository: "acme/new",
474 visibility: "private",
665 });475 });
666476 assert.deepStrictEqual(urls, {
667 assert.deepStrictEqual(result, {477 nameWithOwner: "acme/new",
668 number: 42,478 url: "https://github.com/acme/new",
669 title: "Add PR thread creation",479 sshUrl: "git@github.com:acme/new.git",
670 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
671 baseRefName: "main",
672 headRefName: "feature/pr-threads",
673 state: "open",
674 closedAt: null,
675 mergedAt: null,
676 isCrossRepository: true,
677 headRepositoryNameWithOwner: "octocat/codething-mvp",
678 headRepositoryOwnerLogin: "octocat",
679 });
680 }).pipe(Effect.provide(layer)),
681 );
682
683 it.effect("skips invalid entries when parsing pr lists", () =>
684 Effect.gen(function* () {
685 mockRun.mockReturnValueOnce(
686 Effect.succeed(
687 processOutput(
688 // @effect-diagnostics-next-line preferSchemaOverJson:off
689 JSON.stringify([
690 {
691 number: 0,
692 title: "invalid",
693 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
694 baseRefName: "main",
695 headRefName: "feature/invalid",
696 },
697 {
698 number: 43,
699 title: " Valid PR ",
700 url: " https://github.com/pingdotgg/codething-mvp/pull/43 ",
701 baseRefName: " main ",
702 headRefName: " feature/pr-list ",
703 headRepository: {
704 nameWithOwner: " ",
705 },
706 headRepositoryOwner: {
707 login: " ",
708 },
709 },
710 ]),
711 ),
712 ),
713 );
714
715 const gh = yield* GitHubCli.GitHubCli;
716 const result = yield* gh.listOpenPullRequests({
717 cwd: "/repo",
718 headSelector: "feature/pr-list",
719 });480 });
481 assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]);
482 }).pipe(Effect.provide(layer));
483 });
484});
720485
721 assert.deepStrictEqual(result, [486describe("GitHubCli.checkoutPullRequest", () => {
722 {487 const repository = (fullName: string, defaultBranch = "main") =>
723 number: 43,488 restResponse({
724 title: "Valid PR",489 full_name: fullName,
725 url: "https://github.com/pingdotgg/codething-mvp/pull/43",490 html_url: `https://github.com/${fullName}`,
726 baseRefName: "main",491 ssh_url: `git@github.com:${fullName}.git`,
727 headRefName: "feature/pr-list",492 default_branch: defaultBranch,
728 state: "open",493 });
729 closedAt: null,
730 mergedAt: null,
731 },
732 ]);
733 }).pipe(Effect.provide(layer)),
734 );
735494
736 it.effect("keeps pull requests from gh versions without headRepository.nameWithOwner", () =>495 it.effect("checks a same-repository pull request out from its head branch", () => {
737 // gh < 2.47 (e.g. Ubuntu-packaged 2.46) exports headRepository as496 const { layer, git } = harness({
738 // {id, name} only. These entries must decode instead of being dropped,497 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
739 // with nameWithOwner rebuilt from the owner login.498 api: {
740 Effect.gen(function* () {499 graphql: () =>
741 mockRun.mockReturnValueOnce(500 Effect.succeed(
742 Effect.succeed(501 encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }),
743 processOutput(
744 // @effect-diagnostics-next-line preferSchemaOverJson:off
745 JSON.stringify([
746 {
747 number: 2829,
748 title: "Codex turn mapping",
749 url: "https://github.com/pingdotgg/codething-mvp/pull/2829",
750 baseRefName: "main",
751 headRefName: "t3code/codex-turn-mapping",
752 state: "OPEN",
753 mergedAt: null,
754 isCrossRepository: false,
755 headRepository: {
756 id: "R_kgDORLtfbQ",
757 name: "codething-mvp",
758 },
759 headRepositoryOwner: {
760 id: "MDEyOk9yZ2FuaXphdGlvbjg5MTkxNzI3",
761 login: "pingdotgg",
762 },
763 },
764 ]),
765 ),502 ),
766 ),503 },
767 );504 });
768505 return Effect.gen(function* () {
769 const gh = yield* GitHubCli.GitHubCli;506 const gh = yield* GitHubCli.GitHubCli;
770 const result = yield* gh.listOpenPullRequests({507 yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" });
771 cwd: "/repo",508 assert.deepStrictEqual(git, [
772 headSelector: "t3code/codex-turn-mapping",509 [
773 });510 "fetchRemoteTrackingBranch",
774511 { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" },
775 assert.deepStrictEqual(result, [512 ],
776 {513 ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]],
777 number: 2829,514 ["switchRef", { cwd: "/repo", refName: "feature/x" }],
778 title: "Codex turn mapping",515 [
779 url: "https://github.com/pingdotgg/codething-mvp/pull/2829",516 "setBranchUpstream",
780 baseRefName: "main",517 { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" },
781 headRefName: "t3code/codex-turn-mapping",518 ],
782 state: "open",
783 closedAt: null,
784 mergedAt: null,
785 isCrossRepository: false,
786 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
787 headRepositoryOwnerLogin: "pingdotgg",
788 },
789 ]);519 ]);
790 }).pipe(Effect.provide(layer)),520 }).pipe(Effect.provide(layer));
791 );521 });
792522
793 it.effect("reads repository clone URLs", () =>523 it.effect("refuses a fork checkout when the base's default branch cannot be read", () => {
794 Effect.gen(function* () {524 const { layer, git } = harness({
795 mockRun.mockReturnValueOnce(525 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
796 Effect.succeed(526 localBranches: ["main"],
797 processOutput(527 api: {
798 // @effect-diagnostics-next-line preferSchemaOverJson:off528 graphql: () =>
799 JSON.stringify({529 Effect.succeed(
800 nameWithOwner: "octocat/codething-mvp",530 encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }),
801 url: "https://github.com/octocat/codething-mvp",531 ),
802 sshUrl: "git@github.com:octocat/codething-mvp.git",532 rest: () =>
533 Effect.fail(
534 new GitHubApi.GitHubApiRequestError({
535 host: "github.com",
536 operation: "x",
537 cause: "offline",
803 }),538 }),
804 ),539 ),
805 ),540 },
806 );541 });
807542 return Effect.gen(function* () {
808 const gh = yield* GitHubCli.GitHubCli;543 const gh = yield* GitHubCli.GitHubCli;
809 const result = yield* gh.getRepositoryCloneUrls({544 yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }));
810 cwd: "/repo",545 // Nothing touched the local branches: `main` must not be reset to the fork's commit.
811 repository: "octocat/codething-mvp",546 assert.deepStrictEqual(git, []);
812 });547 }).pipe(Effect.provide(layer));
813548 });
814 assert.deepStrictEqual(result, {
815 nameWithOwner: "octocat/codething-mvp",
816 url: "https://github.com/octocat/codething-mvp",
817 sshUrl: "git@github.com:octocat/codething-mvp.git",
818 });
819 }).pipe(Effect.provide(layer)),
820 );
821549
822 it.effect("creates repositories and parses clone URLs from create output", () =>550 it.effect("adds a remote for a fork and names a default-branch head after its owner", () => {
823 Effect.gen(function* () {551 const { layer, git } = harness({
824 mockRun.mockReturnValueOnce(552 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
825 Effect.succeed(553 localBranches: ["someone/main"],
826 processOutput(554 api: {
827 "✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n",555 graphql: () =>
556 Effect.succeed(
557 encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }),
828 ),558 ),
829 ),559 rest: (input) =>
830 );560 Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")),
831561 },
832 const gh = yield* GitHubCli.GitHubCli;562 });
833 const result = yield* gh.createRepository({563 return Effect.gen(function* () {
834 cwd: "/repo",
835 repository: "octocat/codething-mvp",
836 visibility: "private",
837 });
838
839 assert.deepStrictEqual(result, {
840 nameWithOwner: "octocat/codething-mvp",
841 url: "https://github.com/octocat/codething-mvp",
842 sshUrl: "git@github.com:octocat/codething-mvp.git",
843 });
844 expect(mockRun).toHaveBeenCalledTimes(1);
845 expect(mockRun).toHaveBeenNthCalledWith(1, {
846 operation: "GitHubCli.execute",
847 command: "gh",
848 args: ["repo", "create", "octocat/codething-mvp", "--private"],
849 cwd: "/repo",
850 timeoutMs: 30_000,
851 });
852 }).pipe(Effect.provide(layer)),
853 );
854
855 it.effect("falls back to constructed URLs when create output omits a URL", () =>
856 Effect.gen(function* () {
857 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
858
859 const gh = yield* GitHubCli.GitHubCli;
860 const result = yield* gh.createRepository({
861 cwd: "/repo",
862 repository: "octocat/codething-mvp",
863 visibility: "private",
864 });
865
866 assert.deepStrictEqual(result, {
867 nameWithOwner: "octocat/codething-mvp",
868 url: "https://github.com/octocat/codething-mvp",
869 sshUrl: "git@github.com:octocat/codething-mvp.git",
870 });
871 }).pipe(Effect.provide(layer)),
872 );
873
874 it.effect("surfaces a friendly error when the pull request is not found", () =>
875 Effect.gen(function* () {
876 const cause = new VcsProcessExitError({
877 operation: "GitHubCli.execute",
878 command: "gh pr view",
879 cwd: "/repo",
880 exitCode: 1,
881 failureKind: "not-found",
882 detail:
883 "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)",
884 });
885 mockRun.mockReturnValueOnce(Effect.fail(cause));
886
887 const gh = yield* GitHubCli.GitHubCli;564 const gh = yield* GitHubCli.GitHubCli;
888 const error = yield* gh565 yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true });
889 .getPullRequest({566 assert.deepStrictEqual(git, [
890 cwd: "/repo",567 [
891 reference: "4888",568 "ensureRemote",
892 })569 { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" },
893 .pipe(Effect.flip);570 ],
894571 [
895 assert.equal(error.message.includes("Pull request not found"), true);572 "fetchRemoteTrackingBranch",
896 assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError");573 { cwd: "/repo", remoteName: "someone", remoteBranch: "main" },
897 assert.strictEqual(error.command, "gh");574 ],
898 assert.strictEqual(error.cwd, "/repo");575 ["switchRef", { cwd: "/repo", refName: "someone/main" }],
899 assert.strictEqual(error.cause, cause);576 ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]],
900 assert.equal(error.message.includes(cause.detail), false);577 [
901 }).pipe(Effect.provide(layer)),578 "setBranchUpstream",
902 );579 { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" },
580 ],
581 ]);
582 }).pipe(Effect.provide(layer));
583 });
903584
904 it.effect("surfaces an actionable rate-limit error without exposing provider stderr", () =>585 it("names the local branch the way gh pr checkout does", () => {
905 Effect.gen(function* () {586 const name = (headRefName: string, isCrossRepository: boolean) =>
906 const cause = new VcsProcessExitError({587 GitHubCli.pullRequestCheckoutBranchName({
907 operation: "GitHubCli.execute",588 headRefName,
908 command: "gh",589 headOwner: "someone",
909 cwd: "/repo",590 isCrossRepository,
910 exitCode: 1,591 defaultBranch: "main",
911 failureKind: "rate-limited",
912 detail: "API rate limit exceeded.",
913 stderrLength: 82,
914 stderrTruncated: false,
915 });592 });
916 mockRun.mockReturnValueOnce(Effect.fail(cause));593 assert.strictEqual(name("main", true), "someone/main");
917594 assert.strictEqual(name("feature", true), "feature");
918 const gh = yield* GitHubCli.GitHubCli;595 assert.strictEqual(name("main", false), "main");
919 const error = yield* gh596 });
920 .listOpenPullRequests({
921 cwd: "/repo",
922 headSelector: "feature/rate-limited",
923 })
924 .pipe(Effect.flip);
925
926 assert.strictEqual(error._tag, "GitHubCliRateLimitError");
927 assert.include(error.detail, "GitHub API rate limit exceeded");
928 assert.include(error.detail, "gh api rate_limit");
929 assert.strictEqual(error.cause, cause);
930 assert.notInclude(error.message, "user ID");
931 const paused = yield* gh
932 .execute({ cwd: "/other-repo", args: ["pr", "list"] })
933 .pipe(Effect.flip);
934 assert.strictEqual(paused._tag, "GitHubCliRateLimitError");
935 expect(mockRun).toHaveBeenCalledTimes(1);
936 yield* TestClock.adjust("30 seconds");
937 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("[]")));
938 yield* gh.execute({ cwd: "/other-repo", args: ["pr", "list"] });
939 expect(mockRun).toHaveBeenCalledTimes(2);
940 }).pipe(Effect.provide(layer)),
941 );
942});597});
943
944it.effect("accepts conditional 304 responses and preserves HTTP errors and retry delays", () =>
945 Effect.gen(function* () {
946 const gh = yield* GitHubCli.GitHubCli;
947 const request = {
948 cwd: "/repo",
949 args: [
950 "api",
951 "repos/acme/web/pulls/1",
952 "--hostname",
953 "github.com",
954 "--include",
955 "-H",
956 'If-None-Match: "one"',
957 ],
958 acceptNotModified: true,
959 };
960 const respond = (status: number, headers = "") =>
961 mockRun.mockImplementation(() =>
962 Effect.succeed({
963 ...processOutput(`HTTP/2.0 ${status}\r\n${headers}\r\n`),
964 exitCode: ChildProcessSpawner.ExitCode(1),
965 }),
966 );
967 respond(304);
968 expect((yield* gh.execute(request)).stdout).toContain("304");
969 expect(mockRun.mock.calls[0]?.[0].allowNonZeroExit).toBe(true);
970 respond(401);
971 expect((yield* gh.execute(request).pipe(Effect.flip))._tag).toBe(
972 "GitHubCliAuthenticationError",
973 );
974 respond(403);
975 expect((yield* gh.execute(request).pipe(Effect.flip))._tag).toBe("GitHubCliCommandError");
976 for (const status of [403, 429]) {
977 respond(status, "Retry-After: 120\r\n");
978 expect(yield* gh.execute(request).pipe(Effect.flip)).toMatchObject({
979 _tag: "GitHubCliRateLimitError",
980 retryAt: (yield* Clock.currentTimeMillis) + 120_000,
981 });
982 }
983 respond(
984 403,
985 `X-RateLimit-Remaining: 0\r\nX-RateLimit-Reset: ${Math.floor((yield* Clock.currentTimeMillis) / 1_000) + 60}\r\n`,
986 );
987 expect(yield* gh.execute(request).pipe(Effect.flip)).toMatchObject({
988 _tag: "GitHubCliRateLimitError",
989 retryAt: (yield* Clock.currentTimeMillis) + 60_000,
990 });
991 respond(500);
992 expect(yield* gh.execute(request).pipe(Effect.flip)).toMatchObject({
993 _tag: "GitHubCliCommandError",
994 httpStatus: 500,
995 });
996 }).pipe(Effect.provide(layer)),
997);
apps/server/src/sourceControl/GitHubCli.ts+716-723
...@@ -1,77 +1,38 @@...@@ -1,77 +1,38 @@
1import * as Cache from "effect/Cache";
2import * as Clock from "effect/Clock";
3import * as Duration from "effect/Duration";
4import * as Exit from "effect/Exit";
5import * as Context from "effect/Context";1import * as Context from "effect/Context";
6import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";7import * as Option from "effect/Option";
10import * as PlatformError from "effect/PlatformError";
11import * as Redacted from "effect/Redacted";
12import * as Request from "effect/Request";8import * as Request from "effect/Request";
13import * as RequestResolver from "effect/RequestResolver";9import * as RequestResolver from "effect/RequestResolver";
14import * as Result from "effect/Result";10import * as Result from "effect/Result";
15import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
1612
17import {13import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
18 TrimmedNonEmptyString,
19 type SourceControlRepositoryVisibility,
20 type VcsError,
21} from "@t3tools/contracts";
22import { normalizeGitRemoteUrl } from "@t3tools/shared/git";14import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
23import { decodeJsonResult } from "@t3tools/shared/schemaJson";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";
2423
25import * as VcsProcess from "../vcs/VcsProcess.ts";24import * as VcsProcess from "../vcs/VcsProcess.ts";
26import * as GitHubApi from "./GitHubApi.ts";25import * as GitHubApi from "./GitHubApi.ts";
26import * as GitHubCredentials from "./GitHubCredentials.ts";
27import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";27import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
28import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";28import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
29import {29import {
30 decodeGitHubPullRequestEntries,30 decodeGitHubPullRequestEntries,
31 decodeGitHubPullRequestJson,
32 decodeGitHubPullRequestListJson,
33 type NormalizedGitHubPullRequestRecord,31 type NormalizedGitHubPullRequestRecord,
34} from "./gitHubPullRequests.ts";32} from "./gitHubPullRequests.ts";
3533
36const DEFAULT_TIMEOUT_MS = 30_000;
37
38/** Server-local credential scope; never put its value in RPC payloads or cache keys. */
39export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential;
40
41export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve;34export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve;
4235
43function commandHosts(args: ReadonlyArray<string>): Array<string | null> {
44 const hosts: Array<string | null> = [];
45 const repositoryHost = (repository: string | undefined) => {
46 if (repository === undefined) return null;
47 if (/^https?:\/\//i.test(repository)) {
48 try {
49 return new URL(repository).host.toLowerCase();
50 } catch {
51 return null;
52 }
53 }
54 const parts = repository.split("/");
55 return parts.length === 3 ? parts[0]!.toLowerCase() : null;
56 };
57 if (args[0] === "repo" && args[1] === "view") hosts.push(repositoryHost(args[2]));
58 for (let index = 0; index < args.length; index++) {
59 const arg = args[index]!;
60 if (arg === "--hostname") hosts.push(args[++index]?.toLowerCase() ?? null);
61 else if (arg.startsWith("--hostname=")) hosts.push(arg.slice(11).toLowerCase());
62 else if (arg === "--repo" || arg === "-R") hosts.push(repositoryHost(args[++index]));
63 else if (arg.startsWith("--repo=")) hosts.push(repositoryHost(arg.slice(7)));
64 else if (arg.startsWith("-R")) hosts.push(repositoryHost(arg.slice(2)));
65 else if (/^https?:\/\//i.test(arg)) hosts.push(repositoryHost(arg));
66 }
67 return hosts;
68}
69
70function targetsVerifiedHost(args: ReadonlyArray<string>, host: string): boolean {
71 const hosts = commandHosts(args);
72 return hosts.length > 0 && hosts.every((target) => target === host);
73}
74
75const gitHubCliFailureFields = {36const gitHubCliFailureFields = {
76 command: Schema.Literal("gh"),37 command: Schema.Literal("gh"),
77 cwd: Schema.String,38 cwd: Schema.String,
...@@ -82,12 +43,8 @@ export class GitHubCliUnavailableError extends Schema.TaggedError<GitHubCliUnava...@@ -82,12 +43,8 @@ export class GitHubCliUnavailableError extends Schema.TaggedError<GitHubCliUnava
82 "GitHubCliUnavailableError",43 "GitHubCliUnavailableError",
83 gitHubCliFailureFields,44 gitHubCliFailureFields,
84) {45) {
85 get detail(): string {
86 return "GitHub CLI (`gh`) is required but not available on PATH.";
87 }
88
89 override get message(): string {46 override get message(): string {
90 return `GitHub CLI failed in execute: ${this.detail}`;47 return "GitHub CLI (`gh`) is required but not available on PATH. Install it and run `gh auth login`, or set GH_TOKEN.";
91 }48 }
92}49}
9350
...@@ -95,12 +52,8 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError<GitHubCliAu...@@ -95,12 +52,8 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError<GitHubCliAu
95 "GitHubCliAuthenticationError",52 "GitHubCliAuthenticationError",
96 gitHubCliFailureFields,53 gitHubCliFailureFields,
97) {54) {
98 get detail(): string {
99 return "GitHub CLI is not authenticated. Run `gh auth login` and retry.";
100 }
101
102 override get message(): string {55 override get message(): string {
103 return `GitHub CLI failed in execute: ${this.detail}`;56 return "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.";
104 }57 }
105}58}
10659
...@@ -108,12 +61,8 @@ export class GitHubCliRateLimitError extends Schema.TaggedError<GitHubCliRateLim...@@ -108,12 +61,8 @@ export class GitHubCliRateLimitError extends Schema.TaggedError<GitHubCliRateLim
108 "GitHubCliRateLimitError",61 "GitHubCliRateLimitError",
109 { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) },62 { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) },
110) {63) {
111 get detail(): string {
112 return "GitHub API rate limit exceeded. For the GraphQL quota and reset time, run `gh api graphql -f query='{rateLimit{remaining resetAt}}'`; `gh api rate_limit` reports REST.";
113 }
114
115 override get message(): string {64 override get message(): string {
116 return `GitHub CLI failed in execute: ${this.detail}`;65 return "GitHub API rate limit exceeded. Requests resume when the limit resets.";
117 }66 }
118}67}
11968
...@@ -121,12 +70,8 @@ export class GitHubPullRequestNotFoundError extends Schema.TaggedError<GitHubPul...@@ -121,12 +70,8 @@ export class GitHubPullRequestNotFoundError extends Schema.TaggedError<GitHubPul
121 "GitHubPullRequestNotFoundError",70 "GitHubPullRequestNotFoundError",
122 gitHubCliFailureFields,71 gitHubCliFailureFields,
123) {72) {
124 get detail(): string {
125 return "Pull request not found. Check the PR number or URL and try again.";
126 }
127
128 override get message(): string {73 override get message(): string {
129 return `GitHub CLI failed in execute: ${this.detail}`;74 return "Pull request not found. Check the PR number or URL and try again.";
130 }75 }
131}76}
13277
...@@ -134,12 +79,13 @@ export class GitHubCliCommandError extends Schema.TaggedError<GitHubCliCommandEr...@@ -134,12 +79,13 @@ export class GitHubCliCommandError extends Schema.TaggedError<GitHubCliCommandEr
134 "GitHubCliCommandError",79 "GitHubCliCommandError",
135 { ...gitHubCliFailureFields, httpStatus: Schema.optional(Schema.Int) },80 { ...gitHubCliFailureFields, httpStatus: Schema.optional(Schema.Int) },
136) {81) {
137 get detail(): string {
138 return "GitHub CLI command failed.";
139 }
140
141 override get message(): string {82 override get message(): string {
142 return `GitHub CLI failed in execute: ${this.detail}`;83 // GitHub's own reason ("A pull request already exists…") or the failed step's, when known.
84 const reason =
85 this.cause instanceof Error && this.cause.message.trim() !== ""
86 ? this.cause.message.trim()
87 : null;
88 return reason === null ? "GitHub request failed." : reason;
143 }89 }
144}90}
14591
...@@ -153,12 +99,8 @@ export class GitHubPullRequestListDecodeError extends Schema.TaggedError<GitHubP...@@ -153,12 +99,8 @@ export class GitHubPullRequestListDecodeError extends Schema.TaggedError<GitHubP
153 "GitHubPullRequestListDecodeError",99 "GitHubPullRequestListDecodeError",
154 gitHubCliDecodeFields,100 gitHubCliDecodeFields,
155) {101) {
156 get detail(): string {
157 return "GitHub CLI returned invalid PR list JSON.";
158 }
159
160 override get message(): string {102 override get message(): string {
161 return `GitHub CLI failed in listOpenPullRequests: ${this.detail}`;103 return "GitHub returned an invalid pull request list.";
162 }104 }
163}105}
164106
...@@ -166,12 +108,8 @@ export class GitHubChangeRequestListDecodeError extends Schema.TaggedError<GitHu...@@ -166,12 +108,8 @@ export class GitHubChangeRequestListDecodeError extends Schema.TaggedError<GitHu
166 "GitHubChangeRequestListDecodeError",108 "GitHubChangeRequestListDecodeError",
167 gitHubCliDecodeFields,109 gitHubCliDecodeFields,
168) {110) {
169 get detail(): string {
170 return "GitHub CLI returned invalid change request JSON.";
171 }
172
173 override get message(): string {111 override get message(): string {
174 return `GitHub CLI failed in listChangeRequests: ${this.detail}`;112 return "GitHub returned an invalid change request list.";
175 }113 }
176}114}
177115
...@@ -179,12 +117,8 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedError<GitHubPullR...@@ -179,12 +117,8 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedError<GitHubPullR
179 "GitHubPullRequestDecodeError",117 "GitHubPullRequestDecodeError",
180 gitHubCliDecodeFields,118 gitHubCliDecodeFields,
181) {119) {
182 get detail(): string {
183 return "GitHub CLI returned invalid pull request JSON.";
184 }
185
186 override get message(): string {120 override get message(): string {
187 return `GitHub CLI failed in getPullRequest: ${this.detail}`;121 return "GitHub returned an invalid pull request.";
188 }122 }
189}123}
190124
...@@ -192,12 +126,8 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubReposi...@@ -192,12 +126,8 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubReposi
192 "GitHubRepositoryDecodeError",126 "GitHubRepositoryDecodeError",
193 gitHubCliDecodeFields,127 gitHubCliDecodeFields,
194) {128) {
195 get detail(): string {
196 return "GitHub CLI returned invalid repository JSON.";
197 }
198
199 override get message(): string {129 override get message(): string {
200 return `GitHub CLI failed in getRepositoryCloneUrls: ${this.detail}`;130 return "GitHub returned an invalid repository.";
201 }131 }
202}132}
203133
...@@ -216,36 +146,31 @@ export type GitHubCliError = typeof GitHubCliError.Type;...@@ -216,36 +146,31 @@ export type GitHubCliError = typeof GitHubCliError.Type;
216146
217export const isGitHubCliError = Schema.is(GitHubCliError);147export const isGitHubCliError = Schema.is(GitHubCliError);
218148
219export function fromVcsError(149/** Maps a GitHub API failure onto the errors callers of this service already handle. */
220 context: {150function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHubCliError {
221 readonly command: "gh";151 const context = { command: "gh" as const, cwd, cause: error };
222 readonly cwd: string;152 switch (error._tag) {
223 },153 case "GitHubCliMissingError":
224 error: VcsError,154 return new GitHubCliUnavailableError(context);
225): GitHubCliError {155 case "GitHubNotSignedInError":
226 if (156 case "GitHubApiAuthenticationError":
227 error._tag === "VcsProcessSpawnError" &&157 return new GitHubCliAuthenticationError(context);
228 error.cause instanceof PlatformError.PlatformError &&158 case "GitHubCliFailedError":
229 error.cause.reason._tag === "NotFound" &&159 return new GitHubCliCommandError(context);
230 error.cause.reason.module === "ChildProcess" &&160 case "GitHubApiRateLimitError":
231 error.cause.reason.method === "spawn"161 return new GitHubCliRateLimitError({
232 ) {162 ...context,
233 return new GitHubCliUnavailableError({ ...context, cause: error });163 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
234 }164 });
235165 case "SourceControlRateLimitPausedError":
236 if (error._tag === "VcsProcessExitError") {166 return new GitHubCliRateLimitError({ ...context, retryAt: error.retryAt });
237 if (error.failureKind === "authentication") {167 case "GitHubApiNotFoundError":
238 return new GitHubCliAuthenticationError({ ...context, cause: error });168 return new GitHubPullRequestNotFoundError(context);
239 }169 case "GitHubApiResponseError":
240 if (error.failureKind === "rate-limited") {170 return new GitHubCliCommandError({ ...context, httpStatus: error.status });
241 return new GitHubCliRateLimitError({ ...context, cause: error });171 case "GitHubApiRequestError":
242 }172 return new GitHubCliCommandError(context);
243 if (error.failureKind === "not-found") {
244 return new GitHubPullRequestNotFoundError({ ...context, cause: error });
245 }
246 }173 }
247
248 return new GitHubCliCommandError({ ...context, cause: error });
249}174}
250175
251export interface GitHubPullRequestSummary {176export interface GitHubPullRequestSummary {
...@@ -281,19 +206,6 @@ export interface GitHubRepositoryCloneUrls {...@@ -281,19 +206,6 @@ export interface GitHubRepositoryCloneUrls {
281export class GitHubCli extends Context.Service<206export class GitHubCli extends Context.Service<
282 GitHubCli,207 GitHubCli,
283 {208 {
284 readonly execute: (input: {
285 readonly cwd: string;
286 readonly args: ReadonlyArray<string>;
287 readonly timeoutMs?: number;
288 /** Piped to the child's stdin, for payloads that must never appear in argv. */
289 readonly stdin?: string;
290 readonly env?: NodeJS.ProcessEnv;
291 readonly maxOutputBytes?: number;
292 readonly rateLimitHost?: string;
293 readonly allowReserve?: boolean;
294 readonly acceptNotModified?: boolean;
295 }) => Effect.Effect<VcsProcess.VcsProcessOutput, GitHubCliError>;
296
297 readonly listOpenPullRequests: (input: {209 readonly listOpenPullRequests: (input: {
298 readonly cwd: string;210 readonly cwd: string;
299 readonly headSelector: string;211 readonly headSelector: string;
...@@ -302,16 +214,16 @@ export class GitHubCli extends Context.Service<...@@ -302,16 +214,16 @@ export class GitHubCli extends Context.Service<
302 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;214 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
303215
304 /**216 /**
305 * Pull requests whose head is `headSelector`, in the repository `gh pr list` would read in217 * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the
306 * `cwd`. Lookups on one repository that arrive together share one GraphQL document; a218 * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive
307 * checkout whose repository gh could pick another way is asked through `gh pr list`.219 * together share one GraphQL document.
308 */220 */
309 readonly listPullRequestsByHead: (input: {221 readonly listPullRequestsByHead: (input: {
310 readonly cwd: string;222 readonly cwd: string;
311 readonly headSelector: string;223 readonly headSelector: string;
312 readonly state: "open" | "closed" | "merged" | "all";224 readonly state: "open" | "closed" | "merged" | "all";
313 readonly limit: number;225 readonly limit: number;
314 /** The checkout's GitHub host. Without it the lookup is not batched. */226 /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */
315 readonly rateLimitHost?: string;227 readonly rateLimitHost?: string;
316 }) => Effect.Effect<ReadonlyArray<NormalizedGitHubPullRequestRecord>, GitHubCliError>;228 }) => Effect.Effect<ReadonlyArray<NormalizedGitHubPullRequestRecord>, GitHubCliError>;
317229
...@@ -353,67 +265,73 @@ export class GitHubCli extends Context.Service<...@@ -353,67 +265,73 @@ export class GitHubCli extends Context.Service<
353 }265 }
354>()("t3/sourceControl/GitHubCli") {}266>()("t3/sourceControl/GitHubCli") {}
355267
356const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({268/**
357 nameWithOwner: TrimmedNonEmptyString,269 * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a
358 url: TrimmedNonEmptyString,270 * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin
359 sshUrl: TrimmedNonEmptyString,271 * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of
272 * `git config --get-regexp '^remote\..*\.gh-resolved$'`.
273 *
274 * Null whenever gh might weigh the remotes differently: a remote on another host or under an
275 * SSH alias, more than one mark, or several remotes with none of those names. Callers then
276 * fall back to the provider's remote, then to the best-ranked remote on the host.
277 */
278export function selectGitHubBaseRepository(input: {
279 readonly remotes: string;
280 readonly resolved: string;
281 readonly host: string;
282}): { readonly owner: string; readonly name: string } | null {
283 const host = input.host.toLowerCase();
284 const repositories = new Map<string, { readonly owner: string; readonly name: string }>();
285 for (const line of input.remotes.split("\n")) {
286 const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim());
287 if (!match) continue;
288 const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/");
289 if (remoteHost !== host || !owner || !name || rest.length > 0) return null;
290 repositories.set(match[1]!, { owner, name });
291 }
292 const marks = input.resolved
293 .split("\n")
294 .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim()))
295 .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!));
296 if (marks.length > 1) return null;
297 const [mark] = marks;
298 if (mark) {
299 if (mark[2] === "base") return repositories.get(mark[1]!) ?? null;
300 const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/");
301 return owner && name && rest.length === 0 ? { owner, name } : null;
302 }
303 // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the
304 // top place has no defined winner.
305 const score = (remoteName: string) =>
306 ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1;
307 const ranked = [...repositories.entries()].toSorted(
308 ([left], [right]) => score(right) - score(left),
309 );
310 const [top, next] = ranked;
311 return top !== undefined && (next === undefined || score(top[0]) > score(next[0]))
312 ? top[1]
313 : null;
314}
315
316const RawRepositorySchema = Schema.Struct({
317 full_name: TrimmedNonEmptyString,
318 html_url: TrimmedNonEmptyString,
319 ssh_url: TrimmedNonEmptyString,
320 default_branch: Schema.optional(Schema.NullOr(Schema.String)),
360});321});
361const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect(322const decodeRawRepository = decodeJsonResult(RawRepositorySchema);
362 Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema),
363);
364323
365function normalizeRepositoryCloneUrls(324function repositoryCloneUrls(
366 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,325 raw: Schema.Schema.Type<typeof RawRepositorySchema>,
367): GitHubRepositoryCloneUrls {326): GitHubRepositoryCloneUrls {
368 return {327 return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url };
369 nameWithOwner: raw.nameWithOwner,
370 url: raw.url,
371 sshUrl: raw.sshUrl,
372 };
373}328}
374329
375/**330const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString }));
376 * `gh repo create` prints the canonical URL of the new repository on stdout
377 * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a
378 * follow-up `gh repo view`, which can race GitHub's GraphQL eventual
379 * consistency window and falsely report the just-created repo as missing.
380 */
381function deriveRepositoryCloneUrlsFromCreateOutput(
382 stdout: string,
383 repository: string,
384): GitHubRepositoryCloneUrls {
385 const fallbackHost = "github.com";
386 const match = stdout.match(/https?:\/\/[^\s]+/);
387 if (match) {
388 const cleaned = match[0].replace(/\.git$/, "");
389 try {
390 const parsed = new URL(cleaned);
391 const pathname = parsed.pathname.replace(/^\/+|\/+$/g, "");
392 const segments = pathname.split("/").filter(Boolean);
393 if (segments.length === 2) {
394 const nameWithOwner = `${segments[0]}/${segments[1]}`;
395 return {
396 nameWithOwner,
397 url: `${parsed.origin}/${nameWithOwner}`,
398 sshUrl: `git@${parsed.host}:${nameWithOwner}.git`,
399 };
400 }
401 } catch {
402 // Fall through to the input-derived defaults below.
403 }
404 }
405 return {
406 nameWithOwner: repository,
407 url: `https://${fallbackHost}/${repository}`,
408 sshUrl: `git@${fallbackHost}:${repository}.git`,
409 };
410}
411331
412type PullRequestListState = "open" | "closed" | "merged" | "all";332type PullRequestListState = "open" | "closed" | "merged" | "all";
413333
414const PULL_REQUEST_LIST_JSON_FIELDS =334/** The pull request fields every read selects, in GraphQL. */
415 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner";
416/** The `gh pr list --json` fields above, as GraphQL selects them. */
417const PULL_REQUEST_NODE_SELECTION =335const PULL_REQUEST_NODE_SELECTION =
418 "number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";336 "number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
419const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = {337const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = {
...@@ -424,7 +342,7 @@ const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = {...@@ -424,7 +342,7 @@ const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = {
424};342};
425/**343/**
426 * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one344 * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one
427 * `gh pr list`, but half that keeps each answer near half a second.345 * single lookup, but half that keeps each answer near half a second.
428 */346 */
429const HEAD_LOOKUPS_PER_DOCUMENT = 50;347const HEAD_LOOKUPS_PER_DOCUMENT = 50;
430/**348/**
...@@ -433,8 +351,13 @@ const HEAD_LOOKUPS_PER_DOCUMENT = 50;...@@ -433,8 +351,13 @@ const HEAD_LOOKUPS_PER_DOCUMENT = 50;
433 */351 */
434const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";352const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
435/** A full document is 5,000 rows of well under 2 KB each. */353/** A full document is 5,000 rows of well under 2 KB each. */
436const HEAD_LOOKUP_MAX_OUTPUT_BYTES = 16_000_000;354const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000;
437const RATE_LIMIT_READING = "query { rateLimit { cost limit remaining resetAt } }";355/**
356 * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch
357 * named like a busy default (`main`) is the case this bounds; the owner's own row is near the
358 * top because the newest come first.
359 */
360const OWNER_HEAD_SCAN_LIMIT = 100;
438361
439class PullRequestsByHeadRead extends Request.Class<362class PullRequestsByHeadRead extends Request.Class<
440 {363 {
...@@ -445,25 +368,17 @@ class PullRequestsByHeadRead extends Request.Class<...@@ -445,25 +368,17 @@ class PullRequestsByHeadRead extends Request.Class<
445 readonly headRefName: string;368 readonly headRefName: string;
446 readonly state: PullRequestListState;369 readonly state: PullRequestListState;
447 readonly limit: number;370 readonly limit: number;
371 readonly allowReserve: boolean;
448 },372 },
449 ReadonlyArray<NormalizedGitHubPullRequestRecord>,373 ReadonlyArray<NormalizedGitHubPullRequestRecord>,
450 GitHubCliError374 GitHubCliError
451> {}375> {}
452376
453const GraphQlVariables = Schema.Record(
454 Schema.String,
455 Schema.Union([Schema.String, Schema.Array(Schema.String)]),
456);
457/** A GraphQL request body for `gh api graphql --input -`. */
458const encodeGraphQlRequest = Schema.encodeSync(
459 Schema.fromJsonString(Schema.Struct({ query: Schema.String, variables: GraphQlVariables })),
460);
461
462/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */377/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */
463function buildPullRequestsByHeadQuery(378function buildPullRequestsByHeadQuery(
464 lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>,379 lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>,
465): { readonly document: string; readonly variables: typeof GraphQlVariables.Type } {380): { readonly document: string; readonly variables: Record<string, unknown> } {
466 const variables: Record<string, string | ReadonlyArray<string>> = {};381 const variables: Record<string, unknown> = {};
467 const declarations: string[] = ["$owner: String!", "$name: String!"];382 const declarations: string[] = ["$owner: String!", "$name: String!"];
468 const selections: string[] = [];383 const selections: string[] = [];
469 for (const [index, lookup] of lookups.entries()) {384 for (const [index, lookup] of lookups.entries()) {
...@@ -482,419 +397,303 @@ function buildPullRequestsByHeadQuery(...@@ -482,419 +397,303 @@ function buildPullRequestsByHeadQuery(
482 };397 };
483}398}
484399
485/** The reset time of a `rateLimit` reading, which sets when the next one is due. */400const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) {
486const decodeRateLimitReading = (raw: string) =>401 repository(owner: $owner, name: $name) {
487 Result.map(402 pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} }
488 decodeJsonResult(403 }
489 Schema.Struct({404}`;
490 data: Schema.Struct({ rateLimit: Schema.Struct({ resetAt: Schema.String }) }),
491 }),
492 )(raw),
493 (reading) => reading.data.rateLimit.resetAt,
494 );
495405
496const decodePullRequestsByHead = decodeJsonResult(406const decodePullRequestsByHead = decodeJsonResult(
497 Schema.Struct({407 Schema.Struct({
498 data: Schema.Struct({408 data: Schema.Struct({
499 repository: Schema.Record(409 repository: Schema.NullOr(
500 Schema.String,410 Schema.Record(
501 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })),411 Schema.String,
412 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })),
413 ),
502 ),414 ),
503 }),415 }),
504 }),416 }),
505);417);
506418
419const decodePullRequestByNumber = decodeJsonResult(
420 Schema.Struct({
421 data: Schema.Struct({
422 repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })),
423 }),
424 }),
425);
426
427/** A repository on a GitHub host: the API it is read through, and its owner and name. */
428export interface GitHubRepositoryLocator {
429 readonly host: string;
430 readonly owner: string;
431 readonly name: string;
432}
433
434/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */
435function parseGitHubRepositorySelector(
436 selector: string,
437 defaultHost: string,
438): GitHubRepositoryLocator | null {
439 const trimmed = selector.trim().replace(/\.git$/i, "");
440 if (/^https?:\/\//i.test(trimmed)) {
441 try {
442 const url = new URL(trimmed);
443 const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean);
444 return owner && name && rest.length === 0
445 ? { host: url.host.toLowerCase(), owner, name }
446 : null;
447 } catch {
448 return null;
449 }
450 }
451 const parts = trimmed.split("/").filter(Boolean);
452 if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! };
453 if (parts.length === 3)
454 return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! };
455 return null;
456}
457
507/**458/**
508 * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a459 * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request
509 * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin460 * URL, or a branch name.
510 * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of
511 * `git config --get-regexp '^remote\..*\.gh-resolved$'`.
512 *
513 * Null whenever gh might weigh the remotes differently: a remote on another host or under an
514 * SSH alias, more than one mark, or several remotes with none of those names. Callers then
515 * ask gh itself.
516 */461 */
517export function selectGitHubBaseRepository(input: {462function parsePullRequestReference(
518 readonly remotes: string;463 reference: string,
519 readonly resolved: string;464):
520 readonly host: string;465 | { readonly kind: "number"; readonly number: number }
521}): { readonly owner: string; readonly name: string } | null {466 | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number }
522 const host = input.host.toLowerCase();467 | { readonly kind: "branch"; readonly headSelector: string } {
523 const repositories = new Map<string, { readonly owner: string; readonly name: string }>();468 const trimmed = reference.trim();
524 for (const line of input.remotes.split("\n")) {469 const numbered = /^#?([1-9]\d*)$/.exec(trimmed);
525 const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim());470 if (numbered) return { kind: "number", number: Number(numbered[1]) };
526 if (!match) continue;471 if (/^https?:\/\//i.test(trimmed)) {
527 const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/");472 try {
528 if (remoteHost !== host || !owner || !name || rest.length > 0) return null;473 const url = new URL(trimmed);
529 repositories.set(match[1]!, { owner, name });474 const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname);
475 if (match) {
476 return {
477 kind: "url",
478 locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! },
479 number: Number(match[3]),
480 };
481 }
482 } catch {
483 // Not a URL after all; read it as a branch.
484 }
530 }485 }
531 const marks = input.resolved486 return { kind: "branch", headSelector: trimmed };
532 .split("\n")487}
533 .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim()))488
534 .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!));489/**
535 if (marks.length > 1) return null;490 * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH
536 const [mark] = marks;491 * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through
537 if (mark) {492 * `github.com`, which is what such an alias almost always stands for (issue #6198).
538 if (mark[2] === "base") return repositories.get(mark[1]!) ?? null;493 */
539 const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/");494export function gitHubApiHostForRemote(remoteUrl: string): string | null {
540 return owner && name && rest.length === 0 ? { owner, name } : null;495 const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl);
496 if (provider === null) return null;
497 const host = new URL(provider.baseUrl).host.toLowerCase();
498 // A dotless SSH host is an alias from ~/.ssh/config, never a real API host.
499 if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) {
500 return host.includes("github") ? "github.com" : null;
541 }501 }
542 // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the502 return provider.kind === "github" ? host : null;
543 // top place has no defined winner.503}
544 const score = (remoteName: string) =>504
545 ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1;505/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */
546 const ranked = [...repositories.entries()].toSorted(506function apiHostForHint(host: string): string {
547 ([left], [right]) => score(right) - score(left),507 const normalized = host.toLowerCase();
548 );508 return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized;
549 const [top, next] = ranked;509}
550 return top !== undefined && (next === undefined || score(top[0]) > score(next[0]))510
551 ? top[1]511/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */
552 : null;512export function pullRequestCheckoutBranchName(input: {
513 readonly headRefName: string;
514 readonly headOwner: string | null;
515 readonly isCrossRepository: boolean;
516 readonly defaultBranch: string | null;
517}): string {
518 // gh prefixes the owner only where the fork's branch would take over the default branch's
519 // name, which is the one collision every fork pull request from `main` would hit.
520 return input.isCrossRepository &&
521 input.headOwner !== null &&
522 input.defaultBranch !== null &&
523 input.headRefName === input.defaultBranch
524 ? `${input.headOwner}/${input.headRefName}`
525 : input.headRefName;
553}526}
554527
555/** @public Service construction is part of the canonical Effect module API. */528/** @public Service construction is part of the canonical Effect module API. */
556export const make = Effect.gen(function* () {529export const make = Effect.gen(function* () {
557 const process = yield* VcsProcess.VcsProcess;530 const process = yield* VcsProcess.VcsProcess;
558 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;531 const environment = yield* HostProcessEnvironment;
559 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;532 const api = yield* GitHubApi.GitHubApi;
560533 const git = yield* GitVcsDriver.GitVcsDriver;
561 const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")(534 const fileSystem = yield* FileSystem.FileSystem;
562 function* (input) {
563 const credential = yield* PinnedGitHubCredential;
564 if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) {
565 return yield* new GitHubCliCommandError({
566 command: "gh",
567 cwd: input.cwd,
568 cause: new Error("The GitHub command does not target the verified credential's host."),
569 });
570 }
571 const token = credential === null ? undefined : Redacted.value(credential.token);
572 const env =
573 credential === null
574 ? input.env
575 : {
576 ...input.env,
577 GH_HOST: credential.host,
578 GH_TOKEN: token,
579 GITHUB_TOKEN: token,
580 GH_ENTERPRISE_TOKEN: token,
581 GITHUB_ENTERPRISE_TOKEN: token,
582 GH_DEBUG: "",
583 };
584 const result = yield* process
585 .run({
586 operation: "GitHubCli.execute",
587 command: "gh",
588 args: input.args,
589 cwd: input.cwd,
590 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
591 ...(input.acceptNotModified ? { allowNonZeroExit: true } : {}),
592 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
593 ...(env !== undefined ? { env } : {}),
594 ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),
595 })
596 .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error)));
597 if (result.exitCode !== 0 && input.acceptNotModified) {
598 const status = /^HTTP\/\S+ (\d+)/.exec(result.stdout)?.[1];
599 if (status !== "304" || !input.args.includes("--include")) {
600 const context = { command: "gh" as const, cwd: input.cwd, cause: undefined };
601 const headers = result.stdout.split(/\r?\n\r?\n/, 1)[0] ?? "";
602 const header = (name: string) =>
603 new RegExp(`^${name}:\\s*(.*)$`, "im").exec(headers)?.[1]?.trim();
604 if (
605 status === "429" ||
606 (status === "403" &&
607 (header("x-ratelimit-remaining") === "0" ||
608 header("retry-after") !== undefined ||
609 /rate limit/i.test(result.stderr)))
610 ) {
611 const now = DateTime.toEpochMillis(yield* DateTime.now);
612 const reset = Number(header("x-ratelimit-reset")) * 1_000;
613 const retryAt =
614 SourceControlRateLimit.retryAtFromHeader(header("retry-after"), now) ??
615 (Number.isFinite(reset) && reset > now ? reset : undefined);
616 return yield* new GitHubCliRateLimitError({
617 ...context,
618 ...(retryAt === undefined ? {} : { retryAt }),
619 });
620 }
621 if (status === "401") return yield* new GitHubCliAuthenticationError(context);
622 return yield* new GitHubCliCommandError({
623 ...context,
624 ...(status === undefined ? {} : { httpStatus: Number(status) }),
625 });
626 }
627 }
628 return result;
629 },
630 );
631535
632 /**536 const gitRead = (cwd: string, args: ReadonlyArray<string>) =>
633 * A GraphQL `rateLimit` reading per host and credential, so the budget knows the balance537 process.run({
634 * before reads that cannot report their own cost (`gh pr list`). GraphQL documents keep it538 operation: "GitHubCli.resolveRepository",
635 * current in between. Each reading costs one point and is redone at the reset, or after ten539 command: "git",
636 * minutes so a `gh auth switch` is not priced against the old account for a whole hour.540 args,
637 */541 cwd,
638 const budgetReading = yield* Cache.makeWith(542 allowNonZeroExit: true,
639 (key: string) => {543 timeoutMs: 5_000,
640 const host = key.split("\0")[0]!;544 });
641 return executeRaw({545
642 cwd: globalThis.process.cwd(),546 const commandFailure = (cwd: string, detail: string) =>
643 args: ["api", "graphql", "--hostname", host, "-f", `query=${RATE_LIMIT_READING}`],547 new GitHubCliCommandError({ command: "gh", cwd, cause: new Error(detail) });
644 }).pipe(
645 Effect.tap((result) => budget.observe(host, result.stdout)),
646 Effect.flatMap((result) =>
647 Clock.currentTimeMillis.pipe(
648 Effect.map((now) => {
649 const resetAtMs = Date.parse(
650 decodeRateLimitReading(result.stdout).pipe(
651 Result.match({ onFailure: () => "", onSuccess: (reading) => reading }),
652 ),
653 );
654 // A reset that is missing or already past (a skewed clock) waits a minute, so a
655 // bad reading cannot ask again on every read.
656 return Duration.millis(
657 Number.isFinite(resetAtMs) && resetAtMs > now
658 ? Math.min(resetAtMs - now, 600_000)
659 : 60_000,
660 );
661 }),
662 ),
663 ),
664 );
665 },
666 {
667 capacity: 32,
668 timeToLive: (exit) => (Exit.isSuccess(exit) ? exit.value : Duration.minutes(1)),
669 },
670 );
671548
672 /**549 /**
673 * Runs a GraphQL-priced read under its host's pause and the GraphQL budget. `run` receives550 * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else
674 * the document with `rateLimit` added, so a GraphQL read can report what it spent; a CLI read551 * the remote the caller resolved the provider from, else the best-ranked GitHub remote.
675 * ignores it and is priced at one point.
676 */552 */
677 const guardedRead = <A>(input: {553 const resolveRepository = Effect.fn("GitHubCli.resolveRepository")(function* (input: {
678 readonly cwd: string;554 readonly cwd: string;
679 readonly host: string;555 readonly host?: string | undefined;
680 readonly document: string;556 }) {
681 readonly allowReserve: boolean;557 const envRepository = environment.GH_REPO?.trim();
682 readonly run: (document: string) => Effect.Effect<A, GitHubCliError>;558 const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase();
683 }) =>559 if (envRepository) {
684 Effect.gen(function* () {560 const locator = parseGitHubRepositorySelector(envRepository, defaultHost);
685 const credential = yield* PinnedGitHubCredential;561 if (locator !== null) return locator;
686 const key = { provider: "github" as const, host: input.host };562 }
687 const guarded = Effect.gen(function* () {563 const [remotes, resolved] = yield* Effect.all([
688 const lease = yield* limits.check(564 gitRead(input.cwd, ["remote", "-v"]),
689 key,565 gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]),
690 input.allowReserve ? { allowPaused: true } : undefined,566 ]).pipe(Effect.orElseSucceed(() => [null, null] as const));
691 );567 const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : "";
692 // A failed reading leaves the budget unknown; it never blocks the read itself.568 const fetchRemotes = remoteOutput
693 yield* Cache.get(569 .split("\n")
694 budgetReading,570 .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()))
695 `${input.host}\0${yield* SourceControlRateLimit.CredentialScope}`,571 .filter((match): match is RegExpExecArray => match !== null)
696 ).pipe(Effect.ignore);572 .map((match) => ({
697 return yield* budget573 name: match[1]!,
698 .query(574 url: match[2]!,
699 input.host,575 host: gitHubApiHostForRemote(match[2]!),
700 input.document,576 }));
701 input.allowReserve ? { allowReserve: true } : undefined,577 const host =
702 )578 (input.host === undefined ? undefined : apiHostForHint(input.host)) ??
703 .pipe(579 fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ??
704 Effect.flatMap(input.run),580 fetchRemotes.find((remote) => remote.host !== null)?.host ??
705 Effect.tap(() => limits.recordSuccess({ ...key, lease })),581 defaultHost;
706 Effect.tapError((error) =>582 const selected = selectGitHubBaseRepository({
707 error._tag === "GitHubCliRateLimitError"583 remotes: remoteOutput,
708 ? limits.recordRateLimit({ ...key, lease })584 resolved: resolved?.exitCode === 0 ? resolved.stdout : "",
709 : Effect.void,585 host,
710 ),
711 );
712 });
713 return yield* guarded.pipe(
714 Effect.provideService(
715 SourceControlRateLimit.CredentialScope,
716 credential?.credentialFingerprint ?? (yield* SourceControlRateLimit.CredentialScope),
717 ),
718 Effect.catchTags({
719 SourceControlRateLimitPausedError: (cause) =>
720 Effect.fail(
721 new GitHubCliRateLimitError({
722 command: "gh",
723 cwd: input.cwd,
724 retryAt: cause.retryAt,
725 cause,
726 }),
727 ),
728 }),
729 );
730 });586 });
731587 if (selected !== null) return { host, ...selected };
732 const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")(588 // gh's own order without its prompt: upstream, github, origin, then the first remote, among
733 function* (input) {589 // the ones on this host (an SSH alias counts as its API host).
734 const [command, action] = input.args;590 const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase());
735 if (591 const candidates = fetchRemotes
736 !(592 .filter((remote) => remote.host === host)
737 (command === "pr" && (action === "list" || action === "view")) ||593 .toSorted((left, right) => {
738 (command === "repo" && action === "view")594 const l = rank(left.name);
739 )595 const r = rank(right.name);
740 )596 return (l === -1 ? 99 : l) - (r === -1 ? 99 : r);
741 return yield* executeRaw(input);
742 const credential = yield* PinnedGitHubCredential;
743 if (credential !== null && !targetsVerifiedHost(input.args, credential.host))
744 return yield* executeRaw(input);
745 return yield* guardedRead({
746 cwd: input.cwd,
747 host: (
748 credential?.host ??
749 commandHosts(input.args).find((host) => host !== null) ??
750 input.rateLimitHost ??
751 input.env?.GH_HOST ??
752 globalThis.process.env.GH_HOST ??
753 "github.com"
754 ).toLowerCase(),
755 document: "query {}",
756 allowReserve: input.allowReserve ?? (yield* AllowGitHubReserve),
757 run: () => executeRaw(input),
758 });597 });
759 },598 for (const remote of candidates) {
760 );599 const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1);
600 if (owner && name && rest.length === 0) return { host, owner, name };
601 }
602 return yield* commandFailure(
603 input.cwd,
604 `No GitHub repository on ${host} was found among this checkout's git remotes.`,
605 );
606 });
761607
762 const listPullRequestsWithCli = (input: {608 const graphqlJson = <A>(
763 readonly cwd: string;609 cwd: string,
764 readonly headSelector: string;610 input: GitHubApi.GitHubGraphQlInput,
765 readonly state: PullRequestListState;611 decode: (raw: string) => Result.Result<A, unknown>,
766 readonly limit: number;612 onDecodeFailure: (cause: unknown) => GitHubCliError,
767 readonly rateLimitHost?: string | undefined;613 ) =>
768 }) =>614 api.graphql(input).pipe(
769 execute({615 Effect.mapError((error) => fromGitHubApiError(cwd, error)),
770 cwd: input.cwd,616 Effect.flatMap((raw) => {
771 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),617 const decoded = decode(raw);
772 args: [
773 "pr",
774 "list",
775 "--head",
776 input.headSelector,
777 "--state",
778 input.state,
779 "--limit",
780 String(input.limit),
781 "--json",
782 PULL_REQUEST_LIST_JSON_FIELDS,
783 ],
784 }).pipe(
785 Effect.flatMap((result) => {
786 const raw = result.stdout.trim();
787 if (raw.length === 0) return Effect.succeed([]);
788 const decoded = decodeGitHubPullRequestListJson(raw);
789 return Result.isSuccess(decoded)618 return Result.isSuccess(decoded)
790 ? Effect.succeed(decoded.success)619 ? Effect.succeed(decoded.success)
791 : Effect.fail(620 : Effect.fail(onDecodeFailure(decoded.failure));
792 new GitHubChangeRequestListDecodeError({
793 command: "gh",
794 cwd: input.cwd,
795 cause: decoded.failure,
796 }),
797 );
798 }),621 }),
799 );622 );
800623
801 const git = (cwd: string, args: ReadonlyArray<string>) =>624 const rest = (cwd: string, input: GitHubApi.GitHubRestInput) =>
802 process.run({625 api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error)));
803 operation: "GitHubCli.baseRepository",
804 command: "git",
805 args,
806 cwd,
807 allowNonZeroExit: true,
808 timeoutMs: 5_000,
809 });
810626
811 /** The repository gh reads in `cwd`, or null when gh could pick it another way. */627 /** Pull requests whose head branch is `headRefName` on one repository. */
812 const resolveBaseRepository = (cwd: string, host: string) =>628 const readPullRequestsByHead = (input: {
813 globalThis.process.env.GH_REPO629 readonly cwd: string;
814 ? Effect.succeed(null)630 readonly locator: GitHubRepositoryLocator;
815 : Effect.all([631 readonly lookups: ReadonlyArray<
816 git(cwd, ["remote", "-v"]),632 Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">
817 git(cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]),633 >;
818 ]).pipe(634 readonly allowReserve: boolean;
819 Effect.map(([remotes, resolved]) =>635 readonly onDecodeFailure: (cause: unknown) => GitHubCliError;
820 remotes.exitCode === 0636 }) => {
821 ? selectGitHubBaseRepository({637 const query = buildPullRequestsByHeadQuery(input.lookups);
822 remotes: remotes.stdout,638 return graphqlJson(
823 resolved: resolved.exitCode === 0 ? resolved.stdout : "",639 input.cwd,
824 host,640 {
825 })641 host: input.locator.host,
826 : null,642 operation: "listPullRequestsByHead",
827 ),643 query: query.document,
828 Effect.orElseSucceed(() => null),644 variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables },
829 );645 allowReserve: input.allowReserve,
646 // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred
647 // fork pull requests, so the usual cap would cut the answer short.
648 maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES,
649 },
650 decodePullRequestsByHead,
651 input.onDecodeFailure,
652 ).pipe(
653 Effect.flatMap((decoded) =>
654 decoded.data.repository === null
655 ? Effect.fail(
656 new GitHubPullRequestNotFoundError({
657 command: "gh",
658 cwd: input.cwd,
659 cause: new Error("The repository could not be read."),
660 }),
661 )
662 : Effect.succeed(decoded.data.repository),
663 ),
664 );
665 };
830666
831 const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({667 const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({
832 key: ({ request, context }) =>668 key: ({ request, context }) =>
833 JSON.stringify([669 [
834 request.host,670 request.host,
835 request.owner,671 request.owner,
836 request.name,672 request.name,
837 Context.getOrElse(context, PinnedGitHubCredential, () => null)?.credentialFingerprint ??673 String(request.allowReserve),
838 null,674 Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
675 ?.credentialFingerprint ?? "",
839 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),676 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
840 ]),677 ].join("\0"),
841 resolver: (entries) => {678 resolver: (entries) => {
842 const [first] = entries;679 const [first] = entries;
843 const { cwd, host, owner, name } = first.request;680 const { cwd, host, owner, name, allowReserve } = first.request;
844 const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request));681 return readPullRequestsByHead({
845 const readCli = (entry: (typeof entries)[number]) =>
846 listPullRequestsWithCli({
847 cwd: entry.request.cwd,
848 headSelector: entry.request.headRefName,
849 state: entry.request.state,
850 limit: entry.request.limit,
851 rateLimitHost: entry.request.host,
852 }).pipe(
853 Effect.exit,
854 Effect.map((exit) => entry.completeUnsafe(exit)),
855 );
856 return guardedRead({
857 cwd,682 cwd,
858 host,683 locator: { host, owner, name },
859 document: query.document,684 lookups: entries.map((entry) => entry.request),
860 allowReserve: false,685 allowReserve,
861 run: (document) =>686 onDecodeFailure: (cause) =>
862 executeRaw({687 new GitHubChangeRequestListDecodeError({ command: "gh", cwd, cause }),
863 cwd,
864 args: ["api", "graphql", "--hostname", host, "--input", "-"],
865 // Up to 50 heads of 100 rows each. A default branch such as `main` can match a
866 // hundred fork pull requests, so the 1 MB default would cut the answer short.
867 maxOutputBytes: HEAD_LOOKUP_MAX_OUTPUT_BYTES,
868 stdin: encodeGraphQlRequest({
869 query: document,
870 variables: { owner, name, ...query.variables },
871 }),
872 }).pipe(Effect.tap((result) => budget.observe(host, result.stdout))),
873 }).pipe(688 }).pipe(
874 Effect.flatMap((result) => {689 Effect.map((aliases) => {
875 const decoded = decodePullRequestsByHead(result.stdout);690 for (const [index, entry] of entries.entries()) {
876 if (!Result.isSuccess(decoded)) {691 const alias = aliases[`h${index}`];
877 return Effect.forEach(entries, readCli, { discard: true });692 entry.completeUnsafe(
693 Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)),
694 );
878 }695 }
879 const aliases = decoded.success.data.repository;
880 return Effect.forEach(
881 entries,
882 (entry, index) => {
883 const alias = aliases[`h${index}`];
884 if (alias == null) return readCli(entry);
885 entry.completeUnsafe(Exit.succeed(decodeGitHubPullRequestEntries(alias.nodes)));
886 return Effect.void;
887 },
888 { discard: true },
889 );
890 }),696 }),
891 // A document GitHub refused as a whole (a renamed repository, a field an older
892 // Enterprise host lacks) leaves each lookup to gh. A rate limit fails them all:
893 // asking one at a time would only spend what the pause is saving.
894 Effect.catchIf(
895 (error) => error._tag !== "GitHubCliRateLimitError",
896 () => Effect.forEach(entries, readCli, { discard: true }),
897 ),
898 Effect.catchCause((cause) =>697 Effect.catchCause((cause) =>
899 Effect.sync(() => {698 Effect.sync(() => {
900 for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause));699 for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause));
...@@ -907,174 +706,368 @@ export const make = Effect.gen(function* () {...@@ -907,174 +706,368 @@ export const make = Effect.gen(function* () {
907 RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT),706 RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT),
908 );707 );
909708
910 const listPullRequestsByHead: GitHubCli["Service"]["listPullRequestsByHead"] = Effect.fn(709 const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: {
911 "GitHubCli.listPullRequestsByHead",710 readonly cwd: string;
912 )(function* (input) {711 readonly headSelector: string;
913 const host = input.rateLimitHost?.toLowerCase();712 readonly state: PullRequestListState;
914 const credential = yield* PinnedGitHubCredential;713 readonly limit: number;
915 // `owner:branch` selectors and other hosts keep gh's own handling.714 readonly rateLimitHost?: string | undefined;
916 const repository =715 readonly allowReserve: boolean;
917 host === undefined ||716 }) {
918 input.headSelector.includes(":") ||717 const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost });
919 (credential !== null && credential.host !== host)718 const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100);
920 ? null719 // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the
921 : yield* resolveBaseRepository(input.cwd, host);720 // owner is matched on the rows it returns.
922 if (host === undefined || repository === null) {721 const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());
923 return yield* listPullRequestsWithCli(input);722 const headRefName = ownerMatch?.[2] ?? input.headSelector.trim();
924 }723 const rows = yield* Effect.request(
925 return yield* Effect.request(
926 new PullRequestsByHeadRead({724 new PullRequestsByHeadRead({
927 cwd: input.cwd,725 cwd: input.cwd,
928 host,726 ...locator,
929 owner: repository.owner,727 headRefName,
930 name: repository.name,
931 headRefName: input.headSelector,
932 state: input.state,728 state: input.state,
933 limit: Math.min(Math.max(Math.trunc(input.limit), 1), 100),729 limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit,
730 allowReserve: input.allowReserve,
934 }),731 }),
935 headResolver,732 headResolver,
936 );733 );
734 if (!ownerMatch) return rows;
735 const headOwner = ownerMatch[1]!.toLowerCase();
736 return rows
737 .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner)
738 .slice(0, limit);
937 });739 });
938740
939 return GitHubCli.of({741 const toSummaries = (rows: ReadonlyArray<NormalizedGitHubPullRequestRecord>) =>
940 execute,742 rows.map(pullRequestSummary);
941 listPullRequestsByHead,743
942 listOpenPullRequests: (input) =>744 const readPullRequest = Effect.fn("GitHubCli.readPullRequest")(function* (input: {
943 execute({745 readonly cwd: string;
746 readonly reference: string;
747 readonly rateLimitHost?: string | undefined;
748 }) {
749 const parsed = parsePullRequestReference(input.reference);
750 if (parsed.kind === "branch") {
751 // `gh pr view <branch>` prefers an open pull request, then the newest of any state.
752 const [open] = yield* listByHead({
944 cwd: input.cwd,753 cwd: input.cwd,
945 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),754 headSelector: parsed.headSelector,
755 state: "open",
756 limit: 1,
757 rateLimitHost: input.rateLimitHost,
946 allowReserve: true,758 allowReserve: true,
947 args: [759 });
948 "pr",760 const found =
949 "list",761 open ??
950 "--head",762 (yield* listByHead({
951 input.headSelector,763 cwd: input.cwd,
952 "--state",764 headSelector: parsed.headSelector,
953 "open",765 state: "all",
954 "--limit",766 limit: 1,
955 String(input.limit ?? 1),767 rateLimitHost: input.rateLimitHost,
956 "--json",768 allowReserve: true,
957 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",769 }))[0];
958 ],770 if (found === undefined) {
959 }).pipe(771 return yield* new GitHubPullRequestNotFoundError({
960 Effect.map((result) => result.stdout.trim()),772 command: "gh",
961 Effect.flatMap((raw) =>773 cwd: input.cwd,
962 raw.length === 0774 cause: new Error("No pull request has this head branch."),
963 ? Effect.succeed([])775 });
964 : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(776 }
965 Effect.flatMap((decoded) => {777 return found;
966 if (!Result.isSuccess(decoded)) {778 }
967 return Effect.fail(779 const locator =
968 new GitHubPullRequestListDecodeError({780 parsed.kind === "url"
969 command: "gh",781 ? parsed.locator
970 cwd: input.cwd,782 : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost });
971 cause: decoded.failure,783 const decodeFailure = (cause: unknown) =>
972 }),784 new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, cause });
973 );785 const decoded = yield* graphqlJson(
974 }786 input.cwd,
975787 {
976 return Effect.succeed(decoded.success.map(pullRequestSummary));788 host: locator.host,
977 }),789 operation: "getPullRequest",
978 ),790 query: PULL_REQUEST_BY_NUMBER_QUERY,
979 ),791 variables: { owner: locator.owner, name: locator.name, number: parsed.number },
980 ),
981 getPullRequest: (input) =>
982 execute({
983 cwd: input.cwd,
984 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),
985 allowReserve: true,792 allowReserve: true,
986 args: [793 },
987 "pr",794 decodePullRequestByNumber,
988 "view",795 decodeFailure,
989 input.reference,796 );
990 "--json",797 const node = decoded.data.repository?.pullRequest;
991 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",798 if (node == null) {
992 ],799 return yield* new GitHubPullRequestNotFoundError({
993 }).pipe(800 command: "gh",
994 Effect.map((result) => result.stdout.trim()),
995 Effect.flatMap((raw) =>
996 Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe(
997 Effect.flatMap((decoded) => {
998 if (!Result.isSuccess(decoded)) {
999 return Effect.fail(
1000 new GitHubPullRequestDecodeError({
1001 command: "gh",
1002 cwd: input.cwd,
1003 cause: decoded.failure,
1004 }),
1005 );
1006 }
1007
1008 return Effect.succeed(pullRequestSummary(decoded.success));
1009 }),
1010 ),
1011 ),
1012 ),
1013 getRepositoryCloneUrls: (input) =>
1014 execute({
1015 cwd: input.cwd,
1016 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
1017 }).pipe(
1018 Effect.map((result) => result.stdout.trim()),
1019 Effect.flatMap((raw) =>
1020 decodeRawGitHubRepositoryCloneUrls(raw).pipe(
1021 Effect.mapError(
1022 (cause) =>
1023 new GitHubRepositoryDecodeError({
1024 command: "gh",
1025 cwd: input.cwd,
1026 cause,
1027 }),
1028 ),
1029 ),
1030 ),
1031 Effect.map(normalizeRepositoryCloneUrls),
1032 ),
1033 createRepository: (input) =>
1034 execute({
1035 cwd: input.cwd,801 cwd: input.cwd,
1036 args: ["repo", "create", input.repository, `--${input.visibility}`],802 cause: new Error("The pull request does not exist."),
1037 }).pipe(803 });
1038 Effect.map((result) =>804 }
1039 deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository),805 const [record] = decodeGitHubPullRequestEntries([node]);
1040 ),806 if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request."));
1041 ),807 return record;
1042 createPullRequest: (input) =>808 });
1043 execute({809
810 const readRepository = Effect.fn("GitHubCli.readRepository")(function* (
811 cwd: string,
812 locator: GitHubRepositoryLocator,
813 ) {
814 const response = yield* rest(cwd, {
815 host: locator.host,
816 operation: "getRepository",
817 path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`,
818 allowReserve: true,
819 });
820 const decoded = decodeRawRepository(response.body);
821 if (Result.isFailure(decoded)) {
822 return yield* new GitHubRepositoryDecodeError({
823 command: "gh",
824 cwd,
825 cause: decoded.failure,
826 });
827 }
828 return decoded.success;
829 });
830
831 const readViewerLogin = Effect.fn("GitHubCli.readViewerLogin")(function* (
832 cwd: string,
833 host: string,
834 ) {
835 const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" });
836 const decoded = decodeViewerLogin(response.body);
837 if (Result.isFailure(decoded)) {
838 return yield* new GitHubCliCommandError({ command: "gh", cwd, cause: decoded.failure });
839 }
840 return decoded.success.login;
841 });
842
843 const gitFailure = (cwd: string) => (cause: unknown) =>
844 new GitHubCliCommandError({ command: "gh", cwd, cause });
845
846 const runGit = (cwd: string, operation: string, args: ReadonlyArray<string>) =>
847 git.execute({ operation: `GitHubCli.checkoutPullRequest.${operation}`, cwd, args });
848
849 /**
850 * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a
851 * fork gets a remote of its own), checked out under the name gh would give it, and set to
852 * track the head. A head branch that is gone is read from the base's `refs/pull/<n>/head`.
853 * An existing branch fast-forwards, or with `force` is reset to the pull request.
854 */
855 const checkoutPullRequest: GitHubCli["Service"]["checkoutPullRequest"] = Effect.fn(
856 "GitHubCli.checkoutPullRequest",
857 )(function* (input) {
858 const reference = parsePullRequestReference(input.reference);
859 const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference });
860 const base =
861 reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd });
862 const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase();
863 const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null;
864 const isCrossRepository =
865 pullRequest.isCrossRepository ??
866 (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner);
867 const headOwner =
868 pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null;
869
870 const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe(
871 Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")),
872 Effect.mapError(gitFailure(input.cwd)),
873 );
874 const remoteFor = (nameWithOwner: string) =>
875 remotes
876 .split("\n")
877 .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()))
878 .find(
879 (match) =>
880 match !== null &&
881 normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") ===
882 nameWithOwner.toLowerCase(),
883 )?.[1] ?? null;
884 const baseRemote = Effect.suspend(() => {
885 const known = remoteFor(baseNameWithOwner);
886 return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known);
887 });
888 // A fork's branch named like the base's default branch is checked out under the owner's
889 // prefix. Without the default branch that collision cannot be ruled out, and the checkout
890 // would reset the local default branch to the fork's commit, so it fails instead.
891 const defaultBranch = isCrossRepository
892 ? yield* readRepository(input.cwd, base).pipe(
893 Effect.map((repository) => repository.default_branch ?? null),
894 )
895 : null;
896 const localBranch = pullRequestCheckoutBranchName({
897 headRefName: pullRequest.headRefName,
898 headOwner,
899 isCrossRepository,
900 defaultBranch,
901 });
902
903 /** The remote the head branch lives on; a fork the checkout does not know yet is added. */
904 const headRemote = Effect.gen(function* () {
905 if (!isCrossRepository) return yield* baseRemote;
906 if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone.");
907 const known = remoteFor(headNameWithOwner);
908 if (known !== null) return known;
909 const [owner, name] = headNameWithOwner.split("/");
910 const fork = yield* readRepository(input.cwd, {
911 host: base.host,
912 owner: owner!,
913 name: name!,
914 });
915 const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url");
916 return yield* git.ensureRemote({
1044 cwd: input.cwd,917 cwd: input.cwd,
1045 args: [918 preferredName: headOwner ?? "fork",
1046 "pr",919 url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url,
1047 "create",920 });
1048 "--base",921 });
1049 input.baseBranch,922
1050 "--head",923 const exists = (yield* git
1051 input.headSelector,924 .listLocalBranchNames(input.cwd)
1052 "--title",925 .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch);
1053 input.title,926
1054 "--body-file",927 // The commit to check out, fetched from the head branch where it still exists.
1055 input.bodyFile,928 const target = yield* Effect.gen(function* () {
1056 ],929 const remoteName = yield* headRemote;
1057 }).pipe(Effect.asVoid),930 yield* git.fetchRemoteTrackingBranch({
1058 getDefaultBranch: (input) =>
1059 execute({
1060 cwd: input.cwd,931 cwd: input.cwd,
1061 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),932 remoteName,
1062 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],933 remoteBranch: pullRequest.headRefName,
1063 }).pipe(934 });
1064 Effect.map((value) => {935 return {
1065 const trimmed = value.stdout.trim();936 ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`,
1066 return trimmed.length > 0 ? trimmed : null;937 upstream: { remoteName, remoteBranch: pullRequest.headRefName },
938 };
939 }).pipe(
940 Effect.catch(() =>
941 Effect.gen(function* () {
942 yield* runGit(input.cwd, "fetchPullRef", [
943 "fetch",
944 "--quiet",
945 "--no-tags",
946 yield* baseRemote,
947 `refs/pull/${pullRequest.number}/head`,
948 ]);
949 const { commitSha } = yield* git.resolveCommit({
950 cwd: input.cwd,
951 revision: "FETCH_HEAD",
952 });
953 return { ref: commitSha, upstream: null };
1067 }),954 }),
1068 ),955 ),
1069 checkoutPullRequest: (input) =>956 Effect.mapError(gitFailure(input.cwd)),
1070 execute({957 );
958
959 yield* Effect.gen(function* () {
960 if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]);
961 yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch }));
962 if (exists) {
963 yield* runGit(
964 input.cwd,
965 "sync",
966 input.force === true
967 ? ["reset", "--hard", "--quiet", target.ref]
968 : ["merge", "--ff-only", "--quiet", target.ref],
969 );
970 }
971 // Tracking is set once the branch is the pull request's, so a sync that fails leaves an
972 // existing branch's upstream as it was.
973 if (target.upstream !== null) {
974 yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream });
975 }
976 }).pipe(Effect.mapError(gitFailure(input.cwd)));
977 });
978
979 return GitHubCli.of({
980 listPullRequestsByHead: (input) =>
981 AllowGitHubReserve.pipe(
982 Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })),
983 ),
984 listOpenPullRequests: (input) =>
985 listByHead({
1071 cwd: input.cwd,986 cwd: input.cwd,
1072 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],987 headSelector: input.headSelector,
1073 }).pipe(Effect.asVoid),988 state: "open",
989 limit: input.limit ?? 1,
990 rateLimitHost: input.rateLimitHost,
991 allowReserve: true,
992 }).pipe(Effect.map(toSummaries)),
993 getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)),
994 getRepositoryCloneUrls: (input) =>
995 Effect.gen(function* () {
996 const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe(
997 Effect.map((locator) => locator.host),
998 Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"),
999 )).toLowerCase();
1000 const locator = parseGitHubRepositorySelector(input.repository, fallbackHost);
1001 if (locator === null) {
1002 return yield* commandFailure(input.cwd, "Repositories are named owner/name.");
1003 }
1004 return repositoryCloneUrls(yield* readRepository(input.cwd, locator));
1005 }),
1006 createRepository: (input) =>
1007 Effect.gen(function* () {
1008 const locator = parseGitHubRepositorySelector(
1009 input.repository,
1010 (environment.GH_HOST ?? "github.com").toLowerCase(),
1011 );
1012 const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host);
1013 const owner = locator?.owner ?? viewer;
1014 const name = locator?.name ?? input.repository.trim();
1015 const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com");
1016 const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase();
1017 const response = yield* rest(input.cwd, {
1018 host,
1019 operation: "createRepository",
1020 method: "POST",
1021 path:
1022 isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`,
1023 body: { name, private: input.visibility === "private" },
1024 });
1025 const decoded = decodeRawRepository(response.body);
1026 if (Result.isFailure(decoded)) {
1027 return yield* new GitHubRepositoryDecodeError({
1028 command: "gh",
1029 cwd: input.cwd,
1030 cause: decoded.failure,
1031 });
1032 }
1033 return repositoryCloneUrls(decoded.success);
1034 }),
1035 createPullRequest: (input) =>
1036 Effect.gen(function* () {
1037 const locator = yield* resolveRepository({ cwd: input.cwd });
1038 const body = yield* fileSystem
1039 .readFileString(input.bodyFile)
1040 .pipe(Effect.mapError(gitFailure(input.cwd)));
1041 yield* rest(input.cwd, {
1042 host: locator.host,
1043 operation: "createPullRequest",
1044 method: "POST",
1045 path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`,
1046 // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`.
1047 // gh allows maintainer edits unless told otherwise; the API's default is not documented.
1048 body: {
1049 base: input.baseBranch,
1050 head: input.headSelector,
1051 title: input.title,
1052 body,
1053 maintainer_can_modify: true,
1054 },
1055 });
1056 }),
1057 getDefaultBranch: (input) =>
1058 Effect.gen(function* () {
1059 const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost });
1060 const repository = yield* readRepository(input.cwd, locator);
1061 const branch = repository.default_branch?.trim() ?? "";
1062 return branch.length > 0 ? branch : null;
1063 }),
1064 checkoutPullRequest,
1074 });1065 });
1075});1066});
10761067
1077export const layer = Layer.effect(GitHubCli, make).pipe(1068export const layer = Layer.effect(GitHubCli, make).pipe(
1069 Layer.provideMerge(GitHubApi.layer),
1070 Layer.provideMerge(GitHubCredentials.layer),
1078 Layer.provideMerge(GitHubGraphQlBudget.layer),1071 Layer.provideMerge(GitHubGraphQlBudget.layer),
1079 Layer.provideMerge(SourceControlRateLimit.layer),1072 Layer.provideMerge(SourceControlRateLimit.layer),
1080);1073);
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+44-64
...@@ -3,11 +3,12 @@ import * as DateTime from "effect/DateTime";...@@ -3,11 +3,12 @@ import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
6import { ChildProcessSpawner } from "effect/process";7import { ChildProcessSpawner } from "effect/process";
78
8import * as VcsProcess from "../vcs/VcsProcess.ts";9import * as VcsProcess from "../vcs/VcsProcess.ts";
10import * as GitHubApi from "./GitHubApi.ts";
9import * as GitHubCli from "./GitHubCli.ts";11import * as GitHubCli from "./GitHubCli.ts";
10import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
11import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";12import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
12import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";13import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1314
...@@ -25,37 +26,26 @@ const processResult = (...@@ -25,37 +26,26 @@ const processResult = (
25 stderrTruncated: false,26 stderrTruncated: false,
26});27});
2728
28function makeProvider(github: Partial<GitHubCli.GitHubCli["Service"]>) {29function makeProvider(
30 github: Partial<GitHubCli.GitHubCli["Service"]>,
31 api: Partial<GitHubApi.GitHubApi["Service"]> = {},
32) {
29 return GitHubSourceControlProvider.make.pipe(33 return GitHubSourceControlProvider.make.pipe(
30 Effect.provide(Layer.mock(GitHubCli.GitHubCli)(github)),34 Effect.provide(
35 Layer.merge(Layer.mock(GitHubCli.GitHubCli)(github), Layer.mock(GitHubApi.GitHubApi)(api)),
36 ),
31 );37 );
32}38}
3339
34it.effect("uses the enterprise quota for a current-repository default branch read", () =>40const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
35 Effect.gen(function* () {41
36 // github.com is out of quota; the enterprise read must not be priced against it.42const restResponse = (body: string): GitHubApi.GitHubRestResponse => ({
37 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;43 status: 200,
38 yield* budget.observe(44 headers: {},
39 "github.com",45 body,
40 '{"data":{"rateLimit":{"cost":1,"limit":5000,"remaining":0,"resetAt":"2099-01-01T00:00:00Z"}}}',46 truncated: false,
41 );47 invalidUtf8: false,
42 const provider = yield* GitHubSourceControlProvider.make;48});
43 const branch = yield* provider.getDefaultBranch({
44 cwd: "/enterprise-repo",
45 context: {
46 provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" },
47 remoteName: "origin",
48 remoteUrl: "https://enterprise.test/acme/web.git",
49 },
50 });
51 assert.strictEqual(branch, "main");
52 }).pipe(
53 Effect.provide(GitHubCli.layer),
54 Effect.provideService(VcsProcess.VcsProcess, {
55 run: () => Effect.succeed(processResult("main")),
56 }),
57 ),
58);
5949
60it.effect("maps GitHub PR summaries into provider-neutral change requests", () =>50it.effect("maps GitHub PR summaries into provider-neutral change requests", () =>
61 Effect.gen(function* () {51 Effect.gen(function* () {
...@@ -412,27 +402,20 @@ it.effect.each(["pull", "issues"])(...@@ -412,27 +402,20 @@ it.effect.each(["pull", "issues"])(
412 "resolves %s subjects on the linked host without using the checkout",402 "resolves %s subjects on the linked host without using the checkout",
413 (kind) =>403 (kind) =>
414 Effect.gen(function* () {404 Effect.gen(function* () {
415 const provider = yield* makeProvider({405 const provider = yield* makeProvider(
416 execute: (input) => {406 {},
417 assert.deepStrictEqual(input.args, [407 {
418 "api",408 rest: (input) => {
419 "--hostname",409 assert.strictEqual(input.host, "github.com");
420 "github.com",410 assert.strictEqual(input.path, "repos/owner/repo/issues/42");
421 "repos/owner/repo/issues/42",411 return Effect.succeed(
422 "--jq",412 restResponse(
423 "{title, body}",413 encodeJson({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }),
424 ]);414 ),
425 assert.strictEqual(input.maxOutputBytes, 32_000);415 );
426 assert.strictEqual(input.timeoutMs, 3_000);416 },
427 return Effect.succeed({
428 exitCode: ChildProcessSpawner.ExitCode(0),
429 stdout: JSON.stringify({ title: "Pairing expiry", body: "Preserve remote access" }),
430 stderr: "",
431 stdoutTruncated: false,
432 stderrTruncated: false,
433 });
434 },417 },
435 });418 );
436 const lookup = provider.resolveLink?.({419 const lookup = provider.resolveLink?.({
437 cwd: "/unrelated",420 cwd: "/unrelated",
438 url: new URL(`https://github.com/owner/repo/${kind}/42`),421 url: new URL(`https://github.com/owner/repo/${kind}/42`),
...@@ -456,23 +439,20 @@ it.effect.each(["read", "decode"] as const)(...@@ -456,23 +439,20 @@ it.effect.each(["read", "decode"] as const)(
456 "retains the %s failure without exposing its raw contents",439 "retains the %s failure without exposing its raw contents",
457 (stage) =>440 (stage) =>
458 Effect.gen(function* () {441 Effect.gen(function* () {
459 const cause = new GitHubCli.GitHubCliCommandError({442 const cause = new GitHubApi.GitHubApiResponseError({
460 command: "gh",443 host: "github.com",
461 cwd: "/repo",444 operation: "resolveLink",
462 cause: new Error("private response text"),445 status: 500,
463 });
464 const provider = yield* makeProvider({
465 execute: () =>
466 stage === "read"
467 ? Effect.fail(cause)
468 : Effect.succeed({
469 exitCode: ChildProcessSpawner.ExitCode(0),
470 stdout: "private response text",
471 stderr: "",
472 stdoutTruncated: false,
473 stderrTruncated: false,
474 }),
475 });446 });
447 const provider = yield* makeProvider(
448 {},
449 {
450 rest: () =>
451 stage === "read"
452 ? Effect.fail(cause)
453 : Effect.succeed(restResponse("private response text")),
454 },
455 );
476 const lookup = provider.resolveLink?.({456 const lookup = provider.resolveLink?.({
477 cwd: "/repo",457 cwd: "/repo",
478 url: new URL("https://github.com/owner/repo/issues/42"),458 url: new URL("https://github.com/owner/repo/issues/42"),
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+92-19
...@@ -2,22 +2,33 @@ import * as Schema from "effect/Schema";...@@ -2,22 +2,33 @@ import * as Schema from "effect/Schema";
2import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts";5import * as Result from "effect/Result";
6import {
7 SourceControlProviderError,
8 type ChangeRequest,
9 type SourceControlProviderDiscoveryItem,
10} from "@t3tools/contracts";
11
12import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
613
14import * as GitHubApi from "./GitHubApi.ts";
7import * as GitHubCli from "./GitHubCli.ts";15import * as GitHubCli from "./GitHubCli.ts";
8import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";16import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
9import * as SourceControlProvider from "./SourceControlProvider.ts";17import * as SourceControlProvider from "./SourceControlProvider.ts";
10import {18import {
11 combinedAuthOutput,19 combinedAuthOutput,
12 firstSafeAuthLine,20 firstSafeAuthLine,
21 probeSourceControlProvider,
13 providerAuth,22 providerAuth,
14 type SourceControlAuthProbeInput,23 type SourceControlAuthProbeInput,
15 type SourceControlCliDiscoverySpec,24 type SourceControlCliDiscoverySpec,
25 type SourceControlManagedCliDiscoverySpec,
16} from "./SourceControlProviderDiscovery.ts";26} from "./SourceControlProviderDiscovery.ts";
27import * as VcsProcess from "../vcs/VcsProcess.ts";
1728
18const decodeLinkSubject = Schema.decodeUnknownEffect(29const decodeLinkSubject = Schema.decodeUnknownEffect(
19 Schema.fromJsonString(30 Schema.fromJsonString(
20 Schema.Struct({ title: Schema.String, body: Schema.NullOr(Schema.String) }),31 Schema.Struct({ title: Schema.String, body: Schema.optional(Schema.NullOr(Schema.String)) }),
21 ),32 ),
22);33);
2334
...@@ -111,8 +122,70 @@ export const discovery = {...@@ -111,8 +122,70 @@ export const discovery = {
111 "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).",122 "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).",
112} satisfies SourceControlCliDiscoverySpec;123} satisfies SourceControlCliDiscoverySpec;
113124
125const decodeViewer = Schema.decodeUnknownOption(
126 Schema.fromJsonString(Schema.Struct({ login: Schema.String })),
127);
128
129/** The environment variable gh would take a github.com token from, if one is set. */
130function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null {
131 return ["GH_TOKEN", "GITHUB_TOKEN"].find((name) => environment[name]?.trim()) ?? null;
132}
133
134/**
135 * GitHub is usable with a token from the environment or with `gh` to hand one over. An
136 * environment token is checked against the API, since `gh auth status` may not know it.
137 */
138export const makeDiscovery = Effect.gen(function* () {
139 const api = yield* GitHubApi.GitHubApi;
140 const process = yield* VcsProcess.VcsProcess;
141 const environment = yield* HostProcessEnvironment;
142 return {
143 type: "managed-cli",
144 kind: discovery.kind,
145 label: discovery.label,
146 installHint: discovery.installHint,
147 probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) {
148 const cli = yield* probeSourceControlProvider({ cwd, process, spec: discovery });
149 const variable = environmentTokenVariable(environment);
150 if (variable === null) return cli;
151 const viewer = yield* api
152 .rest({ host: "github.com", operation: "discovery", path: "user" })
153 .pipe(Effect.result);
154 const login = Result.isSuccess(viewer)
155 ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login
156 : undefined;
157 return {
158 ...cli,
159 status: "available" as const,
160 auth:
161 login !== undefined
162 ? providerAuth({
163 status: "authenticated",
164 account: login,
165 host: "github.com",
166 detail: `Using the token in ${variable} from the server environment.`,
167 })
168 : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError"
169 ? // Only a refusal says the token is bad; a network error or a pause says nothing.
170 providerAuth({
171 status: "unknown",
172 host: "github.com",
173 detail: `Could not check the token in ${variable}: ${viewer.failure.message}`,
174 })
175 : providerAuth({
176 status: "unauthenticated",
177 host: "github.com",
178 detail: `GitHub refused the token in ${variable}. Replace it, or unset it to use \`gh auth login\`.`,
179 }),
180 } satisfies SourceControlProviderDiscoveryItem;
181 }),
182 refineUnknownRemote: () => Effect.succeed(null),
183 } satisfies SourceControlManagedCliDiscoverySpec;
184});
185
114export const make = Effect.gen(function* () {186export const make = Effect.gen(function* () {
115 const github = yield* GitHubCli.GitHubCli;187 const github = yield* GitHubCli.GitHubCli;
188 const api = yield* GitHubApi.GitHubApi;
116189
117 const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] =190 const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] =
118 (input) => {191 (input) => {
...@@ -138,7 +211,7 @@ export const make = Effect.gen(function* () {...@@ -138,7 +211,7 @@ export const make = Effect.gen(function* () {
138 reference: SourceControlProvider.transportSafeSourceControlErrorValue(211 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
139 input.headSelector,212 input.headSelector,
140 ),213 ),
141 detail: error.detail,214 detail: error.message,
142 cause: error,215 cause: error,
143 }),216 }),
144 ),217 ),
...@@ -177,7 +250,7 @@ export const make = Effect.gen(function* () {...@@ -177,7 +250,7 @@ export const make = Effect.gen(function* () {
177 reference: SourceControlProvider.transportSafeSourceControlErrorValue(250 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
178 input.headSelector,251 input.headSelector,
179 ),252 ),
180 detail: error.detail,253 detail: error.message,
181 cause: error,254 cause: error,
182 }),255 }),
183 ),256 ),
...@@ -188,15 +261,15 @@ export const make = Effect.gen(function* () {...@@ -188,15 +261,15 @@ export const make = Effect.gen(function* () {
188 input: { readonly cwd: string; readonly url: URL },261 input: { readonly cwd: string; readonly url: URL },
189 endpoint: string,262 endpoint: string,
190 ) {263 ) {
191 const result = yield* github264 const result = yield* api
192 .execute({265 .rest({
193 cwd: input.cwd,266 host: input.url.host,
194 args: ["api", "--hostname", input.url.host, endpoint, "--jq", "{title, body}"],267 operation: "resolveLink",
195 env: { GH_PROMPT_DISABLED: "1" },268 path: endpoint,
196 timeoutMs: 3_000,269 maxResponseBytes: 1_000_000,
197 maxOutputBytes: 32_000,
198 })270 })
199 .pipe(271 .pipe(
272 Effect.timeout("3 seconds"),
200 Effect.mapError(273 Effect.mapError(
201 (cause) =>274 (cause) =>
202 new SourceControlProviderError({275 new SourceControlProviderError({
...@@ -208,7 +281,7 @@ export const make = Effect.gen(function* () {...@@ -208,7 +281,7 @@ export const make = Effect.gen(function* () {
208 }),281 }),
209 ),282 ),
210 );283 );
211 const subject = yield* decodeLinkSubject(result.stdout).pipe(284 const subject = yield* decodeLinkSubject(result.body).pipe(
212 Effect.mapError(285 Effect.mapError(
213 (cause) =>286 (cause) =>
214 new SourceControlProviderError({287 new SourceControlProviderError({
...@@ -220,7 +293,7 @@ export const make = Effect.gen(function* () {...@@ -220,7 +293,7 @@ export const make = Effect.gen(function* () {
220 }),293 }),
221 ),294 ),
222 );295 );
223 return { title: subject.title, body: subject.body };296 return { title: subject.title, body: subject.body ?? null };
224 });297 });
225298
226 return SourceControlProvider.SourceControlProvider.of({299 return SourceControlProvider.SourceControlProvider.of({
...@@ -255,7 +328,7 @@ export const make = Effect.gen(function* () {...@@ -255,7 +328,7 @@ export const make = Effect.gen(function* () {
255 reference: SourceControlProvider.transportSafeSourceControlErrorValue(328 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
256 input.reference,329 input.reference,
257 ),330 ),
258 detail: error.detail,331 detail: error.message,
259 cause: error,332 cause: error,
260 }),333 }),
261 ),334 ),
...@@ -280,7 +353,7 @@ export const make = Effect.gen(function* () {...@@ -280,7 +353,7 @@ export const make = Effect.gen(function* () {
280 reference: SourceControlProvider.transportSafeSourceControlErrorValue(353 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
281 input.headSelector,354 input.headSelector,
282 ),355 ),
283 detail: error.detail,356 detail: error.message,
284 cause: error,357 cause: error,
285 }),358 }),
286 ),359 ),
...@@ -297,7 +370,7 @@ export const make = Effect.gen(function* () {...@@ -297,7 +370,7 @@ export const make = Effect.gen(function* () {
297 repository: SourceControlProvider.transportSafeSourceControlErrorValue(370 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
298 input.repository,371 input.repository,
299 ),372 ),
300 detail: error.detail,373 detail: error.message,
301 cause: error,374 cause: error,
302 }),375 }),
303 ),376 ),
...@@ -314,7 +387,7 @@ export const make = Effect.gen(function* () {...@@ -314,7 +387,7 @@ export const make = Effect.gen(function* () {
314 repository: SourceControlProvider.transportSafeSourceControlErrorValue(387 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
315 input.repository,388 input.repository,
316 ),389 ),
317 detail: error.detail,390 detail: error.message,
318 cause: error,391 cause: error,
319 }),392 }),
320 ),393 ),
...@@ -335,7 +408,7 @@ export const make = Effect.gen(function* () {...@@ -335,7 +408,7 @@ export const make = Effect.gen(function* () {
335 operation: "getDefaultBranch",408 operation: "getDefaultBranch",
336 command: error.command,409 command: error.command,
337 cwd: input.cwd,410 cwd: input.cwd,
338 detail: error.detail,411 detail: error.message,
339 cause: error,412 cause: error,
340 }),413 }),
341 ),414 ),
...@@ -352,7 +425,7 @@ export const make = Effect.gen(function* () {...@@ -352,7 +425,7 @@ export const make = Effect.gen(function* () {
352 reference: SourceControlProvider.transportSafeSourceControlErrorValue(425 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
353 input.reference,426 input.reference,
354 ),427 ),
355 detail: error.detail,428 detail: error.message,
356 cause: error,429 cause: error,
357 }),430 }),
358 ),431 ),
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+2
...@@ -16,6 +16,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";...@@ -16,6 +16,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
16import * as VcsProcess from "../vcs/VcsProcess.ts";16import * as VcsProcess from "../vcs/VcsProcess.ts";
17import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";17import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
18import * as BitbucketApi from "./BitbucketApi.ts";18import * as BitbucketApi from "./BitbucketApi.ts";
19import * as GitHubApi from "./GitHubApi.ts";
19import * as GitHubCli from "./GitHubCli.ts";20import * as GitHubCli from "./GitHubCli.ts";
20import * as GitLabCli from "./GitLabCli.ts";21import * as GitLabCli from "./GitLabCli.ts";
21import * as ForgejoCli from "./ForgejoCli.ts";22import * as ForgejoCli from "./ForgejoCli.ts";
...@@ -38,6 +39,7 @@ const layerSourceControlProviderRegistryTest = (input: {...@@ -38,6 +39,7 @@ const layerSourceControlProviderRegistryTest = (input: {
38 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),39 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
39 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),40 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
40 Layer.mock(GitHubCli.GitHubCli)({}),41 Layer.mock(GitHubCli.GitHubCli)({}),
42 Layer.mock(GitHubApi.GitHubApi)({}),
41 Layer.mock(GitLabCli.GitLabCli)({}),43 Layer.mock(GitLabCli.GitLabCli)({}),
42 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),44 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
43 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),45 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+12-3
...@@ -13,6 +13,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";...@@ -13,6 +13,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
13import * as VcsProcess from "../vcs/VcsProcess.ts";13import * as VcsProcess from "../vcs/VcsProcess.ts";
14import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";14import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
15import * as BitbucketApi from "./BitbucketApi.ts";15import * as BitbucketApi from "./BitbucketApi.ts";
16import * as GitHubApi from "./GitHubApi.ts";
16import * as GitHubCli from "./GitHubCli.ts";17import * as GitHubCli from "./GitHubCli.ts";
17import * as GitLabCli from "./GitLabCli.ts";18import * as GitLabCli from "./GitLabCli.ts";
18import * as ForgejoCli from "./ForgejoCli.ts";19import * as ForgejoCli from "./ForgejoCli.ts";
...@@ -41,6 +42,7 @@ function makeRegistry(input: {...@@ -41,6 +42,7 @@ function makeRegistry(input: {
41 }>;42 }>;
42 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;43 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;
43 readonly github?: Partial<GitHubCli.GitHubCli["Service"]>;44 readonly github?: Partial<GitHubCli.GitHubCli["Service"]>;
45 readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>;
44 readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>;46 readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>;
45 readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];47 readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];
46}) {48}) {
...@@ -95,6 +97,7 @@ function makeRegistry(input: {...@@ -95,6 +97,7 @@ function makeRegistry(input: {
95 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),97 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
96 Layer.mock(BitbucketApi.BitbucketApi)({}),98 Layer.mock(BitbucketApi.BitbucketApi)({}),
97 Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}),99 Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}),
100 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),
98 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),101 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
99 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),102 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
100 ServerConfig.layerTest(process.cwd(), {103 ServerConfig.layerTest(process.cwd(), {
...@@ -305,9 +308,15 @@ it.effect(...@@ -305,9 +308,15 @@ it.effect(
305 Effect.gen(function* () {308 Effect.gen(function* () {
306 const registry = yield* makeRegistry({309 const registry = yield* makeRegistry({
307 remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }],310 remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }],
308 github: {311 githubApi: {
309 execute: () =>312 rest: () =>
310 Effect.succeed(processOutput(JSON.stringify({ title: "GitHub issue", body: null }))),313 Effect.succeed({
314 status: 200,
315 headers: {},
316 body: JSON.stringify({ title: "GitHub issue", body: null }),
317 truncated: false,
318 invalidUtf8: false,
319 }),
311 },320 },
312 gitlab: {321 gitlab: {
313 execute: () =>322 execute: () =>
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+2-1
...@@ -305,6 +305,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -305,6 +305,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
305305
306export const make = Effect.gen(function* () {306export const make = Effect.gen(function* () {
307 const github = yield* GitHubSourceControlProvider.make;307 const github = yield* GitHubSourceControlProvider.make;
308 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;
308 const gitlab = yield* GitLabSourceControlProvider.make;309 const gitlab = yield* GitLabSourceControlProvider.make;
309 const forgejo = yield* ForgejoSourceControlProvider.make;310 const forgejo = yield* ForgejoSourceControlProvider.make;
310 const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery;311 const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery;
...@@ -315,7 +316,7 @@ export const make = Effect.gen(function* () {...@@ -315,7 +316,7 @@ export const make = Effect.gen(function* () {
315 {316 {
316 kind: "github",317 kind: "github",
317 provider: github,318 provider: github,
318 discovery: GitHubSourceControlProvider.discovery,319 discovery: githubDiscovery,
319 },320 },
320 {321 {
321 kind: "gitlab",322 kind: "gitlab",
apps/server/src/sourceControl/gitHubPullRequests.ts-11
...@@ -111,7 +111,6 @@ function normalizeGitHubPullRequestRecord(...@@ -111,7 +111,6 @@ function normalizeGitHubPullRequestRecord(
111}111}
112112
113const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));113const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
114const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema);
115const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema);114const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema);
116115
117/**116/**
...@@ -139,13 +138,3 @@ export function decodeGitHubPullRequestListJson(...@@ -139,13 +138,3 @@ export function decodeGitHubPullRequestListJson(
139> {138> {
140 return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries);139 return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries);
141}140}
142
143export function decodeGitHubPullRequestJson(
144 raw: string,
145): Result.Result<NormalizedGitHubPullRequestRecord, Cause.Cause<Schema.SchemaError>> {
146 const result = decodeGitHubPullRequest(raw);
147 if (Result.isSuccess(result)) {
148 return Result.succeed(normalizeGitHubPullRequestRecord(result.success));
149 }
150 return Result.fail(result.failure);
151}