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

feat(server): GitHub API transport that uses gh only for the token (#16319)

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

4 files changed, 1281 insertions(+), 5 deletions(-)

apps/server/src/sourceControl/GitHubApi.test.ts created+497
......@@ -0,0 +1,497 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import * as PlatformError from "effect/PlatformError";
7import * as TestClock from "effect/testing/TestClock";
8import * as Tracer from "effect/Tracer";
9import { ChildProcessSpawner } from "effect/process";
10import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts";
11import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http";
12
13import * as GitHubApi from "./GitHubApi.ts";
14import * as GitHubCredentials from "./GitHubCredentials.ts";
15import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
16import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
17import * as VcsProcess from "../vcs/VcsProcess.ts";
18
19const NOW = Date.parse("2026-10-05T12:00:00.000Z");
20
21function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Response) {
22 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
23 let tokens = ["first", "second"];
24 let invalidations = 0;
25 const credentials = Layer.succeed(
26 GitHubCredentials.GitHubCredentials,
27 GitHubCredentials.GitHubCredentials.of({
28 get: (host) =>
29 Effect.sync(() => ({
30 host,
31 token: Redacted.make(tokens[0]!),
32 source: "gh" as const,
33 fingerprint: `${host}:${tokens[0]}`,
34 })),
35 invalidate: () =>
36 Effect.sync(() => {
37 invalidations++;
38 tokens = tokens.slice(1);
39 }),
40 }),
41 );
42 const http = Layer.succeed(
43 HttpClient.HttpClient,
44 HttpClient.make((request) => {
45 requests.push(request);
46 return Effect.succeed(HttpClientResponse.fromWeb(request, respond(request)));
47 }),
48 );
49 const layer = GitHubApi.layer.pipe(
50 Layer.provide(Layer.mergeAll(credentials, http)),
51 Layer.provideMerge(GitHubGraphQlBudget.layer),
52 Layer.provideMerge(SourceControlRateLimit.layer),
53 );
54 return { layer, requests, invalidations: () => invalidations };
55}
56
57const json = (body: unknown, init?: ResponseInit) =>
58 new Response(JSON.stringify(body), {
59 ...init,
60 headers: { "content-type": "application/json", ...init?.headers },
61 });
62
63describe("gitHubApiUrls", () => {
64 it("maps github.com, GHE.com and GitHub Enterprise Server", () => {
65 expect(GitHubApi.gitHubApiUrls("GitHub.com")).toEqual({
66 rest: "https://api.github.com",
67 graphql: "https://api.github.com/graphql",
68 });
69 expect(GitHubApi.gitHubApiUrls("acme.ghe.com").graphql).toBe(
70 "https://api.acme.ghe.com/graphql",
71 );
72 expect(GitHubApi.gitHubApiUrls("git.acme.internal")).toEqual({
73 rest: "https://git.acme.internal/api/v3",
74 graphql: "https://git.acme.internal/api/graphql",
75 });
76 });
77});
78
79describe("environmentToken", () => {
80 it("follows gh's precedence per kind of host", () => {
81 const env = { GH_TOKEN: "gh", GITHUB_TOKEN: "github", GH_ENTERPRISE_TOKEN: "ghe" };
82 expect(GitHubCredentials.environmentToken("github.com", env)).toBe("gh");
83 expect(GitHubCredentials.environmentToken("acme.ghe.com", { GITHUB_TOKEN: "github" })).toBe(
84 "github",
85 );
86 // An enterprise token only goes to the host GH_HOST names, never to whatever a remote says.
87 expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBeNull();
88 expect(
89 GitHubCredentials.environmentToken("git.acme.internal", {
90 ...env,
91 GH_HOST: "git.acme.internal",
92 }),
93 ).toBe("ghe");
94 expect(GitHubCredentials.environmentToken("git.acme.internal", { GH_TOKEN: "gh" })).toBeNull();
95 });
96});
97
98describe("GitHubApi", () => {
99 it.effect("sends GraphQL with the token and records the reported budget", () => {
100 const { layer, requests } = harness(() =>
101 json({
102 data: {
103 viewer: { login: "julius" },
104 rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-10-05T13:00:00Z" },
105 },
106 }),
107 );
108 return Effect.gen(function* () {
109 yield* TestClock.setTime(NOW);
110 const api = yield* GitHubApi.GitHubApi;
111 const body = yield* api.graphql({
112 host: "github.com",
113 operation: "viewer",
114 query: "query { viewer { login } }",
115 });
116 expect(body).toContain('"login":"julius"');
117 expect(requests[0]!.url).toBe("https://api.github.com/graphql");
118 expect(requests[0]!.headers.authorization).toBe("Bearer first");
119 }).pipe(Effect.provide(layer));
120 });
121
122 it.effect(
123 "traces the operation, path, query and cost, never the query string or variables",
124 () => {
125 const spans: Array<Tracer.NativeSpan> = [];
126 const tracer = Tracer.make({
127 span: (options) => {
128 const span = new Tracer.NativeSpan(options);
129 spans.push(span);
130 return span;
131 },
132 });
133 const { layer } = harness((request) =>
134 request.url.endsWith("/graphql")
135 ? json(
136 {
137 data: {
138 viewer: { login: "julius" },
139 rateLimit: {
140 cost: 3,
141 limit: 5000,
142 remaining: 4990,
143 resetAt: "2026-10-05T13:00:00Z",
144 },
145 },
146 },
147 { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } },
148 )
149 : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }),
150 );
151 return Effect.gen(function* () {
152 yield* TestClock.setTime(NOW);
153 const api = yield* GitHubApi.GitHubApi;
154 yield* api.graphql({
155 host: "github.com",
156 operation: "getPullRequestDetail",
157 query: "query($body: String!) { viewer { login } }",
158 variables: { body: "secret user text" },
159 });
160 yield* api.rest({
161 host: "github.com",
162 operation: "listWorkflowRuns",
163 path: "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fx",
164 });
165 const byName = (name: string) => spans.filter((span) => span.name === name);
166 const graphql = Object.fromEntries(byName("GitHubApi.graphql")[0]!.attributes);
167 expect(graphql).toMatchObject({
168 "github.operation": "getPullRequestDetail",
169 "github.graphql.query": "query($body: String!) { viewer { login } }",
170 "github.graphql.cost": 3,
171 "github.graphql.remaining": 4990,
172 });
173 expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/);
174 const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes);
175 expect(rest).toMatchObject({
176 "github.operation": "listWorkflowRuns",
177 "github.kind": "rest",
178 "url.path": "/repos/acme/web/actions/runs",
179 "http.response.status_code": 200,
180 "github.ratelimit.remaining": 4800,
181 });
182 // Nothing recorded carries the query string, the variables, or the token.
183 const everything = spans
184 .flatMap((span) => [...span.attributes].map(([key, value]) => `${key}=${String(value)}`))
185 .join("\n");
186 expect(everything).not.toContain("head_sha");
187 expect(everything).not.toContain("secret user text");
188 expect(everything).not.toContain("first");
189 expect(spans.some((span) => span.attributes.has("url.full"))).toBe(false);
190 }).pipe(Effect.provide(layer), Effect.withTracer(tracer));
191 },
192 );
193
194 it.effect("fails a GraphQL answer that carries errors, naming GitHub's reason", () => {
195 const { layer } = harness(() =>
196 json({ data: null, errors: [{ type: "FORBIDDEN", message: "Resource not accessible" }] }),
197 );
198 return Effect.gen(function* () {
199 const api = yield* GitHubApi.GitHubApi;
200 const error = yield* Effect.flip(
201 api.graphql({ host: "github.com", operation: "detail", query: "query { viewer { id } }" }),
202 );
203 expect(error._tag).toBe("GitHubApiResponseError");
204 expect(error.message).toContain("Resource not accessible");
205 }).pipe(Effect.provide(layer));
206 });
207
208 it.effect(
209 "reads a NOT_FOUND answer with another error as a failure, not a missing resource",
210 () => {
211 const { layer } = harness(() =>
212 json({
213 data: null,
214 errors: [
215 { type: "NOT_FOUND", message: "Could not resolve" },
216 { message: "Something went wrong" },
217 ],
218 }),
219 );
220 return Effect.gen(function* () {
221 const api = yield* GitHubApi.GitHubApi;
222 const error = yield* Effect.flip(
223 api.graphql({
224 host: "github.com",
225 operation: "detail",
226 query: "query { viewer { id } }",
227 }),
228 );
229 expect(error._tag).toBe("GitHubApiResponseError");
230 }).pipe(Effect.provide(layer));
231 },
232 );
233
234 it.effect("pauses the host after a GraphQL RATE_LIMITED answer until the reset", () => {
235 const reset = Math.floor(NOW / 1000) + 600;
236 const { layer, requests } = harness(() =>
237 json(
238 { errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }] },
239 { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } },
240 ),
241 );
242 return Effect.gen(function* () {
243 yield* TestClock.setTime(NOW);
244 const api = yield* GitHubApi.GitHubApi;
245 const first = yield* Effect.flip(
246 api.graphql({ host: "github.com", operation: "summary", query: "query { viewer { id } }" }),
247 );
248 expect(first).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 });
249 const second = yield* Effect.flip(
250 api.rest({ host: "github.com", operation: "stack", path: "repos/acme/web/stacks" }),
251 );
252 expect(second).toMatchObject({
253 _tag: "SourceControlRateLimitPausedError",
254 retryAt: reset * 1000,
255 });
256 expect(requests).toHaveLength(1);
257 }).pipe(Effect.provide(layer));
258 });
259
260 it.effect("carries GitHub's own reason for a refused REST request", () => {
261 const { layer } = harness(() =>
262 json(
263 {
264 message: "Validation Failed",
265 errors: [
266 {
267 resource: "PullRequest",
268 code: "custom",
269 message: "A pull request already exists for acme:feature.",
270 },
271 ],
272 },
273 { status: 422 },
274 ),
275 );
276 return Effect.gen(function* () {
277 const api = yield* GitHubApi.GitHubApi;
278 const error = yield* Effect.flip(
279 api.rest({
280 host: "github.com",
281 operation: "createPullRequest",
282 method: "POST",
283 path: "repos/acme/web/pulls",
284 }),
285 );
286 expect(error.message).toBe(
287 "GitHub returned an error: Validation Failed; A pull request already exists for acme:feature.",
288 );
289 }).pipe(Effect.provide(layer));
290 });
291
292 it.effect("lets an interactive request through a pause a background read recorded", () => {
293 const reset = Math.floor(NOW / 1000) + 600;
294 let call = 0;
295 const { layer, requests } = harness(() =>
296 ++call === 1
297 ? json(
298 { message: "API rate limit exceeded" },
299 {
300 status: 403,
301 headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) },
302 },
303 )
304 : json({ ok: true }),
305 );
306 return Effect.gen(function* () {
307 yield* TestClock.setTime(NOW);
308 const api = yield* GitHubApi.GitHubApi;
309 const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" };
310 yield* Effect.flip(api.rest(read));
311 expect((yield* Effect.flip(api.rest(read)))._tag).toBe("SourceControlRateLimitPausedError");
312 const merged = yield* api
313 .rest({
314 host: "github.com",
315 operation: "merge",
316 method: "PUT",
317 path: "repos/acme/web/pulls/7/merge",
318 })
319 .pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true));
320 expect(merged.status).toBe(200);
321 expect(requests).toHaveLength(2);
322 }).pipe(Effect.provide(layer));
323 });
324
325 it.effect("reads an untyped GraphQL quota error as a rate limit", () => {
326 const reset = Math.floor(NOW / 1000) + 900;
327 const { layer } = harness(() =>
328 json(
329 { errors: [{ message: "API rate limit already exceeded for user ID 1." }] },
330 { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } },
331 ),
332 );
333 return Effect.gen(function* () {
334 yield* TestClock.setTime(NOW);
335 const api = yield* GitHubApi.GitHubApi;
336 const error = yield* Effect.flip(
337 api.graphql({ host: "github.com", operation: "lookup", query: "query { viewer { id } }" }),
338 );
339 expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 });
340 }).pipe(Effect.provide(layer));
341 });
342
343 it.effect("maps REST 403 with an exhausted quota to a rate limit, and 304 to an answer", () => {
344 let call = 0;
345 const { layer } = harness(() =>
346 ++call === 1
347 ? new Response(null, { status: 304 })
348 : json(
349 { message: "API rate limit exceeded" },
350 {
351 status: 403,
352 headers: { "retry-after": "60" },
353 },
354 ),
355 );
356 return Effect.gen(function* () {
357 yield* TestClock.setTime(NOW);
358 const api = yield* GitHubApi.GitHubApi;
359 const notModified = yield* api.rest({
360 host: "github.com",
361 operation: "checks",
362 path: "repos/acme/web/pulls/7",
363 ifNoneMatch: '"abc"',
364 });
365 expect(notModified.status).toBe(304);
366 const error = yield* Effect.flip(
367 api.rest({ host: "github.com", operation: "checks", path: "repos/acme/web/pulls/7" }),
368 );
369 expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: NOW + 60_000 });
370 }).pipe(Effect.provide(layer));
371 });
372
373 it.effect("drops a refused token so the next request asks the source again", () => {
374 const { layer, requests, invalidations } = harness((request) =>
375 request.headers.authorization === "Bearer first"
376 ? new Response(null, { status: 401 })
377 : json({ ok: true }),
378 );
379 return Effect.gen(function* () {
380 const api = yield* GitHubApi.GitHubApi;
381 const refused = yield* Effect.flip(
382 api.rest({ host: "github.com", operation: "user", path: "user" }),
383 );
384 expect(refused._tag).toBe("GitHubApiAuthenticationError");
385 expect(invalidations()).toBe(1);
386 const answered = yield* api.rest({ host: "github.com", operation: "user", path: "user" });
387 expect(answered.status).toBe(200);
388 expect(requests.map((request) => request.headers.authorization)).toEqual([
389 "Bearer first",
390 "Bearer second",
391 ]);
392 }).pipe(Effect.provide(layer));
393 });
394
395 it.effect("refuses to send a pinned credential to another host", () => {
396 const { layer, requests } = harness(() => json({}));
397 return Effect.gen(function* () {
398 const api = yield* GitHubApi.GitHubApi;
399 const error = yield* Effect.flip(
400 api.rest({ host: "git.acme.internal", operation: "user", path: "user" }),
401 ).pipe(
402 Effect.provideService(GitHubApi.PinnedGitHubCredential, {
403 host: "github.com",
404 token: Redacted.make("pinned"),
405 credentialFingerprint: "github.com:pinned",
406 }),
407 );
408 expect(error._tag).toBe("GitHubApiAuthenticationError");
409 expect(requests).toHaveLength(0);
410 }).pipe(Effect.provide(layer));
411 });
412});
413
414describe("GitHubCredentials", () => {
415 const credentialsWith = (run: VcsProcess.VcsProcess["Service"]["run"]) =>
416 GitHubCredentials.layer.pipe(
417 Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })),
418 Layer.provide(NodeServices.layer),
419 );
420
421 it.effect("fails with GitHubCliMissingError when gh is not on PATH", () =>
422 Effect.gen(function* () {
423 const credentials = yield* GitHubCredentials.GitHubCredentials;
424 const error = yield* Effect.flip(credentials.get("git.acme.internal"));
425 expect(error._tag).toBe("GitHubCliMissingError");
426 }).pipe(
427 Effect.provide(
428 credentialsWith(() =>
429 Effect.fail(
430 new VcsProcessSpawnError({
431 operation: "GitHubCredentials.get",
432 command: "gh",
433 cwd: "/",
434 cause: PlatformError.systemError({
435 _tag: "NotFound",
436 module: "ChildProcess",
437 method: "spawn",
438 }),
439 }),
440 ),
441 ),
442 ),
443 ),
444 );
445
446 it.effect("does not cache a gh failure that is not about signing in", () => {
447 let calls = 0;
448 return Effect.gen(function* () {
449 const credentials = yield* GitHubCredentials.GitHubCredentials;
450 const first = yield* Effect.flip(credentials.get("github.com"));
451 expect(first._tag).toBe("GitHubCliFailedError");
452 expect((yield* credentials.get("github.com")).source).toBe("gh");
453 expect(calls).toBe(2);
454 }).pipe(
455 Effect.provide(
456 credentialsWith(() =>
457 ++calls === 1
458 ? Effect.fail(
459 new VcsProcessTimeoutError({
460 operation: "GitHubCredentials.get",
461 command: "gh",
462 cwd: "/",
463 timeoutMs: 10_000,
464 }),
465 )
466 : Effect.succeed({
467 exitCode: ChildProcessSpawner.ExitCode(0),
468 stdout: "token\n",
469 stderr: "",
470 stdoutTruncated: false,
471 stderrTruncated: false,
472 }),
473 ),
474 ),
475 );
476 });
477
478 it.effect("fails with GitHubNotSignedInError when gh prints no token", () =>
479 Effect.gen(function* () {
480 const credentials = yield* GitHubCredentials.GitHubCredentials;
481 const error = yield* Effect.flip(credentials.get("git.acme.internal"));
482 expect(error._tag).toBe("GitHubNotSignedInError");
483 }).pipe(
484 Effect.provide(
485 credentialsWith(() =>
486 Effect.succeed({
487 exitCode: ChildProcessSpawner.ExitCode(0),
488 stdout: "\n",
489 stderr: "",
490 stdoutTruncated: false,
491 stderrTruncated: false,
492 }),
493 ),
494 ),
495 ),
496 );
497});
apps/server/src/sourceControl/GitHubApi.ts created+593
......@@ -0,0 +1,593 @@
1import * as Clock from "effect/Clock";
2import * as Context from "effect/Context";
3import * as Data from "effect/Data";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8import * as Redacted from "effect/Redacted";
9import * as Schema from "effect/Schema";
10import * as Semaphore from "effect/Semaphore";
11import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http";
12
13import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
14import * as GitHubCredentials from "./GitHubCredentials.ts";
15import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
16import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
17
18const DEFAULT_TIMEOUT = Duration.seconds(30);
19const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
20/** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */
21const CONCURRENCY = 8;
22const API_VERSION = "2022-11-28";
23
24/**
25 * A credential already verified for one host. Every request made under it must target that
26 * host, so a page cannot read one account's data with another's token mid-flight. Server-local:
27 * never put its value in RPC payloads or cache keys.
28 */
29export const PinnedGitHubCredential = Context.Reference<{
30 readonly host: string;
31 readonly token: Redacted.Redacted<string>;
32 readonly credentialFingerprint: string;
33} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null });
34
35/**
36 * Set by interactive callers (a user's read or write, not a background sweep). Requests made
37 * under it may spend the GraphQL reserve and go through a rate-limit pause: a user acting on a
38 * pull request should not be refused because a background read exhausted the quota.
39 */
40export const AllowGitHubReserve = Context.Reference<boolean>(
41 "t3/sourceControl/AllowGitHubReserve",
42 { defaultValue: () => false },
43);
44
45export class GitHubApiRequestError extends Schema.TaggedError<GitHubApiRequestError>()(
46 "GitHubApiRequestError",
47 { host: Schema.String, operation: Schema.String, cause: Schema.Defect() },
48) {
49 override get message(): string {
50 return `Could not reach GitHub at ${this.host}.`;
51 }
52}
53
54export class GitHubApiAuthenticationError extends Schema.TaggedError<GitHubApiAuthenticationError>()(
55 "GitHubApiAuthenticationError",
56 { host: Schema.String, operation: Schema.String },
57) {
58 override get message(): string {
59 return `GitHub refused the credential for ${this.host}. Run \`gh auth login --hostname ${this.host}\` and retry.`;
60 }
61}
62
63export class GitHubApiRateLimitError extends Schema.TaggedError<GitHubApiRateLimitError>()(
64 "GitHubApiRateLimitError",
65 {
66 host: Schema.String,
67 operation: Schema.String,
68 retryAt: Schema.optionalKey(Schema.Finite),
69 },
70) {
71 override get message(): string {
72 return "GitHub API rate limit exceeded.";
73 }
74}
75
76export class GitHubApiNotFoundError extends Schema.TaggedError<GitHubApiNotFoundError>()(
77 "GitHubApiNotFoundError",
78 { host: Schema.String, operation: Schema.String },
79) {
80 override get message(): string {
81 return "GitHub could not find the requested resource, or the credential cannot see it.";
82 }
83}
84
85/**
86 * GitHub answered with a failure that is none of the above. `githubErrors` carries GitHub's own
87 * error messages, from a GraphQL `errors` list or a REST `message`/`errors` body: they name the
88 * field and the reason ("A pull request already exists for acme:feature"), never a token.
89 */
90export class GitHubApiResponseError extends Schema.TaggedError<GitHubApiResponseError>()(
91 "GitHubApiResponseError",
92 {
93 host: Schema.String,
94 operation: Schema.String,
95 status: Schema.Int,
96 githubErrors: Schema.optionalKey(Schema.Array(Schema.String)),
97 },
98) {
99 override get message(): string {
100 return this.githubErrors !== undefined && this.githubErrors.length > 0
101 ? `GitHub returned an error: ${this.githubErrors.join("; ")}`
102 : `GitHub returned HTTP ${this.status}.`;
103 }
104}
105
106export type GitHubApiError =
107 | GitHubCredentials.GitHubCredentialUnavailableError
108 | GitHubApiRequestError
109 | GitHubApiAuthenticationError
110 | GitHubApiRateLimitError
111 | GitHubApiNotFoundError
112 | GitHubApiResponseError
113 | SourceControlRateLimit.SourceControlRateLimitPausedError;
114
115export interface GitHubRestResponse {
116 readonly status: number;
117 readonly headers: Readonly<Record<string, string | undefined>>;
118 readonly body: string;
119 readonly truncated: boolean;
120}
121
122export interface GitHubRestInput {
123 readonly host: string;
124 readonly operation: string;
125 readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
126 /** Relative to the API root, e.g. `repos/acme/web/pulls/7`; query string included. */
127 readonly path: string;
128 readonly body?: unknown;
129 /** Defaults to `application/vnd.github+json`. */
130 readonly accept?: string;
131 /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */
132 readonly ifNoneMatch?: string;
133 readonly maxResponseBytes?: number;
134 /** Overrides `AllowGitHubReserve` for this one request. */
135 readonly allowReserve?: boolean;
136}
137
138export interface GitHubGraphQlInput {
139 readonly host: string;
140 readonly operation: string;
141 readonly query: string;
142 readonly variables?: Readonly<Record<string, unknown>>;
143 readonly allowReserve?: boolean;
144}
145
146export class GitHubApi extends Context.Service<
147 GitHubApi,
148 {
149 /** The raw JSON body of a successful GraphQL answer, with `rateLimit` recorded. */
150 readonly graphql: (input: GitHubGraphQlInput) => Effect.Effect<string, GitHubApiError>;
151 readonly rest: (input: GitHubRestInput) => Effect.Effect<GitHubRestResponse, GitHubApiError>;
152 /** The credential a request to `host` would carry right now. */
153 readonly credential: (
154 host: string,
155 ) => Effect.Effect<
156 { readonly token: Redacted.Redacted<string>; readonly fingerprint: string },
157 GitHubApiError
158 >;
159 }
160>()("t3/sourceControl/GitHubApi") {}
161
162function normalizeHost(host: string): string {
163 return host.trim().toLowerCase();
164}
165
166/**
167 * Where the API for a host lives. github.com and GHE.com data residency serve it from an `api.`
168 * subdomain; GitHub Enterprise Server serves it under `/api` on the instance itself.
169 */
170export function gitHubApiUrls(host: string): { readonly rest: string; readonly graphql: string } {
171 const normalized = normalizeHost(host);
172 if (normalized === "github.com") {
173 return { rest: "https://api.github.com", graphql: "https://api.github.com/graphql" };
174 }
175 if (normalized.endsWith(".ghe.com")) {
176 return {
177 rest: `https://api.${normalized}`,
178 graphql: `https://api.${normalized}/graphql`,
179 };
180 }
181 return { rest: `https://${normalized}/api/v3`, graphql: `https://${normalized}/api/graphql` };
182}
183
184/** GraphQL documents longer than this are cut in traces; the hash still identifies them. */
185const TRACED_QUERY_MAX_CHARS = 4_000;
186
187/** A stable short id for a GraphQL document, so traces group by query without its text. */
188function queryHash(query: string): string {
189 let hash = 0x811c9dc5;
190 for (let index = 0; index < query.length; index++) {
191 hash ^= query.charCodeAt(index);
192 hash = Math.imul(hash, 0x01000193);
193 }
194 return (hash >>> 0).toString(16).padStart(8, "0");
195}
196
197/** The numeric rate-limit headers GitHub sends, as span attributes. */
198function rateLimitAttributes(
199 headers: Readonly<Record<string, string | undefined>>,
200): Record<string, number | string> {
201 const attributes: Record<string, number | string> = {};
202 for (const [header, name] of [
203 ["x-ratelimit-limit", "github.ratelimit.limit"],
204 ["x-ratelimit-remaining", "github.ratelimit.remaining"],
205 ["x-ratelimit-used", "github.ratelimit.used"],
206 ["x-ratelimit-reset", "github.ratelimit.reset"],
207 ] as const) {
208 const value = Number(headers[header]);
209 if (headers[header] !== undefined && Number.isFinite(value)) attributes[name] = value;
210 }
211 const resource = headers["x-ratelimit-resource"];
212 if (resource !== undefined) attributes["github.ratelimit.resource"] = resource;
213 return attributes;
214}
215
216const decodeGraphQlCost = Schema.decodeUnknownOption(
217 Schema.fromJsonString(
218 Schema.Struct({
219 data: Schema.Struct({
220 rateLimit: Schema.Struct({ cost: Schema.Number, remaining: Schema.Number }),
221 }),
222 }),
223 ),
224);
225
226/** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */
227function retryAtFrom(
228 headers: Readonly<Record<string, string | undefined>>,
229 now: number,
230): number | undefined {
231 const fromRetryAfter = SourceControlRateLimit.retryAtFromHeader(headers["retry-after"], now);
232 if (fromRetryAfter !== undefined) return fromRetryAfter;
233 const reset = Number(headers["x-ratelimit-reset"]) * 1_000;
234 return Number.isFinite(reset) && reset > now ? reset : undefined;
235}
236
237const decodeGraphQlErrors = Schema.decodeUnknownOption(
238 Schema.fromJsonString(
239 Schema.Struct({
240 errors: Schema.NonEmptyArray(
241 Schema.Struct({
242 type: Schema.optional(Schema.String),
243 message: Schema.optional(Schema.String),
244 }),
245 ),
246 }),
247 ),
248);
249
250/** A REST failure body: `{ message, errors: [{ message } | { resource, field, code }] }`. */
251const decodeRestErrors = Schema.decodeUnknownOption(
252 Schema.fromJsonString(
253 Schema.Struct({
254 message: Schema.optional(Schema.String),
255 errors: Schema.optional(
256 Schema.Array(
257 Schema.Union([
258 Schema.String,
259 Schema.Struct({
260 message: Schema.optional(Schema.String),
261 field: Schema.optional(Schema.String),
262 code: Schema.optional(Schema.String),
263 }),
264 ]),
265 ),
266 ),
267 }),
268 ),
269);
270
271function restErrorMessages(body: string): ReadonlyArray<string> | undefined {
272 return Option.match(decodeRestErrors(body), {
273 onNone: () => undefined,
274 onSome: (decoded) => {
275 const details = (decoded.errors ?? []).flatMap((error) =>
276 typeof error === "string"
277 ? [error]
278 : error.message !== undefined
279 ? [error.message]
280 : error.field !== undefined && error.code !== undefined
281 ? [`${error.field} ${error.code}`]
282 : [],
283 );
284 const messages = [...(decoded.message === undefined ? [] : [decoded.message]), ...details];
285 return messages.length > 0 ? messages : undefined;
286 },
287 });
288}
289
290/** What one GitHub answer means, decided once from its status, headers and body. */
291type Answer = Data.TaggedEnum<{
292 Ok: {};
293 RateLimited: {};
294 Unauthorized: {};
295 NotFound: {};
296 Failed: { readonly messages: ReadonlyArray<string> | undefined };
297}>;
298const Answer = Data.taggedEnum<Answer>();
299
300/**
301 * GitHub reports a failed GraphQL document with HTTP 200 and an `errors` list, so a GraphQL body
302 * is read for its error types as well as its status. `gh api graphql` failed those too, and
303 * callers rely on that to fall back to narrower reads.
304 */
305function classify(input: {
306 readonly status: number;
307 readonly headers: Readonly<Record<string, string | undefined>>;
308 readonly body: string;
309 readonly graphql: boolean;
310 readonly acceptNotModified: boolean;
311}): Answer {
312 const { status, headers, body } = input;
313 const errors = input.graphql
314 ? Option.getOrUndefined(decodeGraphQlErrors(body))?.errors
315 : undefined;
316 const types = errors?.flatMap((error) => (error.type === undefined ? [] : [error.type])) ?? [];
317 const messages = errors?.flatMap((error) => (error.message === undefined ? [] : [error.message]));
318 if (
319 status === 429 ||
320 types.includes("RATE_LIMITED") ||
321 // An exhausted GraphQL quota can answer HTTP 200 with an untyped "API rate limit already
322 // exceeded" error; the headers say the same thing.
323 (input.graphql && errors !== undefined && headers["x-ratelimit-remaining"] === "0") ||
324 (input.graphql &&
325 messages !== undefined &&
326 messages.some((message) => /rate limit (already )?exceeded/i.test(message))) ||
327 (status === 403 &&
328 (headers["x-ratelimit-remaining"] === "0" ||
329 headers["retry-after"] !== undefined ||
330 /rate limit/i.test(body)))
331 ) {
332 return Answer.RateLimited();
333 }
334 if (status === 401) return Answer.Unauthorized();
335 if (
336 status === 404 ||
337 (errors !== undefined && errors.every((error) => error.type === "NOT_FOUND"))
338 ) {
339 return Answer.NotFound();
340 }
341 if (errors !== undefined) return Answer.Failed({ messages });
342 if ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) {
343 return Answer.Ok();
344 }
345 return Answer.Failed({ messages: input.graphql ? undefined : restErrorMessages(body) });
346}
347
348/** @public Service construction is part of the canonical Effect module API. */
349export const make = Effect.gen(function* () {
350 const httpClient = yield* HttpClient.HttpClient;
351 const credentials = yield* GitHubCredentials.GitHubCredentials;
352 const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
353 const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
354 const gate = yield* Semaphore.make(CONCURRENCY);
355
356 const credential: GitHubApi["Service"]["credential"] = Effect.fn("GitHubApi.credential")(
357 function* (host) {
358 const normalized = normalizeHost(host);
359 const pinned = yield* PinnedGitHubCredential;
360 if (pinned !== null) {
361 // A pinned page only ever talks to the host it verified.
362 if (pinned.host !== normalized) {
363 return yield* new GitHubApiAuthenticationError({
364 host: normalized,
365 operation: "credential",
366 });
367 }
368 return { token: pinned.token, fingerprint: pinned.credentialFingerprint };
369 }
370 const held = yield* credentials.get(normalized);
371 return { token: held.token, fingerprint: held.fingerprint };
372 },
373 );
374
375 /**
376 * Sends one request under the host's rate-limit pause and fails a refusal with the error that
377 * says why. `onSuccess` sees the answer only once it is known to be one.
378 */
379 const send = Effect.fn("GitHubApi.send")(function* (input: {
380 readonly host: string;
381 readonly operation: string;
382 readonly request: HttpClientRequest.HttpClientRequest;
383 readonly maxResponseBytes: number;
384 readonly allowReserve: boolean;
385 readonly acceptNotModified: boolean;
386 /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */
387 readonly graphql?: boolean;
388 readonly timeout?: Duration.Duration | undefined;
389 }) {
390 const host = normalizeHost(input.host);
391 // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace.
392 yield* Effect.annotateCurrentSpan({
393 "github.host": host,
394 "github.operation": input.operation,
395 "github.kind": input.graphql === true ? "graphql" : "rest",
396 "http.request.method": input.request.method,
397 "url.path": new URL(input.request.url).pathname,
398 });
399 const { token, fingerprint } = yield* credential(host);
400 const scope = yield* SourceControlRateLimit.CredentialScope;
401 const key = { provider: "github" as const, host };
402 const run = Effect.gen(function* () {
403 const lease = yield* limits
404 .check(key, input.allowReserve ? { allowPaused: true } : undefined)
405 .pipe(
406 Effect.tapError((paused) =>
407 Effect.annotateCurrentSpan({
408 "github.paused": true,
409 "github.retry_at": paused.retryAt,
410 }),
411 ),
412 );
413 // One deadline covers the headers and the body: a host that answers headers and then stalls
414 // must not hold a slot of the shared gate for undici's own five-minute body timeout.
415 const { response, collected } = yield* Effect.gen(function* () {
416 const response: HttpClientResponse.HttpClientResponse = yield* httpClient
417 .execute(
418 input.request.pipe(
419 HttpClientRequest.bearerToken(Redacted.value(token)),
420 HttpClientRequest.setHeaders({
421 "x-github-api-version": API_VERSION,
422 "user-agent": "t3code",
423 }),
424 ),
425 )
426 .pipe(
427 // `GitHubApi.send` is the request's span. The client's own span would add `url.full`
428 // and `url.query`, which can carry SHAs and branch names, so it is off for GitHub.
429 Effect.provideService(HttpClient.TracerDisabledWhen, () => true),
430 );
431 const collected = yield* collectUint8StreamText({
432 stream: response.stream,
433 maxBytes: input.maxResponseBytes,
434 }).pipe(
435 // 204, 304 and many refusals carry no body at all, which is an empty answer.
436 Effect.catchIf(
437 (error) => error.reason._tag === "EmptyBodyError",
438 () => Effect.succeed({ text: "", truncated: false }),
439 ),
440 );
441 return { response, collected };
442 }).pipe(
443 Effect.timeout(input.timeout ?? DEFAULT_TIMEOUT),
444 Effect.mapError(
445 (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }),
446 ),
447 );
448 const headers = response.headers;
449 const status = response.status;
450 yield* Effect.annotateCurrentSpan({
451 "http.response.status_code": status,
452 ...rateLimitAttributes(headers),
453 });
454 const context = { host, operation: input.operation };
455 return yield* Answer.$match(
456 classify({
457 status,
458 headers,
459 body: collected.text,
460 graphql: input.graphql === true,
461 acceptNotModified: input.acceptNotModified,
462 }),
463 {
464 Ok: () =>
465 limits.recordSuccess({ ...key, lease }).pipe(
466 Effect.as({
467 status,
468 headers,
469 body: collected.text,
470 truncated: collected.truncated,
471 }),
472 ),
473 RateLimited: () =>
474 Effect.gen(function* () {
475 const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis);
476 yield* limits.recordRateLimit({ ...key, lease, retryAt });
477 yield* Effect.annotateCurrentSpan({
478 "github.rate_limited": true,
479 ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }),
480 });
481 return yield* new GitHubApiRateLimitError({
482 ...context,
483 ...(retryAt === undefined ? {} : { retryAt }),
484 });
485 }),
486 // The source may hold a newer token than the one that was refused.
487 Unauthorized: () =>
488 credentials
489 .invalidate(host)
490 .pipe(Effect.andThen(Effect.fail(new GitHubApiAuthenticationError(context)))),
491 NotFound: () => Effect.fail(new GitHubApiNotFoundError(context)),
492 Failed: ({ messages }) =>
493 Effect.fail(
494 new GitHubApiResponseError({
495 ...context,
496 status,
497 ...(messages === undefined ? {} : { githubErrors: messages }),
498 }),
499 ),
500 },
501 );
502 });
503 // The rate-limit scope follows the credential, so a pause on one account never blocks another.
504 return yield* gate
505 .withPermit(run)
506 .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint));
507 });
508
509 const rest: GitHubApi["Service"]["rest"] = (input) => {
510 const url = `${gitHubApiUrls(input.host).rest}/${input.path.replace(/^\/+/, "")}`;
511 const base = HttpClientRequest.make(input.method ?? "GET")(url).pipe(
512 HttpClientRequest.setHeader("accept", input.accept ?? "application/vnd.github+json"),
513 );
514 const withEtag =
515 input.ifNoneMatch === undefined
516 ? base
517 : base.pipe(HttpClientRequest.setHeader("if-none-match", input.ifNoneMatch));
518 const request =
519 input.body === undefined
520 ? withEtag
521 : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body));
522 return AllowGitHubReserve.pipe(
523 Effect.flatMap((interactive) =>
524 send({
525 host: input.host,
526 operation: input.operation,
527 request,
528 maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES,
529 allowReserve: input.allowReserve ?? interactive,
530 acceptNotModified: input.ifNoneMatch !== undefined,
531 }),
532 ),
533 );
534 };
535
536 const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")(
537 function* (input) {
538 const host = normalizeHost(input.host);
539 const { fingerprint } = yield* credential(host);
540 const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint;
541 const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve);
542 // The document, never its variables: user text (bodies, search terms) travels as variables.
543 yield* Effect.annotateCurrentSpan({
544 "github.operation": input.operation,
545 "github.graphql.query_hash": queryHash(input.query),
546 "github.graphql.query":
547 input.query.length > TRACED_QUERY_MAX_CHARS
548 ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…`
549 : input.query,
550 });
551 return yield* Effect.gen(function* () {
552 const query = yield* budget.query(
553 host,
554 input.query,
555 allowReserve ? { allowReserve: true } : undefined,
556 );
557 const response = yield* send({
558 host,
559 operation: input.operation,
560 request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe(
561 HttpClientRequest.acceptJson,
562 HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }),
563 ),
564 maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES,
565 allowReserve,
566 acceptNotModified: false,
567 graphql: true,
568 });
569 // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded.
570 if (response.truncated) {
571 return yield* new GitHubApiResponseError({
572 host,
573 operation: input.operation,
574 status: response.status,
575 });
576 }
577 yield* budget.observe(host, response.body);
578 const cost = Option.getOrUndefined(decodeGraphQlCost(response.body));
579 if (cost !== undefined) {
580 yield* Effect.annotateCurrentSpan({
581 "github.graphql.cost": cost.data.rateLimit.cost,
582 "github.graphql.remaining": cost.data.rateLimit.remaining,
583 });
584 }
585 return response.body;
586 }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope));
587 },
588 );
589
590 return GitHubApi.of({ graphql, rest, credential });
591});
592
593export const layer = Layer.effect(GitHubApi, make);
apps/server/src/sourceControl/GitHubCli.ts+2-5
......@@ -23,6 +23,7 @@ import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
2323import { decodeJsonResult } from "@t3tools/shared/schemaJson";
2424
2525import * as VcsProcess from "../vcs/VcsProcess.ts";
26import * as GitHubApi from "./GitHubApi.ts";
2627import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
2728import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
2829import {
......@@ -35,11 +36,7 @@ import {
3536const DEFAULT_TIMEOUT_MS = 30_000;
3637
3738/** Server-local credential scope; never put its value in RPC payloads or cache keys. */
38export const PinnedGitHubCredential = Context.Reference<{
39 readonly host: string;
40 readonly token: Redacted.Redacted<string>;
41 readonly credentialFingerprint: string;
42} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null });
39export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential;
4340
4441export const AllowGitHubReserve = Context.Reference<boolean>(
4542 "t3/sourceControl/AllowGitHubReserve",
apps/server/src/sourceControl/GitHubCredentials.ts created+189
......@@ -0,0 +1,189 @@
1import * as Cache from "effect/Cache";
2import * as Context from "effect/Context";
3import * as Crypto from "effect/Crypto";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Hex from "effect/encoding/Hex";
7import * as Exit from "effect/Exit";
8import * as Layer from "effect/Layer";
9import * as Option from "effect/Option";
10import * as PlatformError from "effect/PlatformError";
11import * as Redacted from "effect/Redacted";
12import * as Schema from "effect/Schema";
13
14import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";
15
16import * as VcsProcess from "../vcs/VcsProcess.ts";
17
18/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */
19const TOKEN_TTL = Duration.minutes(5);
20/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */
21const MISSING_TTL = Duration.seconds(10);
22
23export const GitHubCredentialSource = Schema.Literals(["env", "gh"]);
24export type GitHubCredentialSource = typeof GitHubCredentialSource.Type;
25
26export interface GitHubCredential {
27 readonly host: string;
28 readonly token: Redacted.Redacted<string>;
29 readonly source: GitHubCredentialSource;
30 /** A digest of host and token: safe for cache keys and rate-limit scopes, never the token. */
31 readonly fingerprint: string;
32}
33
34/** Nothing in the environment, and no `gh` on PATH to ask. */
35export class GitHubCliMissingError extends Schema.TaggedError<GitHubCliMissingError>()(
36 "GitHubCliMissingError",
37 { host: Schema.String },
38) {
39 override get message(): string {
40 return `No GitHub credential for ${this.host}: set GH_TOKEN, or install the GitHub CLI and run \`gh auth login\`.`;
41 }
42}
43
44/** `gh` is installed but holds no login for the host. */
45export class GitHubNotSignedInError extends Schema.TaggedError<GitHubNotSignedInError>()(
46 "GitHubNotSignedInError",
47 { host: Schema.String },
48) {
49 override get message(): string {
50 return `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.`;
51 }
52}
53
54/** `gh auth token` timed out or failed for a reason other than having no login. */
55export class GitHubCliFailedError extends Schema.TaggedError<GitHubCliFailedError>()(
56 "GitHubCliFailedError",
57 { host: Schema.String, cause: Schema.Defect() },
58) {
59 override get message(): string {
60 return `The GitHub CLI could not hand over a credential for ${this.host}. Check \`gh auth status\` on the server.`;
61 }
62}
63
64/** There is no token for the host. */
65export type GitHubCredentialUnavailableError =
66 | GitHubCliMissingError
67 | GitHubNotSignedInError
68 | GitHubCliFailedError;
69
70/**
71 * Where GitHub tokens come from. Callers ask per host and never see how the token was found,
72 * so another source (an in-app OAuth login) slots in here without touching any of them.
73 */
74export class GitHubCredentials extends Context.Service<
75 GitHubCredentials,
76 {
77 readonly get: (
78 host: string,
79 ) => Effect.Effect<GitHubCredential, GitHubCredentialUnavailableError>;
80 /** Drops the held token after GitHub refused it, so the next read asks its source again. */
81 readonly invalidate: (host: string) => Effect.Effect<void>;
82 }
83>()("t3/sourceControl/GitHubCredentials") {}
84
85function normalizeHost(host: string): string {
86 return host.trim().toLowerCase();
87}
88
89/** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */
90function isGitHubDotCom(host: string): boolean {
91 return host === "github.com" || host.endsWith(".ghe.com");
92}
93
94/**
95 * The environment token for a host, in gh's precedence order. gh hands `GH_ENTERPRISE_TOKEN` to
96 * any non-github.com host; here it only goes to the host `GH_HOST` names, because a remote URL
97 * picks the host and a hostile one must not receive an enterprise token.
98 */
99export function environmentToken(
100 host: string,
101 env: Readonly<Record<string, string | undefined>>,
102): string | null {
103 const names = isGitHubDotCom(host)
104 ? ["GH_TOKEN", "GITHUB_TOKEN"]
105 : env.GH_HOST?.trim().toLowerCase() === host
106 ? ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]
107 : [];
108 for (const name of names) {
109 const value = env[name]?.trim();
110 if (value) return value;
111 }
112 return null;
113}
114
115/** @public Service construction is part of the canonical Effect module API. */
116export const make = Effect.gen(function* () {
117 const process = yield* VcsProcess.VcsProcess;
118 const crypto = yield* Crypto.Crypto;
119 const environment = yield* HostProcessEnvironment;
120 const workingDirectory = yield* HostProcessWorkingDirectory;
121
122 /** `host:sha256(token)`, safe for cache keys and rate-limit scopes. */
123 const fingerprintOf = (host: string, token: string) =>
124 crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe(
125 Effect.map((digest) => `${host}:${Hex.encode(digest)}`),
126 // Hashing a string in memory has no platform failure worth a typed error.
127 Effect.orDie,
128 );
129
130 const fromGh = (host: string) =>
131 process
132 .run({
133 operation: "GitHubCredentials.get",
134 command: "gh",
135 args: ["auth", "token", "--hostname", host],
136 cwd: workingDirectory,
137 // Never let gh print the token into a debug log.
138 env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" },
139 timeoutMs: 10_000,
140 })
141 .pipe(
142 Effect.mapError((error) =>
143 error._tag === "VcsProcessSpawnError" &&
144 error.cause instanceof PlatformError.PlatformError &&
145 error.cause.reason._tag === "NotFound"
146 ? new GitHubCliMissingError({ host })
147 : // gh exits non-zero with "no oauth token" when it has no login for the host.
148 error._tag === "VcsProcessExitError"
149 ? new GitHubNotSignedInError({ host })
150 : new GitHubCliFailedError({ host, cause: error }),
151 ),
152 Effect.map((output) => output.stdout.trim()),
153 Effect.filterOrFail(
154 (token) => token !== "",
155 () => new GitHubNotSignedInError({ host }),
156 ),
157 );
158
159 const lookup = Effect.fn("GitHubCredentials.lookup")(function* (host: string) {
160 const fromEnv = environmentToken(host, environment);
161 const token = fromEnv ?? (yield* fromGh(host));
162 return {
163 host,
164 token: Redacted.make(token),
165 source: fromEnv !== null ? "env" : "gh",
166 fingerprint: yield* fingerprintOf(host, token),
167 } satisfies GitHubCredential;
168 });
169
170 const cache = yield* Cache.makeWith(lookup, {
171 capacity: 32,
172 // A transient gh failure (a timeout, a locked keyring) is asked again on the next read.
173 timeToLive: (exit) =>
174 Exit.isSuccess(exit)
175 ? TOKEN_TTL
176 : Exit.findErrorOption(exit).pipe(
177 Option.exists((error) => error._tag === "GitHubCliFailedError"),
178 )
179 ? Duration.zero
180 : MISSING_TTL,
181 });
182
183 return GitHubCredentials.of({
184 get: (host) => Cache.get(cache, normalizeHost(host)),
185 invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)),
186 });
187});
188
189export const layer = Layer.effect(GitHubCredentials, make);