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";
1616import * as Effect from "effect/Effect";
1717import * as FileSystem from "effect/FileSystem";
1818import * as Layer from "effect/Layer";
19import * as Redacted from "effect/Redacted";
1920import * as Path from "effect/Path";
2021import * as PlatformError from "effect/PlatformError";
2122import * as Schema from "effect/Schema";
2223import * as TestClock from "effect/testing/TestClock";
2324import { HttpClient, HttpClientResponse, HttpServerResponse } from "effect/http";
24import { ChildProcessSpawner } from "effect/process";
2525import { vi } from "vite-plus/test";
2626
2727import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
......@@ -35,7 +35,7 @@ import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.t
3535import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
3636import { openMediaFile } from "./MediaFile.ts";
3737import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
38import * as GitHubCli from "../sourceControl/GitHubCli.ts";
38import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
3939import { githubMediaResponse } from "./GitHubMediaFetch.ts";
4040
4141vi.mock("node:fs/promises", async (importOriginal) => {
......@@ -126,7 +126,7 @@ const layerTest = Layer.mergeAll(
126126).pipe(Layer.provideMerge(NodeServices.layer));
127127
128128describe("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", () => {
130130 let lookups = 0;
131131 const authorizations: Array<string | undefined> = [];
132132 return Effect.gen(function* () {
......@@ -138,19 +138,21 @@ describe("AssetAccess", () => {
138138 expect((yield* githubMediaResponse(asset, {})).status).toBe(404);
139139 expect((yield* githubMediaResponse(asset, {})).status).toBe(200);
140140 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);
142143 expect(authorizations).toEqual([undefined, "Bearer signed-in", "Bearer signed-in"]);
143144 }).pipe(
144145 Effect.provide(
145 Layer.mock(GitHubCli.GitHubCli)({
146 execute: () =>
147 Effect.sync(() => ({
148 exitCode: ChildProcessSpawner.ExitCode(0),
149 stdout: ++lookups === 1 ? "" : "signed-in",
150 stderr: "",
151 stdoutTruncated: false,
152 stderrTruncated: false,
153 })),
146 Layer.mock(GitHubCredentials.GitHubCredentials)({
147 get: (host) =>
148 ++lookups === 1
149 ? Effect.fail(new GitHubCredentials.GitHubNotSignedInError({ host }))
150 : Effect.succeed({
151 host,
152 token: Redacted.make("signed-in"),
153 source: "gh" as const,
154 fingerprint: "fingerprint",
155 }),
154156 }),
155157 ),
156158 Effect.provideService(
apps/server/src/assets/GitHubMediaFetch.ts+9-42
......@@ -12,7 +12,7 @@ import {
1212 type HttpClientResponse,
1313} from "effect/http";
1414
15import * as GitHubCli from "../sourceControl/GitHubCli.ts";
15import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts";
1616
1717/**
1818 * Exactly the hosts the credential is for. Everything a redirect leads to — the presigned
......@@ -37,8 +37,6 @@ const isCredentialedHost = (url: string) => {
3737const MAX_REDIRECTS = 3;
3838/** Following the redirect here, rather than in `fetch`, is what keeps the token on GitHub. */
3939const MANUAL_REDIRECT: RequestInit = { redirect: "manual" };
40const TOKEN_CACHE_TTL_MS = 5 * 60_000;
41const TOKEN_CACHE_MAX_ENTRIES = 32;
4240/** Passed through so a seek in a long video costs one upstream range request, not a full download. */
4341const FORWARDED_REQUEST_HEADERS = ["range", "if-range"] as const;
4442const FORWARDED_RESPONSE_HEADERS = [
......@@ -57,45 +55,14 @@ const SVG_CONTENT_TYPE = "image/svg+xml";
5755const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
5856
5957/**
60 * A media request per image and one per video seek, each of which would otherwise spawn `gh`.
61 * The token is what `gh auth token` would print again on the next call, and it is held no longer
62 * than a signed asset URL lives.
58 * The github.com credential, or null without one: a public asset still loads, and a private one
59 * fails the way it does in a browser that is not signed in.
6360 */
64const tokenCache = new Map<string, { readonly at: number; readonly token: Redacted.Redacted }>();
65
66const githubToken = Effect.fn("GitHubMediaFetch.githubToken")(function* (input: {
67 readonly cwd: string;
68 readonly host: string;
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});
61const githubToken = GitHubCredentials.GitHubCredentials.pipe(
62 Effect.flatMap((credentials) => credentials.get("github.com")),
63 Effect.map((credential) => credential.token),
64 Effect.orElseSucceed(() => null),
65);
9966
10067/**
10168 * 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
146113 requestHeaders: Record<string, string | undefined>,
147114) {
148115 // 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;
150117 const forwarded: Record<string, string> = {};
151118 for (const name of FORWARDED_REQUEST_HEADERS) {
152119 const value = requestHeaders[name];
apps/server/src/git/GitManager.test.ts+13-5
......@@ -36,6 +36,7 @@ import {
3636 ThreadId,
3737} from "@t3tools/contracts";
3838import * as DateTime from "effect/DateTime";
39import * as GitHubApi from "../sourceControl/GitHubApi.ts";
3940import * as GitHubCli from "../sourceControl/GitHubCli.ts";
4041import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts";
4142import * as GitLabCli from "../sourceControl/GitLabCli.ts";
......@@ -383,7 +384,11 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
383384 );
384385 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> => {
387392 const args = [...input.args];
388393 ghCalls.push(args.join(" "));
389394
......@@ -518,7 +523,6 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
518523
519524 return {
520525 service: {
521 execute,
522526 // The fake answers the CLI shape, so batched lookups read it the way the fallback does.
523527 listPullRequestsByHead: (input) =>
524528 execute({
......@@ -724,7 +728,12 @@ function makeManager(input?: {
724728 discover: Effect.succeed([]),
725729 }),
726730 ),
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 ),
728737 ),
729738 );
730739
......@@ -2660,8 +2669,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
26602669 provider: "github",
26612670 providerOperation: "listChangeRequests",
26622671 providerCommand: "gh",
2663 errorDetail:
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.",
2672 errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.",
26652673 });
26662674 const loggedText = [
26672675 warning?.message ?? "",
apps/server/src/server.ts+2-1
......@@ -562,7 +562,8 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
562562 Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),
563563 Layer.provideMerge(layerServerSettings),
564564 // 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),
566567 Layer.provideMerge(layerGit),
567568 Layer.provideMerge(layerVcs),
568569 Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)),
apps/server/src/sourceControl/GitHubApi.ts+2-1
......@@ -145,6 +145,7 @@ export interface GitHubGraphQlInput {
145145 readonly query: string;
146146 readonly variables?: Readonly<Record<string, unknown>>;
147147 readonly allowReserve?: boolean;
148 readonly maxResponseBytes?: number;
148149}
149150
150151export class GitHubApi extends Context.Service<
......@@ -567,7 +568,7 @@ export const make = Effect.gen(function* () {
567568 HttpClientRequest.acceptJson,
568569 HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }),
569570 ),
570 maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES,
571 maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
571572 allowReserve,
572573 acceptNotModified: false,
573574 graphql: true,
apps/server/src/sourceControl/GitHubCli.test.ts+431-831
......@@ -1,157 +1,109 @@
1import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest";
2import * as Cache from "effect/Cache";
1import { assert, it, describe } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
33import * as TestClock from "effect/testing/TestClock";
4import * as Clock from "effect/Clock";
5import * as DateTime from "effect/DateTime";
64import * as Effect from "effect/Effect";
75import * as Fiber from "effect/Fiber";
6import * as FileSystem from "effect/FileSystem";
87import * as Layer from "effect/Layer";
9import * as PlatformError from "effect/PlatformError";
10import * as Redacted from "effect/Redacted";
118import * as Schema from "effect/Schema";
129import { ChildProcessSpawner } from "effect/process";
13import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
1410
11import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1512import * as VcsProcess from "../vcs/VcsProcess.ts";
13import * as GitHubApi from "./GitHubApi.ts";
1614import * as GitHubCli from "./GitHubCli.ts";
17import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
18import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
15import * as GitHubCredentials from "./GitHubCredentials.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 => ({
23 exitCode: ChildProcessSpawner.ExitCode(0),
19const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({
20 exitCode: ChildProcessSpawner.ExitCode(exitCode),
2421 stdout,
2522 stderr: "",
2623 stdoutTruncated: false,
2724 stderrTruncated: false,
2825});
2926
30const quotaOutput = (remaining = 5000, resetAt = "2099-01-01T00:00:00Z") =>
31 processOutput(
32 JSON.stringify({ data: { rateLimit: { cost: 1, limit: 5000, remaining, resetAt } } }),
33 );
34
35const isBudgetReading = (input: VcsProcess.VcsProcessInput) =>
36 input.args[0] === "api" &&
37 input.args[1] === "graphql" &&
38 input.args.at(-1)?.includes("rateLimit");
39
40const mockRun = vi.fn<VcsProcess.VcsProcess["Service"]["run"]>();
41
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);
27const remotesOutput = (...entries: ReadonlyArray<readonly [string, string]>) =>
28 entries
29 .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`])
30 .join("\n");
31
32const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({
33 status,
34 headers: {},
35 body: body === undefined ? "" : encodeJson(body),
36 truncated: false,
37 invalidUtf8: false,
38});
5039
51afterEach(() => {
52 mockRun.mockReset();
40const node = (number: number, headRefName: string, owner = "acme") => ({
41 number,
42 title: `PR ${number}`,
43 url: `https://github.com/acme/web/pull/${number}`,
44 baseRefName: "main",
45 headRefName,
46 state: "OPEN",
47 isCrossRepository: owner !== "acme",
48 updatedAt: "2026-01-02T00:00:00Z",
49 headRepository: { name: "web", nameWithOwner: `${owner}/web` },
50 headRepositoryOwner: { login: owner },
5351});
5452
55it.effect("reads the GraphQL budget once per window, preserves the reserve, and resumes", () =>
56 Effect.gen(function* () {
57 let readings = 0;
58 const commands: string[] = [];
59 let remaining = 501;
60 let resetAt = DateTime.formatIso(
61 DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + 60_000),
62 );
63 const gh = yield* GitHubCli.make.pipe(
64 Effect.provideService(VcsProcess.VcsProcess, {
65 run: (input) =>
66 Effect.sync(() => {
67 if (isBudgetReading(input)) {
68 readings++;
69 assert.strictEqual(input.args[3], "enterprise.test");
70 return quotaOutput(remaining, resetAt);
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"],
53/**
54 * A GitHubCli over a mocked GitHubApi, git driver and process. `remotes` is what
55 * `git remote -v` prints; `git` records every driver call.
56 */
57function harness(input: {
58 readonly remotes: string;
59 readonly api: Partial<GitHubApi.GitHubApi["Service"]>;
60 readonly localBranches?: ReadonlyArray<string>;
61}) {
62 const git: Array<readonly [string, unknown]> = [];
63 const record =
64 <A>(name: string, value: A) =>
65 (args: unknown) =>
66 Effect.sync(() => {
67 git.push([name, args]);
68 return value;
8469 });
85 yield* read("list");
86 const failure = yield* read("view").pipe(Effect.flip);
87 assert.strictEqual(failure._tag, "GitHubCliRateLimitError");
88 assert.deepStrictEqual(commands, ["pr list"]);
89 yield* read("view").pipe(Effect.provideService(GitHubCli.AllowGitHubReserve, true));
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 }),
70 const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({
71 execute: (args) =>
72 Effect.sync(() => {
73 git.push(["execute", args.args]);
74 return processOutput("");
11675 }),
117 );
118 const read = gh.execute({ cwd: "/repo", args: ["pr", "list"] });
119 yield* read;
120 // The window resets ten seconds in, so the reading expires with it.
121 resetAt = DateTime.formatIso(DateTime.makeUnsafe(startedAt + 10_000 + 3_600_000));
122 yield* TestClock.adjust("10 seconds");
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("[]")),
76 resolvePrimaryRemoteName: () => Effect.succeed("origin"),
77 readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"),
78 ensureRemote: (args) =>
79 Effect.sync(() => {
80 git.push(["ensureRemote", args]);
81 return args.preferredName;
14982 }),
150 );
151 const result = yield* gh.execute({ cwd: "/repo", args: ["pr", "list"] });
152 assert.strictEqual(result.stdout, "[]");
153 }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
154);
83 fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args),
84 setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args),
85 switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never,
86 listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]),
87 resolveCommit: () => Effect.succeed({ commitSha: "abc123" }),
88 });
89 const process = Layer.mock(VcsProcess.VcsProcess)({
90 run: (args) =>
91 Effect.succeed(
92 args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1),
93 ),
94 });
95 const layer = Layer.effect(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
156108describe("selectGitHubBaseRepository", () => {
157109 const remotes = (...entries: ReadonlyArray<readonly [string, string]>) =>
......@@ -223,50 +175,97 @@ describe("selectGitHubBaseRepository", () => {
223175 });
224176});
225177
226describe("GitHubCli.listPullRequestsByHead", () => {
227 const remoteOutput =
228 "origin\tgit@github.com:acme/web.git (fetch)\norigin\tgit@github.com:acme/web.git (push)\n";
229 const node = (number: number, headRefName: string) => ({
230 number,
231 title: `PR ${number}`,
232 url: `https://github.com/acme/web/pull/${number}`,
233 baseRefName: "main",
234 headRefName,
235 state: "MERGED",
236 mergedAt: "2026-01-01T00:00:00Z",
237 updatedAt: "2026-01-02T00:00:00Z",
238 headRepository: { name: "web", nameWithOwner: "acme/web" },
239 headRepositoryOwner: { login: "acme" },
178describe("GitHubCli repository resolution", () => {
179 it.effect("reads the repository gh would pick from the remotes", () => {
180 const paths: string[] = [];
181 const { layer } = harness({
182 remotes: remotesOutput(
183 ["origin", "git@github.com:me/web.git"],
184 ["upstream", "https://github.com/acme/web.git"],
185 ),
186 api: {
187 rest: (input) =>
188 Effect.sync(() => {
189 paths.push(`${input.host} ${input.path}`);
190 return restResponse({
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));
240204 });
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", () =>
256 Effect.gen(function* () {
257 const documents: Array<{ query: string; variables: Record<string, unknown> }> = [];
258 mockRun.mockImplementation((input) =>
259 Effect.sync(() => {
260 if (input.command === "git") return git(input);
261 documents.push(decodeRequest(input.stdin ?? ""));
262 return jsonOutput({
263 data: {
264 repository: { h0: { nodes: [node(7, "feature/a")] }, h1: { nodes: [] } },
265 rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2099-01-01T00:00:00Z" },
266 },
267 });
268 }),
206 it.effect("reads an SSH alias remote through github.com", () => {
207 const hosts: string[] = [];
208 const { layer } = harness({
209 remotes: remotesOutput(["origin", "git@github:acme/web.git"]),
210 api: {
211 rest: (input) =>
212 Effect.sync(() => {
213 hosts.push(`${input.host} ${input.path}`);
214 return restResponse({
215 full_name: "acme/web",
216 html_url: "https://github.com/acme/web",
217 ssh_url: "git@github.com:acme/web.git",
218 default_branch: "main",
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",
269232 );
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* () {
270269 const gh = yield* GitHubCli.GitHubCli;
271270 const lookups = yield* Effect.all(
272271 ["feature/a", "feature/b"].map((headSelector) =>
......@@ -283,12 +282,11 @@ describe("GitHubCli.listPullRequestsByHead", () => {
283282 yield* TestClock.adjust("50 millis");
284283 const [first, second] = yield* Fiber.join(lookups);
285284 assert.deepStrictEqual(
286 first?.map((pr) => [pr.number, pr.state, pr.headRepositoryNameWithOwner]),
287 [[7, "merged", "acme/web"]],
285 first?.map((pr) => pr.number),
286 [7],
288287 );
289288 assert.deepStrictEqual(second, []);
290289 assert.strictEqual(documents.length, 1);
291 assert.include(documents[0]!.query, "rateLimit");
292290 assert.deepStrictEqual(documents[0]!.variables, {
293291 owner: "acme",
294292 name: "web",
......@@ -297,701 +295,303 @@ describe("GitHubCli.listPullRequestsByHead", () => {
297295 h1: "feature/b",
298296 s1: ["OPEN", "CLOSED", "MERGED"],
299297 });
300 }).pipe(Effect.provide(layer)),
301 );
298 }).pipe(Effect.provide(layer));
299 });
302300
303 it.effect("asks gh pr list when gh could read another repository", () =>
304 Effect.gen(function* () {
305 const commands: Array<ReadonlyArray<string>> = [];
306 mockRun.mockImplementation((input) =>
307 Effect.sync(() => {
308 commands.push([input.command, ...input.args]);
309 if (input.command === "git") {
310 return processOutput(
311 input.args[0] === "remote"
312 ? "a\tgit@github.com:me/web.git (fetch)\nb\tgit@github.com:acme/web.git (fetch)\n"
313 : "",
314 );
315 }
316 return input.args[3] === "feature/empty"
317 ? processOutput("")
318 : jsonOutput([node(8, "feature/a")]);
319 }),
320 );
301 it.effect("matches an owner:branch selector on the head owner", () => {
302 const { layer } = harness({
303 remotes,
304 api: {
305 graphql: (input) =>
306 Effect.succeed(
307 encodeJson({
308 data: {
309 repository: {
310 h0: {
311 nodes:
312 input.variables?.h0 === "main"
313 ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")]
314 : [],
315 },
316 },
317 },
318 }),
319 ),
320 },
321 });
322 return Effect.gen(function* () {
321323 const gh = yield* GitHubCli.GitHubCli;
322 const pullRequests = yield* gh.listPullRequestsByHead({
323 cwd: "/repo",
324 headSelector: "feature/a",
325 state: "all",
326 limit: 100,
327 rateLimitHost: "github.com",
328 });
324 const open = yield* gh
325 .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 })
326 .pipe(Effect.forkChild);
327 yield* TestClock.adjust("50 millis");
329328 assert.deepStrictEqual(
330 pullRequests.map((pr) => pr.number),
329 (yield* Fiber.join(open)).map((pr) => pr.number),
331330 [8],
332331 );
333 assert.deepStrictEqual(commands.at(-1), [
334 "gh",
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 );
332 }).pipe(Effect.provide(layer));
333 });
356334
357 it.effect("fails a rate-limited document whole instead of asking head by head", () =>
358 Effect.gen(function* () {
359 let ghCalls = 0;
360 mockRun.mockImplementation((input) => {
361 if (input.command === "git") return Effect.succeed(git(input));
362 ghCalls++;
363 return Effect.fail(
364 new VcsProcessExitError({
365 operation: "GitHubCli.execute",
366 command: "gh",
367 cwd: "/repo",
368 exitCode: 1,
369 failureKind: "rate-limited",
370 detail: "API rate limit exceeded.",
371 stderrLength: 24,
372 stderrTruncated: false,
373 }),
374 );
375 });
335 it.effect("maps API failures onto the errors callers handle", () => {
336 const { layer } = harness({
337 remotes,
338 api: {
339 graphql: (input) =>
340 Effect.fail(
341 input.variables?.h0 === "missing"
342 ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" })
343 : new GitHubApi.GitHubApiRateLimitError({
344 host: "github.com",
345 operation: "x",
346 retryAt: 123,
347 }),
348 ),
349 },
350 });
351 return Effect.gen(function* () {
376352 const gh = yield* GitHubCli.GitHubCli;
377 const lookups = yield* Effect.all(
378 ["feature/a", "feature/b"].map((headSelector) =>
379 gh
380 .listPullRequestsByHead({
381 cwd: "/repo",
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);
353 const read = (headSelector: string) =>
354 gh
355 .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 })
356 .pipe(Effect.flip, Effect.forkChild);
357 const missing = yield* read("missing");
391358 yield* TestClock.adjust("50 millis");
392 const errors = yield* Fiber.join(lookups);
393 assert.deepStrictEqual(
394 errors.map((error) => error._tag),
395 ["GitHubCliRateLimitError", "GitHubCliRateLimitError"],
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);
359 assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError");
360 const limited = yield* read("limited");
361 yield* TestClock.adjust("50 millis");
362 const error = yield* Fiber.join(limited);
409363 assert.strictEqual(error._tag, "GitHubCliRateLimitError");
410 expect(mockRun).not.toHaveBeenCalled();
411 }).pipe(Effect.provide(layer.pipe(Layer.provide(GitHubGraphQlBudget.layer)))),
412 );
364 assert.propertyVal(error, "retryAt", 123);
365 }).pipe(Effect.provide(layer));
366 });
367});
413368
414 it.effect("keeps quota snapshots separate for verified credentials on the same host", () =>
415 Effect.gen(function* () {
416 let reads = 0;
417 const gh = yield* GitHubCli.make.pipe(
418 Effect.provideService(VcsProcess.VcsProcess, {
419 run: (input) =>
420 Effect.sync(() => {
421 if (isBudgetReading(input)) {
422 return quotaOutput(input.env?.GH_TOKEN === "empty" ? 0 : 5000);
423 }
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,
369describe("GitHubCli.getPullRequest", () => {
370 it.effect("reads a pull request by number, and by URL on its own repository", () => {
371 const variables: Array<unknown> = [];
372 const { layer } = harness({
373 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
374 api: {
375 graphql: (input) =>
376 Effect.sync(() => {
377 variables.push(input.variables);
378 return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } });
435379 }),
436 );
437 yield* read("empty").pipe(Effect.flip);
438 yield* read("healthy");
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 );
380 },
381 });
382 return Effect.gen(function* () {
449383 const gh = yield* GitHubCli.GitHubCli;
450 // Constructed outside either request, like the PR service's read caches.
451 const cache = yield* Cache.make({
452 lookup: (host: string) =>
453 gh.execute({
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",
384 assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42);
385 yield* gh.getPullRequest({
386 cwd: "/repo",
387 reference: "https://github.com/other/thing/pull/42",
460388 });
461 const results = yield* Effect.forEach(
462 ["github.com", "github.example.test"],
463 (host, index) =>
464 Cache.get(cache, host).pipe(
465 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
466 host,
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 );
389 assert.deepStrictEqual(variables, [
390 { owner: "acme", name: "web", number: 42 },
391 { owner: "other", name: "thing", number: 42 },
392 ]);
393 }).pipe(Effect.provide(layer));
394 });
486395
487 it.effect("refuses other or implicit hosts before exposing a scoped credential to gh", () =>
488 Effect.gen(function* () {
396 it.effect("fails a missing pull request as not found", () => {
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* () {
489404 const gh = yield* GitHubCli.GitHubCli;
490 for (const args of [
491 ["api", "user", "--hostname", "other.example.test"],
492 ["api", "user", "--hostname=other.example.test"],
493 ["pr", "view", "1", "--repo", "other.example.test/owner/repo"],
494 ["repo", "view", "other.example.test/owner/repo", "--json", "name"],
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 );
405 const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip);
406 assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError");
407 }).pipe(Effect.provide(layer));
408 });
409});
512410
513 it.effect("pins repository-targeted writes on enterprise hosts", () =>
514 Effect.gen(function* () {
515 mockRun.mockReturnValue(Effect.succeed(processOutput("")));
516 const gh = yield* GitHubCli.GitHubCli;
517 yield* gh
518 .execute({
519 cwd: "/repo",
520 args: ["pr", "merge", "1", "--repo", "github.example.test/owner/repo"],
521 })
522 .pipe(
523 Effect.provideService(GitHubCli.PinnedGitHubCredential, {
524 host: "github.example.test",
525 token: Redacted.make("enterprise-credential"),
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",
411describe("GitHubCli writes", () => {
412 it.effect("creates a cross-repository pull request with an owner:branch head", () => {
413 const requests: Array<GitHubApi.GitHubRestInput> = [];
414 const { layer } = harness({
415 remotes: remotesOutput(
416 ["origin", "git@github.com:me/web.git"],
417 ["upstream", "git@github.com:acme/web.git"],
418 ),
419 api: {
420 rest: (input) =>
421 Effect.sync(() => {
422 requests.push(input);
423 return restResponse({ number: 1 }, 201);
539424 }),
540 );
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 }),
425 },
561426 });
562
563 const commandFailure = GitHubCli.fromVcsError(context, missingCwd);
564
565 assert.equal(commandFailure._tag, "GitHubCliCommandError");
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
427 return Effect.gen(function* () {
428 const fs = yield* FileSystem.FileSystem;
429 const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" });
430 yield* fs.writeFileString(bodyFile, "Body");
598431 const gh = yield* GitHubCli.GitHubCli;
599 const result = yield* gh.getPullRequest({
432 yield* gh.createPullRequest({
600433 cwd: "/repo",
601 reference: "#42",
434 baseBranch: "main",
435 headSelector: "me:feature",
436 title: "Title",
437 bodyFile,
602438 });
603
604 assert.deepStrictEqual(result, {
605 number: 42,
606 title: "Add PR thread creation",
607 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
608 baseRefName: "main",
609 headRefName: "feature/pr-threads",
610 state: "open",
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,
439 assert.strictEqual(requests[0]!.method, "POST");
440 assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls");
441 assert.deepStrictEqual(requests[0]!.body, {
442 base: "main",
443 head: "me:feature",
444 title: "Title",
445 body: "Body",
446 maintainer_can_modify: true,
631447 });
632 }).pipe(Effect.provide(layer)),
633 );
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 );
448 }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped);
449 });
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* () {
661470 const gh = yield* GitHubCli.GitHubCli;
662 const result = yield* gh.getPullRequest({
471 const urls = yield* gh.createRepository({
663472 cwd: "/repo",
664 reference: "#42",
473 repository: "acme/new",
474 visibility: "private",
665475 });
666
667 assert.deepStrictEqual(result, {
668 number: 42,
669 title: "Add PR thread creation",
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",
476 assert.deepStrictEqual(urls, {
477 nameWithOwner: "acme/new",
478 url: "https://github.com/acme/new",
479 sshUrl: "git@github.com:acme/new.git",
719480 });
481 assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]);
482 }).pipe(Effect.provide(layer));
483 });
484});
720485
721 assert.deepStrictEqual(result, [
722 {
723 number: 43,
724 title: "Valid PR",
725 url: "https://github.com/pingdotgg/codething-mvp/pull/43",
726 baseRefName: "main",
727 headRefName: "feature/pr-list",
728 state: "open",
729 closedAt: null,
730 mergedAt: null,
731 },
732 ]);
733 }).pipe(Effect.provide(layer)),
734 );
486describe("GitHubCli.checkoutPullRequest", () => {
487 const repository = (fullName: string, defaultBranch = "main") =>
488 restResponse({
489 full_name: fullName,
490 html_url: `https://github.com/${fullName}`,
491 ssh_url: `git@github.com:${fullName}.git`,
492 default_branch: defaultBranch,
493 });
735494
736 it.effect("keeps pull requests from gh versions without headRepository.nameWithOwner", () =>
737 // gh < 2.47 (e.g. Ubuntu-packaged 2.46) exports headRepository as
738 // {id, name} only. These entries must decode instead of being dropped,
739 // with nameWithOwner rebuilt from the owner login.
740 Effect.gen(function* () {
741 mockRun.mockReturnValueOnce(
742 Effect.succeed(
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 ]),
495 it.effect("checks a same-repository pull request out from its head branch", () => {
496 const { layer, git } = harness({
497 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
498 api: {
499 graphql: () =>
500 Effect.succeed(
501 encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }),
765502 ),
766 ),
767 );
768
503 },
504 });
505 return Effect.gen(function* () {
769506 const gh = yield* GitHubCli.GitHubCli;
770 const result = yield* gh.listOpenPullRequests({
771 cwd: "/repo",
772 headSelector: "t3code/codex-turn-mapping",
773 });
774
775 assert.deepStrictEqual(result, [
776 {
777 number: 2829,
778 title: "Codex turn mapping",
779 url: "https://github.com/pingdotgg/codething-mvp/pull/2829",
780 baseRefName: "main",
781 headRefName: "t3code/codex-turn-mapping",
782 state: "open",
783 closedAt: null,
784 mergedAt: null,
785 isCrossRepository: false,
786 headRepositoryNameWithOwner: "pingdotgg/codething-mvp",
787 headRepositoryOwnerLogin: "pingdotgg",
788 },
507 yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" });
508 assert.deepStrictEqual(git, [
509 [
510 "fetchRemoteTrackingBranch",
511 { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" },
512 ],
513 ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]],
514 ["switchRef", { cwd: "/repo", refName: "feature/x" }],
515 [
516 "setBranchUpstream",
517 { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" },
518 ],
789519 ]);
790 }).pipe(Effect.provide(layer)),
791 );
520 }).pipe(Effect.provide(layer));
521 });
792522
793 it.effect("reads repository clone URLs", () =>
794 Effect.gen(function* () {
795 mockRun.mockReturnValueOnce(
796 Effect.succeed(
797 processOutput(
798 // @effect-diagnostics-next-line preferSchemaOverJson:off
799 JSON.stringify({
800 nameWithOwner: "octocat/codething-mvp",
801 url: "https://github.com/octocat/codething-mvp",
802 sshUrl: "git@github.com:octocat/codething-mvp.git",
523 it.effect("refuses a fork checkout when the base's default branch cannot be read", () => {
524 const { layer, git } = harness({
525 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
526 localBranches: ["main"],
527 api: {
528 graphql: () =>
529 Effect.succeed(
530 encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }),
531 ),
532 rest: () =>
533 Effect.fail(
534 new GitHubApi.GitHubApiRequestError({
535 host: "github.com",
536 operation: "x",
537 cause: "offline",
803538 }),
804539 ),
805 ),
806 );
807
540 },
541 });
542 return Effect.gen(function* () {
808543 const gh = yield* GitHubCli.GitHubCli;
809 const result = yield* gh.getRepositoryCloneUrls({
810 cwd: "/repo",
811 repository: "octocat/codething-mvp",
812 });
813
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 );
544 yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }));
545 // Nothing touched the local branches: `main` must not be reset to the fork's commit.
546 assert.deepStrictEqual(git, []);
547 }).pipe(Effect.provide(layer));
548 });
821549
822 it.effect("creates repositories and parses clone URLs from create output", () =>
823 Effect.gen(function* () {
824 mockRun.mockReturnValueOnce(
825 Effect.succeed(
826 processOutput(
827 "✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n",
550 it.effect("adds a remote for a fork and names a default-branch head after its owner", () => {
551 const { layer, git } = harness({
552 remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]),
553 localBranches: ["someone/main"],
554 api: {
555 graphql: () =>
556 Effect.succeed(
557 encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }),
828558 ),
829 ),
830 );
831
832 const gh = yield* GitHubCli.GitHubCli;
833 const result = yield* gh.createRepository({
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
559 rest: (input) =>
560 Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")),
561 },
562 });
563 return Effect.gen(function* () {
887564 const gh = yield* GitHubCli.GitHubCli;
888 const error = yield* gh
889 .getPullRequest({
890 cwd: "/repo",
891 reference: "4888",
892 })
893 .pipe(Effect.flip);
894
895 assert.equal(error.message.includes("Pull request not found"), true);
896 assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError");
897 assert.strictEqual(error.command, "gh");
898 assert.strictEqual(error.cwd, "/repo");
899 assert.strictEqual(error.cause, cause);
900 assert.equal(error.message.includes(cause.detail), false);
901 }).pipe(Effect.provide(layer)),
902 );
565 yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true });
566 assert.deepStrictEqual(git, [
567 [
568 "ensureRemote",
569 { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" },
570 ],
571 [
572 "fetchRemoteTrackingBranch",
573 { cwd: "/repo", remoteName: "someone", remoteBranch: "main" },
574 ],
575 ["switchRef", { cwd: "/repo", refName: "someone/main" }],
576 ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]],
577 [
578 "setBranchUpstream",
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", () =>
905 Effect.gen(function* () {
906 const cause = new VcsProcessExitError({
907 operation: "GitHubCli.execute",
908 command: "gh",
909 cwd: "/repo",
910 exitCode: 1,
911 failureKind: "rate-limited",
912 detail: "API rate limit exceeded.",
913 stderrLength: 82,
914 stderrTruncated: false,
585 it("names the local branch the way gh pr checkout does", () => {
586 const name = (headRefName: string, isCrossRepository: boolean) =>
587 GitHubCli.pullRequestCheckoutBranchName({
588 headRefName,
589 headOwner: "someone",
590 isCrossRepository,
591 defaultBranch: "main",
915592 });
916 mockRun.mockReturnValueOnce(Effect.fail(cause));
917
918 const gh = yield* GitHubCli.GitHubCli;
919 const error = yield* gh
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 );
593 assert.strictEqual(name("main", true), "someone/main");
594 assert.strictEqual(name("feature", true), "feature");
595 assert.strictEqual(name("main", false), "main");
596 });
942597});
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 @@
1import * as Cache from "effect/Cache";
2import * as Clock from "effect/Clock";
3import * as Duration from "effect/Duration";
4import * as Exit from "effect/Exit";
51import * as Context from "effect/Context";
62import * as DateTime from "effect/DateTime";
73import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";
86import * as Layer from "effect/Layer";
97import * as Option from "effect/Option";
10import * as PlatformError from "effect/PlatformError";
11import * as Redacted from "effect/Redacted";
128import * as Request from "effect/Request";
139import * as RequestResolver from "effect/RequestResolver";
1410import * as Result from "effect/Result";
1511import * as Schema from "effect/Schema";
1612
17import {
18 TrimmedNonEmptyString,
19 type SourceControlRepositoryVisibility,
20 type VcsError,
21} from "@t3tools/contracts";
13import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
2214import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
2315import { 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
2524import * as VcsProcess from "../vcs/VcsProcess.ts";
2625import * as GitHubApi from "./GitHubApi.ts";
26import * as GitHubCredentials from "./GitHubCredentials.ts";
2727import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
2828import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
2929import {
3030 decodeGitHubPullRequestEntries,
31 decodeGitHubPullRequestJson,
32 decodeGitHubPullRequestListJson,
3331 type NormalizedGitHubPullRequestRecord,
3432} 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
4134export 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
7536const gitHubCliFailureFields = {
7637 command: Schema.Literal("gh"),
7738 cwd: Schema.String,
......@@ -82,12 +43,8 @@ export class GitHubCliUnavailableError extends Schema.TaggedError<GitHubCliUnava
8243 "GitHubCliUnavailableError",
8344 gitHubCliFailureFields,
8445) {
85 get detail(): string {
86 return "GitHub CLI (`gh`) is required but not available on PATH.";
87 }
88
8946 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.";
9148 }
9249}
9350
......@@ -95,12 +52,8 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError<GitHubCliAu
9552 "GitHubCliAuthenticationError",
9653 gitHubCliFailureFields,
9754) {
98 get detail(): string {
99 return "GitHub CLI is not authenticated. Run `gh auth login` and retry.";
100 }
101
10255 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.";
10457 }
10558}
10659
......@@ -108,12 +61,8 @@ export class GitHubCliRateLimitError extends Schema.TaggedError<GitHubCliRateLim
10861 "GitHubCliRateLimitError",
10962 { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) },
11063) {
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
11564 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.";
11766 }
11867}
11968
......@@ -121,12 +70,8 @@ export class GitHubPullRequestNotFoundError extends Schema.TaggedError<GitHubPul
12170 "GitHubPullRequestNotFoundError",
12271 gitHubCliFailureFields,
12372) {
124 get detail(): string {
125 return "Pull request not found. Check the PR number or URL and try again.";
126 }
127
12873 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.";
13075 }
13176}
13277
......@@ -134,12 +79,13 @@ export class GitHubCliCommandError extends Schema.TaggedError<GitHubCliCommandEr
13479 "GitHubCliCommandError",
13580 { ...gitHubCliFailureFields, httpStatus: Schema.optional(Schema.Int) },
13681) {
137 get detail(): string {
138 return "GitHub CLI command failed.";
139 }
140
14182 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;
14389 }
14490}
14591
......@@ -153,12 +99,8 @@ export class GitHubPullRequestListDecodeError extends Schema.TaggedError<GitHubP
15399 "GitHubPullRequestListDecodeError",
154100 gitHubCliDecodeFields,
155101) {
156 get detail(): string {
157 return "GitHub CLI returned invalid PR list JSON.";
158 }
159
160102 override get message(): string {
161 return `GitHub CLI failed in listOpenPullRequests: ${this.detail}`;
103 return "GitHub returned an invalid pull request list.";
162104 }
163105}
164106
......@@ -166,12 +108,8 @@ export class GitHubChangeRequestListDecodeError extends Schema.TaggedError<GitHu
166108 "GitHubChangeRequestListDecodeError",
167109 gitHubCliDecodeFields,
168110) {
169 get detail(): string {
170 return "GitHub CLI returned invalid change request JSON.";
171 }
172
173111 override get message(): string {
174 return `GitHub CLI failed in listChangeRequests: ${this.detail}`;
112 return "GitHub returned an invalid change request list.";
175113 }
176114}
177115
......@@ -179,12 +117,8 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedError<GitHubPullR
179117 "GitHubPullRequestDecodeError",
180118 gitHubCliDecodeFields,
181119) {
182 get detail(): string {
183 return "GitHub CLI returned invalid pull request JSON.";
184 }
185
186120 override get message(): string {
187 return `GitHub CLI failed in getPullRequest: ${this.detail}`;
121 return "GitHub returned an invalid pull request.";
188122 }
189123}
190124
......@@ -192,12 +126,8 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubReposi
192126 "GitHubRepositoryDecodeError",
193127 gitHubCliDecodeFields,
194128) {
195 get detail(): string {
196 return "GitHub CLI returned invalid repository JSON.";
197 }
198
199129 override get message(): string {
200 return `GitHub CLI failed in getRepositoryCloneUrls: ${this.detail}`;
130 return "GitHub returned an invalid repository.";
201131 }
202132}
203133
......@@ -216,36 +146,31 @@ export type GitHubCliError = typeof GitHubCliError.Type;
216146
217147export const isGitHubCliError = Schema.is(GitHubCliError);
218148
219export function fromVcsError(
220 context: {
221 readonly command: "gh";
222 readonly cwd: string;
223 },
224 error: VcsError,
225): GitHubCliError {
226 if (
227 error._tag === "VcsProcessSpawnError" &&
228 error.cause instanceof PlatformError.PlatformError &&
229 error.cause.reason._tag === "NotFound" &&
230 error.cause.reason.module === "ChildProcess" &&
231 error.cause.reason.method === "spawn"
232 ) {
233 return new GitHubCliUnavailableError({ ...context, cause: error });
234 }
235
236 if (error._tag === "VcsProcessExitError") {
237 if (error.failureKind === "authentication") {
238 return new GitHubCliAuthenticationError({ ...context, cause: error });
239 }
240 if (error.failureKind === "rate-limited") {
241 return new GitHubCliRateLimitError({ ...context, cause: error });
242 }
243 if (error.failureKind === "not-found") {
244 return new GitHubPullRequestNotFoundError({ ...context, cause: error });
245 }
149/** Maps a GitHub API failure onto the errors callers of this service already handle. */
150function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHubCliError {
151 const context = { command: "gh" as const, cwd, cause: error };
152 switch (error._tag) {
153 case "GitHubCliMissingError":
154 return new GitHubCliUnavailableError(context);
155 case "GitHubNotSignedInError":
156 case "GitHubApiAuthenticationError":
157 return new GitHubCliAuthenticationError(context);
158 case "GitHubCliFailedError":
159 return new GitHubCliCommandError(context);
160 case "GitHubApiRateLimitError":
161 return new GitHubCliRateLimitError({
162 ...context,
163 ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }),
164 });
165 case "SourceControlRateLimitPausedError":
166 return new GitHubCliRateLimitError({ ...context, retryAt: error.retryAt });
167 case "GitHubApiNotFoundError":
168 return new GitHubPullRequestNotFoundError(context);
169 case "GitHubApiResponseError":
170 return new GitHubCliCommandError({ ...context, httpStatus: error.status });
171 case "GitHubApiRequestError":
172 return new GitHubCliCommandError(context);
246173 }
247
248 return new GitHubCliCommandError({ ...context, cause: error });
249174}
250175
251176export interface GitHubPullRequestSummary {
......@@ -281,19 +206,6 @@ export interface GitHubRepositoryCloneUrls {
281206export class GitHubCli extends Context.Service<
282207 GitHubCli,
283208 {
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
297209 readonly listOpenPullRequests: (input: {
298210 readonly cwd: string;
299211 readonly headSelector: string;
......@@ -302,16 +214,16 @@ export class GitHubCli extends Context.Service<
302214 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
303215
304216 /**
305 * Pull requests whose head is `headSelector`, in the repository `gh pr list` would read in
306 * `cwd`. Lookups on one repository that arrive together share one GraphQL document; a
307 * checkout whose repository gh could pick another way is asked through `gh pr list`.
217 * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the
218 * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive
219 * together share one GraphQL document.
308220 */
309221 readonly listPullRequestsByHead: (input: {
310222 readonly cwd: string;
311223 readonly headSelector: string;
312224 readonly state: "open" | "closed" | "merged" | "all";
313225 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. */
315227 readonly rateLimitHost?: string;
316228 }) => Effect.Effect<ReadonlyArray<NormalizedGitHubPullRequestRecord>, GitHubCliError>;
317229
......@@ -353,67 +265,73 @@ export class GitHubCli extends Context.Service<
353265 }
354266>()("t3/sourceControl/GitHubCli") {}
355267
356const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
357 nameWithOwner: TrimmedNonEmptyString,
358 url: TrimmedNonEmptyString,
359 sshUrl: TrimmedNonEmptyString,
268/**
269 * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a
270 * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin
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)),
360321});
361const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect(
362 Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema),
363);
322const decodeRawRepository = decodeJsonResult(RawRepositorySchema);
364323
365function normalizeRepositoryCloneUrls(
366 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,
324function repositoryCloneUrls(
325 raw: Schema.Schema.Type<typeof RawRepositorySchema>,
367326): GitHubRepositoryCloneUrls {
368 return {
369 nameWithOwner: raw.nameWithOwner,
370 url: raw.url,
371 sshUrl: raw.sshUrl,
372 };
327 return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url };
373328}
374329
375/**
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}
330const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString }));
411331
412332type PullRequestListState = "open" | "closed" | "merged" | "all";
413333
414const PULL_REQUEST_LIST_JSON_FIELDS =
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. */
334/** The pull request fields every read selects, in GraphQL. */
417335const PULL_REQUEST_NODE_SELECTION =
418336 "number title url baseRefName headRefName state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }";
419337const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = {
......@@ -424,7 +342,7 @@ const GRAPHQL_STATES: Record<PullRequestListState, ReadonlyArray<string>> = {
424342};
425343/**
426344 * 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.
428346 */
429347const HEAD_LOOKUPS_PER_DOCUMENT = 50;
430348/**
......@@ -433,8 +351,13 @@ const HEAD_LOOKUPS_PER_DOCUMENT = 50;
433351 */
434352const HEAD_LOOKUP_BATCH_WINDOW = "50 millis";
435353/** A full document is 5,000 rows of well under 2 KB each. */
436const HEAD_LOOKUP_MAX_OUTPUT_BYTES = 16_000_000;
437const RATE_LIMIT_READING = "query { rateLimit { cost limit remaining resetAt } }";
354const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000;
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
439362class PullRequestsByHeadRead extends Request.Class<
440363 {
......@@ -445,25 +368,17 @@ class PullRequestsByHeadRead extends Request.Class<
445368 readonly headRefName: string;
446369 readonly state: PullRequestListState;
447370 readonly limit: number;
371 readonly allowReserve: boolean;
448372 },
449373 ReadonlyArray<NormalizedGitHubPullRequestRecord>,
450374 GitHubCliError
451375> {}
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
462377/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */
463378function buildPullRequestsByHeadQuery(
464379 lookups: ReadonlyArray<Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">>,
465): { readonly document: string; readonly variables: typeof GraphQlVariables.Type } {
466 const variables: Record<string, string | ReadonlyArray<string>> = {};
380): { readonly document: string; readonly variables: Record<string, unknown> } {
381 const variables: Record<string, unknown> = {};
467382 const declarations: string[] = ["$owner: String!", "$name: String!"];
468383 const selections: string[] = [];
469384 for (const [index, lookup] of lookups.entries()) {
......@@ -482,419 +397,303 @@ function buildPullRequestsByHeadQuery(
482397 };
483398}
484399
485/** The reset time of a `rateLimit` reading, which sets when the next one is due. */
486const decodeRateLimitReading = (raw: string) =>
487 Result.map(
488 decodeJsonResult(
489 Schema.Struct({
490 data: Schema.Struct({ rateLimit: Schema.Struct({ resetAt: Schema.String }) }),
491 }),
492 )(raw),
493 (reading) => reading.data.rateLimit.resetAt,
494 );
400const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) {
401 repository(owner: $owner, name: $name) {
402 pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} }
403 }
404}`;
495405
496406const decodePullRequestsByHead = decodeJsonResult(
497407 Schema.Struct({
498408 data: Schema.Struct({
499 repository: Schema.Record(
500 Schema.String,
501 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })),
409 repository: Schema.NullOr(
410 Schema.Record(
411 Schema.String,
412 Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })),
413 ),
502414 ),
503415 }),
504416 }),
505417);
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
507458/**
508 * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a
509 * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin
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.
459 * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request
460 * URL, or a branch name.
516461 */
517export function selectGitHubBaseRepository(input: {
518 readonly remotes: string;
519 readonly resolved: string;
520 readonly host: string;
521}): { readonly owner: string; readonly name: string } | null {
522 const host = input.host.toLowerCase();
523 const repositories = new Map<string, { readonly owner: string; readonly name: string }>();
524 for (const line of input.remotes.split("\n")) {
525 const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim());
526 if (!match) continue;
527 const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/");
528 if (remoteHost !== host || !owner || !name || rest.length > 0) return null;
529 repositories.set(match[1]!, { owner, name });
462function parsePullRequestReference(
463 reference: string,
464):
465 | { readonly kind: "number"; readonly number: number }
466 | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number }
467 | { readonly kind: "branch"; readonly headSelector: string } {
468 const trimmed = reference.trim();
469 const numbered = /^#?([1-9]\d*)$/.exec(trimmed);
470 if (numbered) return { kind: "number", number: Number(numbered[1]) };
471 if (/^https?:\/\//i.test(trimmed)) {
472 try {
473 const url = new URL(trimmed);
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 }
530485 }
531 const marks = input.resolved
532 .split("\n")
533 .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim()))
534 .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!));
535 if (marks.length > 1) return null;
536 const [mark] = marks;
537 if (mark) {
538 if (mark[2] === "base") return repositories.get(mark[1]!) ?? null;
539 const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/");
540 return owner && name && rest.length === 0 ? { owner, name } : null;
486 return { kind: "branch", headSelector: trimmed };
487}
488
489/**
490 * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH
491 * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through
492 * `github.com`, which is what such an alias almost always stands for (issue #6198).
493 */
494export function gitHubApiHostForRemote(remoteUrl: string): string | 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;
541501 }
542 // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the
543 // top place has no defined winner.
544 const score = (remoteName: string) =>
545 ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1;
546 const ranked = [...repositories.entries()].toSorted(
547 ([left], [right]) => score(right) - score(left),
548 );
549 const [top, next] = ranked;
550 return top !== undefined && (next === undefined || score(top[0]) > score(next[0]))
551 ? top[1]
552 : null;
502 return provider.kind === "github" ? host : null;
503}
504
505/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */
506function apiHostForHint(host: string): string {
507 const normalized = host.toLowerCase();
508 return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized;
509}
510
511/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */
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;
553526}
554527
555528/** @public Service construction is part of the canonical Effect module API. */
556529export const make = Effect.gen(function* () {
557530 const process = yield* VcsProcess.VcsProcess;
558 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
559 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
560
561 const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")(
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 );
531 const environment = yield* HostProcessEnvironment;
532 const api = yield* GitHubApi.GitHubApi;
533 const git = yield* GitVcsDriver.GitVcsDriver;
534 const fileSystem = yield* FileSystem.FileSystem;
631535
632 /**
633 * A GraphQL `rateLimit` reading per host and credential, so the budget knows the balance
634 * before reads that cannot report their own cost (`gh pr list`). GraphQL documents keep it
635 * current in between. Each reading costs one point and is redone at the reset, or after ten
636 * minutes so a `gh auth switch` is not priced against the old account for a whole hour.
637 */
638 const budgetReading = yield* Cache.makeWith(
639 (key: string) => {
640 const host = key.split("\0")[0]!;
641 return executeRaw({
642 cwd: globalThis.process.cwd(),
643 args: ["api", "graphql", "--hostname", host, "-f", `query=${RATE_LIMIT_READING}`],
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 );
536 const gitRead = (cwd: string, args: ReadonlyArray<string>) =>
537 process.run({
538 operation: "GitHubCli.resolveRepository",
539 command: "git",
540 args,
541 cwd,
542 allowNonZeroExit: true,
543 timeoutMs: 5_000,
544 });
545
546 const commandFailure = (cwd: string, detail: string) =>
547 new GitHubCliCommandError({ command: "gh", cwd, cause: new Error(detail) });
671548
672549 /**
673 * Runs a GraphQL-priced read under its host's pause and the GraphQL budget. `run` receives
674 * the document with `rateLimit` added, so a GraphQL read can report what it spent; a CLI read
675 * ignores it and is priced at one point.
550 * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else
551 * the remote the caller resolved the provider from, else the best-ranked GitHub remote.
676552 */
677 const guardedRead = <A>(input: {
553 const resolveRepository = Effect.fn("GitHubCli.resolveRepository")(function* (input: {
678554 readonly cwd: string;
679 readonly host: string;
680 readonly document: string;
681 readonly allowReserve: boolean;
682 readonly run: (document: string) => Effect.Effect<A, GitHubCliError>;
683 }) =>
684 Effect.gen(function* () {
685 const credential = yield* PinnedGitHubCredential;
686 const key = { provider: "github" as const, host: input.host };
687 const guarded = Effect.gen(function* () {
688 const lease = yield* limits.check(
689 key,
690 input.allowReserve ? { allowPaused: true } : undefined,
691 );
692 // A failed reading leaves the budget unknown; it never blocks the read itself.
693 yield* Cache.get(
694 budgetReading,
695 `${input.host}\0${yield* SourceControlRateLimit.CredentialScope}`,
696 ).pipe(Effect.ignore);
697 return yield* budget
698 .query(
699 input.host,
700 input.document,
701 input.allowReserve ? { allowReserve: true } : undefined,
702 )
703 .pipe(
704 Effect.flatMap(input.run),
705 Effect.tap(() => limits.recordSuccess({ ...key, lease })),
706 Effect.tapError((error) =>
707 error._tag === "GitHubCliRateLimitError"
708 ? limits.recordRateLimit({ ...key, lease })
709 : Effect.void,
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 );
555 readonly host?: string | undefined;
556 }) {
557 const envRepository = environment.GH_REPO?.trim();
558 const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase();
559 if (envRepository) {
560 const locator = parseGitHubRepositorySelector(envRepository, defaultHost);
561 if (locator !== null) return locator;
562 }
563 const [remotes, resolved] = yield* Effect.all([
564 gitRead(input.cwd, ["remote", "-v"]),
565 gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]),
566 ]).pipe(Effect.orElseSucceed(() => [null, null] as const));
567 const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : "";
568 const fetchRemotes = remoteOutput
569 .split("\n")
570 .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()))
571 .filter((match): match is RegExpExecArray => match !== null)
572 .map((match) => ({
573 name: match[1]!,
574 url: match[2]!,
575 host: gitHubApiHostForRemote(match[2]!),
576 }));
577 const host =
578 (input.host === undefined ? undefined : apiHostForHint(input.host)) ??
579 fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ??
580 fetchRemotes.find((remote) => remote.host !== null)?.host ??
581 defaultHost;
582 const selected = selectGitHubBaseRepository({
583 remotes: remoteOutput,
584 resolved: resolved?.exitCode === 0 ? resolved.stdout : "",
585 host,
730586 });
731
732 const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")(
733 function* (input) {
734 const [command, action] = input.args;
735 if (
736 !(
737 (command === "pr" && (action === "list" || action === "view")) ||
738 (command === "repo" && action === "view")
739 )
740 )
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),
587 if (selected !== null) return { host, ...selected };
588 // gh's own order without its prompt: upstream, github, origin, then the first remote, among
589 // the ones on this host (an SSH alias counts as its API host).
590 const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase());
591 const candidates = fetchRemotes
592 .filter((remote) => remote.host === host)
593 .toSorted((left, right) => {
594 const l = rank(left.name);
595 const r = rank(right.name);
596 return (l === -1 ? 99 : l) - (r === -1 ? 99 : r);
758597 });
759 },
760 );
598 for (const remote of candidates) {
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: {
763 readonly cwd: string;
764 readonly headSelector: string;
765 readonly state: PullRequestListState;
766 readonly limit: number;
767 readonly rateLimitHost?: string | undefined;
768 }) =>
769 execute({
770 cwd: input.cwd,
771 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),
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);
608 const graphqlJson = <A>(
609 cwd: string,
610 input: GitHubApi.GitHubGraphQlInput,
611 decode: (raw: string) => Result.Result<A, unknown>,
612 onDecodeFailure: (cause: unknown) => GitHubCliError,
613 ) =>
614 api.graphql(input).pipe(
615 Effect.mapError((error) => fromGitHubApiError(cwd, error)),
616 Effect.flatMap((raw) => {
617 const decoded = decode(raw);
789618 return Result.isSuccess(decoded)
790619 ? Effect.succeed(decoded.success)
791 : Effect.fail(
792 new GitHubChangeRequestListDecodeError({
793 command: "gh",
794 cwd: input.cwd,
795 cause: decoded.failure,
796 }),
797 );
620 : Effect.fail(onDecodeFailure(decoded.failure));
798621 }),
799622 );
800623
801 const git = (cwd: string, args: ReadonlyArray<string>) =>
802 process.run({
803 operation: "GitHubCli.baseRepository",
804 command: "git",
805 args,
806 cwd,
807 allowNonZeroExit: true,
808 timeoutMs: 5_000,
809 });
624 const rest = (cwd: string, input: GitHubApi.GitHubRestInput) =>
625 api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error)));
810626
811 /** The repository gh reads in `cwd`, or null when gh could pick it another way. */
812 const resolveBaseRepository = (cwd: string, host: string) =>
813 globalThis.process.env.GH_REPO
814 ? Effect.succeed(null)
815 : Effect.all([
816 git(cwd, ["remote", "-v"]),
817 git(cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]),
818 ]).pipe(
819 Effect.map(([remotes, resolved]) =>
820 remotes.exitCode === 0
821 ? selectGitHubBaseRepository({
822 remotes: remotes.stdout,
823 resolved: resolved.exitCode === 0 ? resolved.stdout : "",
824 host,
825 })
826 : null,
827 ),
828 Effect.orElseSucceed(() => null),
829 );
627 /** Pull requests whose head branch is `headRefName` on one repository. */
628 const readPullRequestsByHead = (input: {
629 readonly cwd: string;
630 readonly locator: GitHubRepositoryLocator;
631 readonly lookups: ReadonlyArray<
632 Pick<PullRequestsByHeadRead, "headRefName" | "state" | "limit">
633 >;
634 readonly allowReserve: boolean;
635 readonly onDecodeFailure: (cause: unknown) => GitHubCliError;
636 }) => {
637 const query = buildPullRequestsByHeadQuery(input.lookups);
638 return graphqlJson(
639 input.cwd,
640 {
641 host: input.locator.host,
642 operation: "listPullRequestsByHead",
643 query: query.document,
644 variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables },
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
831667 const headResolver = RequestResolver.makeGrouped<PullRequestsByHeadRead, string>({
832668 key: ({ request, context }) =>
833 JSON.stringify([
669 [
834670 request.host,
835671 request.owner,
836672 request.name,
837 Context.getOrElse(context, PinnedGitHubCredential, () => null)?.credentialFingerprint ??
838 null,
673 String(request.allowReserve),
674 Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null)
675 ?.credentialFingerprint ?? "",
839676 Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""),
840 ]),
677 ].join("\0"),
841678 resolver: (entries) => {
842679 const [first] = entries;
843 const { cwd, host, owner, name } = first.request;
844 const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request));
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({
680 const { cwd, host, owner, name, allowReserve } = first.request;
681 return readPullRequestsByHead({
857682 cwd,
858 host,
859 document: query.document,
860 allowReserve: false,
861 run: (document) =>
862 executeRaw({
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))),
683 locator: { host, owner, name },
684 lookups: entries.map((entry) => entry.request),
685 allowReserve,
686 onDecodeFailure: (cause) =>
687 new GitHubChangeRequestListDecodeError({ command: "gh", cwd, cause }),
873688 }).pipe(
874 Effect.flatMap((result) => {
875 const decoded = decodePullRequestsByHead(result.stdout);
876 if (!Result.isSuccess(decoded)) {
877 return Effect.forEach(entries, readCli, { discard: true });
689 Effect.map((aliases) => {
690 for (const [index, entry] of entries.entries()) {
691 const alias = aliases[`h${index}`];
692 entry.completeUnsafe(
693 Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)),
694 );
878695 }
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 );
890696 }),
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 ),
898697 Effect.catchCause((cause) =>
899698 Effect.sync(() => {
900699 for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause));
......@@ -907,174 +706,368 @@ export const make = Effect.gen(function* () {
907706 RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT),
908707 );
909708
910 const listPullRequestsByHead: GitHubCli["Service"]["listPullRequestsByHead"] = Effect.fn(
911 "GitHubCli.listPullRequestsByHead",
912 )(function* (input) {
913 const host = input.rateLimitHost?.toLowerCase();
914 const credential = yield* PinnedGitHubCredential;
915 // `owner:branch` selectors and other hosts keep gh's own handling.
916 const repository =
917 host === undefined ||
918 input.headSelector.includes(":") ||
919 (credential !== null && credential.host !== host)
920 ? null
921 : yield* resolveBaseRepository(input.cwd, host);
922 if (host === undefined || repository === null) {
923 return yield* listPullRequestsWithCli(input);
924 }
925 return yield* Effect.request(
709 const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: {
710 readonly cwd: string;
711 readonly headSelector: string;
712 readonly state: PullRequestListState;
713 readonly limit: number;
714 readonly rateLimitHost?: string | undefined;
715 readonly allowReserve: boolean;
716 }) {
717 const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost });
718 const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100);
719 // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the
720 // owner is matched on the rows it returns.
721 const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim());
722 const headRefName = ownerMatch?.[2] ?? input.headSelector.trim();
723 const rows = yield* Effect.request(
926724 new PullRequestsByHeadRead({
927725 cwd: input.cwd,
928 host,
929 owner: repository.owner,
930 name: repository.name,
931 headRefName: input.headSelector,
726 ...locator,
727 headRefName,
932728 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,
934731 }),
935732 headResolver,
936733 );
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);
937739 });
938740
939 return GitHubCli.of({
940 execute,
941 listPullRequestsByHead,
942 listOpenPullRequests: (input) =>
943 execute({
741 const toSummaries = (rows: ReadonlyArray<NormalizedGitHubPullRequestRecord>) =>
742 rows.map(pullRequestSummary);
743
744 const readPullRequest = Effect.fn("GitHubCli.readPullRequest")(function* (input: {
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({
944753 cwd: input.cwd,
945 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),
754 headSelector: parsed.headSelector,
755 state: "open",
756 limit: 1,
757 rateLimitHost: input.rateLimitHost,
946758 allowReserve: true,
947 args: [
948 "pr",
949 "list",
950 "--head",
951 input.headSelector,
952 "--state",
953 "open",
954 "--limit",
955 String(input.limit ?? 1),
956 "--json",
957 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner",
958 ],
959 }).pipe(
960 Effect.map((result) => result.stdout.trim()),
961 Effect.flatMap((raw) =>
962 raw.length === 0
963 ? Effect.succeed([])
964 : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
965 Effect.flatMap((decoded) => {
966 if (!Result.isSuccess(decoded)) {
967 return Effect.fail(
968 new GitHubPullRequestListDecodeError({
969 command: "gh",
970 cwd: input.cwd,
971 cause: decoded.failure,
972 }),
973 );
974 }
975
976 return Effect.succeed(decoded.success.map(pullRequestSummary));
977 }),
978 ),
979 ),
980 ),
981 getPullRequest: (input) =>
982 execute({
983 cwd: input.cwd,
984 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),
759 });
760 const found =
761 open ??
762 (yield* listByHead({
763 cwd: input.cwd,
764 headSelector: parsed.headSelector,
765 state: "all",
766 limit: 1,
767 rateLimitHost: input.rateLimitHost,
768 allowReserve: true,
769 }))[0];
770 if (found === undefined) {
771 return yield* new GitHubPullRequestNotFoundError({
772 command: "gh",
773 cwd: input.cwd,
774 cause: new Error("No pull request has this head branch."),
775 });
776 }
777 return found;
778 }
779 const locator =
780 parsed.kind === "url"
781 ? parsed.locator
782 : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost });
783 const decodeFailure = (cause: unknown) =>
784 new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, cause });
785 const decoded = yield* graphqlJson(
786 input.cwd,
787 {
788 host: locator.host,
789 operation: "getPullRequest",
790 query: PULL_REQUEST_BY_NUMBER_QUERY,
791 variables: { owner: locator.owner, name: locator.name, number: parsed.number },
985792 allowReserve: true,
986 args: [
987 "pr",
988 "view",
989 input.reference,
990 "--json",
991 "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
992 ],
993 }).pipe(
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({
793 },
794 decodePullRequestByNumber,
795 decodeFailure,
796 );
797 const node = decoded.data.repository?.pullRequest;
798 if (node == null) {
799 return yield* new GitHubPullRequestNotFoundError({
800 command: "gh",
1035801 cwd: input.cwd,
1036 args: ["repo", "create", input.repository, `--${input.visibility}`],
1037 }).pipe(
1038 Effect.map((result) =>
1039 deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository),
1040 ),
1041 ),
1042 createPullRequest: (input) =>
1043 execute({
802 cause: new Error("The pull request does not exist."),
803 });
804 }
805 const [record] = decodeGitHubPullRequestEntries([node]);
806 if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request."));
807 return record;
808 });
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({
1044917 cwd: input.cwd,
1045 args: [
1046 "pr",
1047 "create",
1048 "--base",
1049 input.baseBranch,
1050 "--head",
1051 input.headSelector,
1052 "--title",
1053 input.title,
1054 "--body-file",
1055 input.bodyFile,
1056 ],
1057 }).pipe(Effect.asVoid),
1058 getDefaultBranch: (input) =>
1059 execute({
918 preferredName: headOwner ?? "fork",
919 url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url,
920 });
921 });
922
923 const exists = (yield* git
924 .listLocalBranchNames(input.cwd)
925 .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch);
926
927 // The commit to check out, fetched from the head branch where it still exists.
928 const target = yield* Effect.gen(function* () {
929 const remoteName = yield* headRemote;
930 yield* git.fetchRemoteTrackingBranch({
1060931 cwd: input.cwd,
1061 ...(input.rateLimitHost === undefined ? {} : { rateLimitHost: input.rateLimitHost }),
1062 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
1063 }).pipe(
1064 Effect.map((value) => {
1065 const trimmed = value.stdout.trim();
1066 return trimmed.length > 0 ? trimmed : null;
932 remoteName,
933 remoteBranch: pullRequest.headRefName,
934 });
935 return {
936 ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`,
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 };
1067954 }),
1068955 ),
1069 checkoutPullRequest: (input) =>
1070 execute({
956 Effect.mapError(gitFailure(input.cwd)),
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({
1071986 cwd: input.cwd,
1072 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
1073 }).pipe(Effect.asVoid),
987 headSelector: input.headSelector,
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,
10741065 });
10751066});
10761067
10771068export const layer = Layer.effect(GitHubCli, make).pipe(
1069 Layer.provideMerge(GitHubApi.layer),
1070 Layer.provideMerge(GitHubCredentials.layer),
10781071 Layer.provideMerge(GitHubGraphQlBudget.layer),
10791072 Layer.provideMerge(SourceControlRateLimit.layer),
10801073);
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+44-64
......@@ -3,11 +3,12 @@ import * as DateTime from "effect/DateTime";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
67import { ChildProcessSpawner } from "effect/process";
78
89import * as VcsProcess from "../vcs/VcsProcess.ts";
10import * as GitHubApi from "./GitHubApi.ts";
911import * as GitHubCli from "./GitHubCli.ts";
10import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
1112import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
1213import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1314
......@@ -25,37 +26,26 @@ const processResult = (
2526 stderrTruncated: false,
2627});
2728
28function makeProvider(github: Partial<GitHubCli.GitHubCli["Service"]>) {
29function makeProvider(
30 github: Partial<GitHubCli.GitHubCli["Service"]>,
31 api: Partial<GitHubApi.GitHubApi["Service"]> = {},
32) {
2933 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 ),
3137 );
3238}
3339
34it.effect("uses the enterprise quota for a current-repository default branch read", () =>
35 Effect.gen(function* () {
36 // github.com is out of quota; the enterprise read must not be priced against it.
37 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
38 yield* budget.observe(
39 "github.com",
40 '{"data":{"rateLimit":{"cost":1,"limit":5000,"remaining":0,"resetAt":"2099-01-01T00:00:00Z"}}}',
41 );
42 const provider = yield* GitHubSourceControlProvider.make;
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);
40const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
41
42const restResponse = (body: string): GitHubApi.GitHubRestResponse => ({
43 status: 200,
44 headers: {},
45 body,
46 truncated: false,
47 invalidUtf8: false,
48});
5949
6050it.effect("maps GitHub PR summaries into provider-neutral change requests", () =>
6151 Effect.gen(function* () {
......@@ -412,27 +402,20 @@ it.effect.each(["pull", "issues"])(
412402 "resolves %s subjects on the linked host without using the checkout",
413403 (kind) =>
414404 Effect.gen(function* () {
415 const provider = yield* makeProvider({
416 execute: (input) => {
417 assert.deepStrictEqual(input.args, [
418 "api",
419 "--hostname",
420 "github.com",
421 "repos/owner/repo/issues/42",
422 "--jq",
423 "{title, body}",
424 ]);
425 assert.strictEqual(input.maxOutputBytes, 32_000);
426 assert.strictEqual(input.timeoutMs, 3_000);
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 });
405 const provider = yield* makeProvider(
406 {},
407 {
408 rest: (input) => {
409 assert.strictEqual(input.host, "github.com");
410 assert.strictEqual(input.path, "repos/owner/repo/issues/42");
411 return Effect.succeed(
412 restResponse(
413 encodeJson({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }),
414 ),
415 );
416 },
434417 },
435 });
418 );
436419 const lookup = provider.resolveLink?.({
437420 cwd: "/unrelated",
438421 url: new URL(`https://github.com/owner/repo/${kind}/42`),
......@@ -456,23 +439,20 @@ it.effect.each(["read", "decode"] as const)(
456439 "retains the %s failure without exposing its raw contents",
457440 (stage) =>
458441 Effect.gen(function* () {
459 const cause = new GitHubCli.GitHubCliCommandError({
460 command: "gh",
461 cwd: "/repo",
462 cause: new Error("private response text"),
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 }),
442 const cause = new GitHubApi.GitHubApiResponseError({
443 host: "github.com",
444 operation: "resolveLink",
445 status: 500,
475446 });
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 );
476456 const lookup = provider.resolveLink?.({
477457 cwd: "/repo",
478458 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";
22import * as DateTime from "effect/DateTime";
33import * as Effect from "effect/Effect";
44import * 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";
715import * as GitHubCli from "./GitHubCli.ts";
816import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
917import * as SourceControlProvider from "./SourceControlProvider.ts";
1018import {
1119 combinedAuthOutput,
1220 firstSafeAuthLine,
21 probeSourceControlProvider,
1322 providerAuth,
1423 type SourceControlAuthProbeInput,
1524 type SourceControlCliDiscoverySpec,
25 type SourceControlManagedCliDiscoverySpec,
1626} from "./SourceControlProviderDiscovery.ts";
27import * as VcsProcess from "../vcs/VcsProcess.ts";
1728
1829const decodeLinkSubject = Schema.decodeUnknownEffect(
1930 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)) }),
2132 ),
2233);
2334
......@@ -111,8 +122,70 @@ export const discovery = {
111122 "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).",
112123} 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
114186export const make = Effect.gen(function* () {
115187 const github = yield* GitHubCli.GitHubCli;
188 const api = yield* GitHubApi.GitHubApi;
116189
117190 const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] =
118191 (input) => {
......@@ -138,7 +211,7 @@ export const make = Effect.gen(function* () {
138211 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
139212 input.headSelector,
140213 ),
141 detail: error.detail,
214 detail: error.message,
142215 cause: error,
143216 }),
144217 ),
......@@ -177,7 +250,7 @@ export const make = Effect.gen(function* () {
177250 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
178251 input.headSelector,
179252 ),
180 detail: error.detail,
253 detail: error.message,
181254 cause: error,
182255 }),
183256 ),
......@@ -188,15 +261,15 @@ export const make = Effect.gen(function* () {
188261 input: { readonly cwd: string; readonly url: URL },
189262 endpoint: string,
190263 ) {
191 const result = yield* github
192 .execute({
193 cwd: input.cwd,
194 args: ["api", "--hostname", input.url.host, endpoint, "--jq", "{title, body}"],
195 env: { GH_PROMPT_DISABLED: "1" },
196 timeoutMs: 3_000,
197 maxOutputBytes: 32_000,
264 const result = yield* api
265 .rest({
266 host: input.url.host,
267 operation: "resolveLink",
268 path: endpoint,
269 maxResponseBytes: 1_000_000,
198270 })
199271 .pipe(
272 Effect.timeout("3 seconds"),
200273 Effect.mapError(
201274 (cause) =>
202275 new SourceControlProviderError({
......@@ -208,7 +281,7 @@ export const make = Effect.gen(function* () {
208281 }),
209282 ),
210283 );
211 const subject = yield* decodeLinkSubject(result.stdout).pipe(
284 const subject = yield* decodeLinkSubject(result.body).pipe(
212285 Effect.mapError(
213286 (cause) =>
214287 new SourceControlProviderError({
......@@ -220,7 +293,7 @@ export const make = Effect.gen(function* () {
220293 }),
221294 ),
222295 );
223 return { title: subject.title, body: subject.body };
296 return { title: subject.title, body: subject.body ?? null };
224297 });
225298
226299 return SourceControlProvider.SourceControlProvider.of({
......@@ -255,7 +328,7 @@ export const make = Effect.gen(function* () {
255328 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
256329 input.reference,
257330 ),
258 detail: error.detail,
331 detail: error.message,
259332 cause: error,
260333 }),
261334 ),
......@@ -280,7 +353,7 @@ export const make = Effect.gen(function* () {
280353 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
281354 input.headSelector,
282355 ),
283 detail: error.detail,
356 detail: error.message,
284357 cause: error,
285358 }),
286359 ),
......@@ -297,7 +370,7 @@ export const make = Effect.gen(function* () {
297370 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
298371 input.repository,
299372 ),
300 detail: error.detail,
373 detail: error.message,
301374 cause: error,
302375 }),
303376 ),
......@@ -314,7 +387,7 @@ export const make = Effect.gen(function* () {
314387 repository: SourceControlProvider.transportSafeSourceControlErrorValue(
315388 input.repository,
316389 ),
317 detail: error.detail,
390 detail: error.message,
318391 cause: error,
319392 }),
320393 ),
......@@ -335,7 +408,7 @@ export const make = Effect.gen(function* () {
335408 operation: "getDefaultBranch",
336409 command: error.command,
337410 cwd: input.cwd,
338 detail: error.detail,
411 detail: error.message,
339412 cause: error,
340413 }),
341414 ),
......@@ -352,7 +425,7 @@ export const make = Effect.gen(function* () {
352425 reference: SourceControlProvider.transportSafeSourceControlErrorValue(
353426 input.reference,
354427 ),
355 detail: error.detail,
428 detail: error.message,
356429 cause: error,
357430 }),
358431 ),
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+2
......@@ -16,6 +16,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1616import * as VcsProcess from "../vcs/VcsProcess.ts";
1717import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
1818import * as BitbucketApi from "./BitbucketApi.ts";
19import * as GitHubApi from "./GitHubApi.ts";
1920import * as GitHubCli from "./GitHubCli.ts";
2021import * as GitLabCli from "./GitLabCli.ts";
2122import * as ForgejoCli from "./ForgejoCli.ts";
......@@ -38,6 +39,7 @@ const layerSourceControlProviderRegistryTest = (input: {
3839 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
3940 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
4041 Layer.mock(GitHubCli.GitHubCli)({}),
42 Layer.mock(GitHubApi.GitHubApi)({}),
4143 Layer.mock(GitLabCli.GitLabCli)({}),
4244 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
4345 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+12-3
......@@ -13,6 +13,7 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1313import * as VcsProcess from "../vcs/VcsProcess.ts";
1414import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
1515import * as BitbucketApi from "./BitbucketApi.ts";
16import * as GitHubApi from "./GitHubApi.ts";
1617import * as GitHubCli from "./GitHubCli.ts";
1718import * as GitLabCli from "./GitLabCli.ts";
1819import * as ForgejoCli from "./ForgejoCli.ts";
......@@ -41,6 +42,7 @@ function makeRegistry(input: {
4142 }>;
4243 readonly process?: Partial<VcsProcess.VcsProcess["Service"]>;
4344 readonly github?: Partial<GitHubCli.GitHubCli["Service"]>;
45 readonly githubApi?: Partial<GitHubApi.GitHubApi["Service"]>;
4446 readonly gitlab?: Partial<GitLabCli.GitLabCli["Service"]>;
4547 readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];
4648}) {
......@@ -95,6 +97,7 @@ function makeRegistry(input: {
9597 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
9698 Layer.mock(BitbucketApi.BitbucketApi)({}),
9799 Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}),
100 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),
98101 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
99102 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
100103 ServerConfig.layerTest(process.cwd(), {
......@@ -305,9 +308,15 @@ it.effect(
305308 Effect.gen(function* () {
306309 const registry = yield* makeRegistry({
307310 remotes: [{ name: "origin", url: "https://github.com/unrelated/checkout.git" }],
308 github: {
309 execute: () =>
310 Effect.succeed(processOutput(JSON.stringify({ title: "GitHub issue", body: null }))),
311 githubApi: {
312 rest: () =>
313 Effect.succeed({
314 status: 200,
315 headers: {},
316 body: JSON.stringify({ title: "GitHub issue", body: null }),
317 truncated: false,
318 invalidUtf8: false,
319 }),
311320 },
312321 gitlab: {
313322 execute: () =>
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+2-1
......@@ -305,6 +305,7 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
305305
306306export const make = Effect.gen(function* () {
307307 const github = yield* GitHubSourceControlProvider.make;
308 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;
308309 const gitlab = yield* GitLabSourceControlProvider.make;
309310 const forgejo = yield* ForgejoSourceControlProvider.make;
310311 const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery;
......@@ -315,7 +316,7 @@ export const make = Effect.gen(function* () {
315316 {
316317 kind: "github",
317318 provider: github,
318 discovery: GitHubSourceControlProvider.discovery,
319 discovery: githubDiscovery,
319320 },
320321 {
321322 kind: "gitlab",
apps/server/src/sourceControl/gitHubPullRequests.ts-11
......@@ -111,7 +111,6 @@ function normalizeGitHubPullRequestRecord(
111111}
112112
113113const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
114const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema);
115114const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema);
116115
117116/**
......@@ -139,13 +138,3 @@ export function decodeGitHubPullRequestListJson(
139138> {
140139 return Result.map(decodeGitHubPullRequestList(raw), decodeGitHubPullRequestEntries);
141140}
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}