1import { describe, expect, it } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
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 GitHubQuota from "./GitHubQuota.ts";
16import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
17import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
18import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
19
20const NOW = Date.parse("2026-10-05T12:00:00.000Z");
21
22function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Response) {
23 const requests: Array<HttpClientRequest.HttpClientRequest> = [];
24 let tokens = ["first", "second"];
25 let invalidations = 0;
26 let credentialReads = 0;
27 const credentials = Layer.succeed(
28 GitHubCredentials.GitHubCredentials,
29 GitHubCredentials.GitHubCredentials.of({
30 get: (host) =>
31 Effect.sync(() => {
32 credentialReads++;
33 return {
34 host,
35 token: Redacted.make(tokens[0]!),
36 source: "gh" as const,
37 fingerprint: `${host}:${tokens[0]}`,
38 };
39 }),
40 invalidate: () =>
41 Effect.sync(() => {
42 invalidations++;
43 tokens = tokens.slice(1);
44 }),
45 }),
46 );
47 const http = Layer.succeed(
48 HttpClient.HttpClient,
49 HttpClient.make((request) => {
50 requests.push(request);
51 return Effect.succeed(HttpClientResponse.fromWeb(request, respond(request)));
52 }),
53 );
54 const layer = GitHubApi.layer.pipe(
55 Layer.provide(Layer.mergeAll(credentials, http)),
56 Layer.provideMerge(GitHubQuota.layer),
57 Layer.provideMerge(SourceControlRateLimit.layer),
58 );
59 return {
60 layer,
61 requests,
62 invalidations: () => invalidations,
63 credentialReads: () => credentialReads,
64 };
65}
66
67const json = (body: unknown, init?: ResponseInit) =>
68 new Response(JSON.stringify(body), {
69 ...init,
70 headers: { "content-type": "application/json", ...init?.headers },
71 });
72
73describe("gitHubApiUrls", () => {
74 it("maps github.com, GHE.com and GitHub Enterprise Server", () => {
75 expect(GitHubApi.gitHubApiUrls("GitHub.com")).toEqual({
76 rest: "https://api.github.com",
77 graphql: "https://api.github.com/graphql",
78 });
79 expect(GitHubApi.gitHubApiUrls("acme.ghe.com").graphql).toBe(
80 "https://api.acme.ghe.com/graphql",
81 );
82 expect(GitHubApi.gitHubApiUrls("git.acme.internal")).toEqual({
83 rest: "https://git.acme.internal/api/v3",
84 graphql: "https://git.acme.internal/api/graphql",
85 });
86 });
87});
88
89describe("environmentToken", () => {
90 it("follows gh's precedence per kind of host", () => {
91 const env = { GH_TOKEN: "gh", GITHUB_TOKEN: "github", GH_ENTERPRISE_TOKEN: "ghe" };
92 expect(GitHubCredentials.environmentToken("github.com", env)).toBe("gh");
93 expect(GitHubCredentials.environmentToken("acme.ghe.com", { GITHUB_TOKEN: "github" })).toBe(
94 "github",
95 );
96 // An enterprise token only goes to the host GH_HOST names, never to whatever a remote says.
97 expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBeNull();
98 expect(
99 GitHubCredentials.environmentToken("git.acme.internal", {
100 ...env,
101 GH_HOST: "git.acme.internal",
102 }),
103 ).toBe("ghe");
104 expect(GitHubCredentials.environmentToken("git.acme.internal", { GH_TOKEN: "gh" })).toBeNull();
105 });
106});
107
108describe("GitHubApi", () => {
109 it.effect("sends GraphQL with the token, and the document as written", () => {
110 const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } }));
111 return Effect.gen(function* () {
112 yield* TestClock.setTime(NOW);
113 const api = yield* GitHubApi.GitHubApi;
114 const body = yield* api.graphql({
115 host: "github.com",
116 operation: "viewer",
117 query: "query { viewer { login } }",
118 });
119 expect(body).toContain('"login":"julius"');
120 expect(requests[0]!.url).toBe("https://api.github.com/graphql");
121 expect(requests[0]!.headers.authorization).toBe("Bearer first");
122 // No `rateLimit` selection is added: the quota is read off the response headers.
123 const sent = requests[0]!.body;
124 expect(sent._tag === "Uint8Array" ? new TextDecoder().decode(sent.body) : "").toContain(
125 '"query":"query { viewer { login } }"',
126 );
127 }).pipe(Effect.provide(layer));
128 });
129
130 it.effect("resolves GraphQL credentials once per request and refreshes after refusal", () => {
131 const { layer, requests, invalidations, credentialReads } = harness((request) =>
132 request.headers.authorization === "Bearer first"
133 ? new Response(null, { status: 401 })
134 : json({ data: { viewer: { login: "julius" } } }),
135 );
136 return Effect.gen(function* () {
137 const api = yield* GitHubApi.GitHubApi;
138 const input = {
139 host: "github.com",
140 operation: "viewer",
141 query: "query { viewer { login } }",
142 };
143 const refused = yield* Effect.flip(api.graphql(input));
144 expect(refused._tag).toBe("GitHubApiAuthenticationError");
145 expect(credentialReads()).toBe(1);
146 expect(invalidations()).toBe(1);
147
148 const body = yield* api.graphql(input);
149 expect(body).toContain('"login":"julius"');
150 expect(credentialReads()).toBe(2);
151 expect(requests.map((request) => request.headers.authorization)).toEqual([
152 "Bearer first",
153 "Bearer second",
154 ]);
155 }).pipe(Effect.provide(layer));
156 });
157
158 it.effect(
159 "traces the operation, path, query and quota, never the query string or variables",
160 () => {
161 const spans: Array<Tracer.NativeSpan> = [];
162 const tracer = Tracer.make({
163 span: (options) => {
164 const span = new Tracer.NativeSpan(options);
165 spans.push(span);
166 return span;
167 },
168 });
169 const { layer } = harness((request) =>
170 request.url.endsWith("/graphql")
171 ? json(
172 { data: { viewer: { login: "julius" } } },
173 { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } },
174 )
175 : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }),
176 );
177 return Effect.gen(function* () {
178 yield* TestClock.setTime(NOW);
179 const api = yield* GitHubApi.GitHubApi;
180 yield* api.graphql({
181 host: "github.com",
182 operation: "getPullRequestDetail",
183 query: "query($body: String!) { viewer { login } }",
184 variables: { body: "secret user text" },
185 });
186 yield* api.rest({
187 host: "github.com",
188 operation: "listWorkflowRuns",
189 path: "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fx",
190 });
191 const byName = (name: string) => spans.filter((span) => span.name === name);
192 const graphql = Object.fromEntries(byName("GitHubApi.graphql")[0]!.attributes);
193 expect(graphql).toMatchObject({
194 "github.operation": "getPullRequestDetail",
195 "github.graphql.query": "query($body: String!) { viewer { login } }",
196 });
197 expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({
198 "github.ratelimit.remaining": 4990,
199 "github.ratelimit.resource": "graphql",
200 });
201 expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/);
202 const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes);
203 expect(rest).toMatchObject({
204 "github.operation": "listWorkflowRuns",
205 "github.kind": "rest",
206 "url.path": "/repos/acme/web/actions/runs",
207 "http.response.status_code": 200,
208 "github.ratelimit.remaining": 4800,
209 });
210 // Nothing recorded carries the query string, the variables, or the token.
211 const everything = spans
212 .flatMap((span) => [...span.attributes].map(([key, value]) => `${key}=${String(value)}`))
213 .join("\n");
214 expect(everything).not.toContain("head_sha");
215 expect(everything).not.toContain("secret user text");
216 expect(everything).not.toContain("first");
217 expect(spans.some((span) => span.attributes.has("url.full"))).toBe(false);
218 }).pipe(Effect.provide(layer), Effect.withTracer(tracer));
219 },
220 );
221
222 it.effect("fails a GraphQL answer that carries errors, naming GitHub's reason", () => {
223 const { layer } = harness(() =>
224 json({ data: null, errors: [{ type: "FORBIDDEN", message: "Resource not accessible" }] }),
225 );
226 return Effect.gen(function* () {
227 const api = yield* GitHubApi.GitHubApi;
228 const error = yield* Effect.flip(
229 api.graphql({ host: "github.com", operation: "detail", query: "query { viewer { id } }" }),
230 );
231 expect(error._tag).toBe("GitHubApiResponseError");
232 expect(error.message).toContain("Resource not accessible");
233 }).pipe(Effect.provide(layer));
234 });
235
236 it.effect(
237 "reads a NOT_FOUND answer with another error as a failure, not a missing resource",
238 () => {
239 const { layer } = harness(() =>
240 json({
241 data: null,
242 errors: [
243 { type: "NOT_FOUND", message: "Could not resolve" },
244 { message: "Something went wrong" },
245 ],
246 }),
247 );
248 return Effect.gen(function* () {
249 const api = yield* GitHubApi.GitHubApi;
250 const error = yield* Effect.flip(
251 api.graphql({
252 host: "github.com",
253 operation: "detail",
254 query: "query { viewer { id } }",
255 }),
256 );
257 expect(error._tag).toBe("GitHubApiResponseError");
258 }).pipe(Effect.provide(layer));
259 },
260 );
261
262 it.effect("pauses the host after a GraphQL RATE_LIMITED answer until the reset", () => {
263 const reset = Math.floor(NOW / 1000) + 600;
264 const { layer, requests } = harness(() =>
265 json(
266 { errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }] },
267 { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } },
268 ),
269 );
270 return Effect.gen(function* () {
271 yield* TestClock.setTime(NOW);
272 const api = yield* GitHubApi.GitHubApi;
273 const first = yield* Effect.flip(
274 api.graphql({ host: "github.com", operation: "summary", query: "query { viewer { id } }" }),
275 );
276 expect(first).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 });
277 const second = yield* Effect.flip(
278 api.rest({ host: "github.com", operation: "stack", path: "repos/acme/web/stacks" }),
279 );
280 expect(second).toMatchObject({
281 _tag: "SourceControlRateLimitPausedError",
282 retryAt: reset * 1000,
283 });
284 expect(requests).toHaveLength(1);
285 }).pipe(Effect.provide(layer));
286 });
287
288 it.effect("keeps the last tenth of a quota for interactive requests until it resets", () => {
289 const reset = Math.floor(NOW / 1000) + 600;
290 const quota = (remaining: number) => ({
291 "x-ratelimit-resource": "core",
292 "x-ratelimit-limit": "5000",
293 "x-ratelimit-remaining": String(remaining),
294 "x-ratelimit-reset": String(reset),
295 });
296 let remaining = 499;
297 const { layer, requests } = harness((request) =>
298 request.url.endsWith("/graphql")
299 ? json({ data: { viewer: { id: "1" } } })
300 : json({ ok: true }, { headers: quota(remaining--) }),
301 );
302 return Effect.gen(function* () {
303 yield* TestClock.setTime(NOW);
304 const api = yield* GitHubApi.GitHubApi;
305 const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" };
306 // The first answer leaves 499 of 5000, under the last tenth; the next background read waits.
307 yield* api.rest(read);
308 const refused = yield* Effect.flip(api.rest(read));
309 expect(refused).toMatchObject({
310 _tag: "SourceControlRateLimitPausedError",
311 retryAt: reset * 1000,
312 });
313 // A user's own request may spend the reserve, and GraphQL has a quota of its own.
314 yield* api.rest(read).pipe(Effect.provideService(SourceControlRateLimit.Interactive, true));
315 yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" });
316 expect(requests).toHaveLength(3);
317 // The reset gives the background its quota back.
318 yield* TestClock.setTime(reset * 1000);
319 yield* api.rest(read);
320 expect(requests).toHaveLength(4);
321 }).pipe(Effect.provide(layer));
322 });
323
324 it.effect("carries GitHub's own reason for a refused REST request", () => {
325 const { layer } = harness(() =>
326 json(
327 {
328 message: "Validation Failed",
329 errors: [
330 {
331 resource: "PullRequest",
332 code: "custom",
333 message: "A pull request already exists for acme:feature.",
334 },
335 ],
336 },
337 { status: 422 },
338 ),
339 );
340 return Effect.gen(function* () {
341 const api = yield* GitHubApi.GitHubApi;
342 const error = yield* Effect.flip(
343 api.rest({
344 host: "github.com",
345 operation: "createPullRequest",
346 method: "POST",
347 path: "repos/acme/web/pulls",
348 }),
349 );
350 expect(error.message).toBe(
351 "GitHub returned an error: Validation Failed; A pull request already exists for acme:feature.",
352 );
353 }).pipe(Effect.provide(layer));
354 });
355
356 it.effect("lets an interactive request through a pause a background read recorded", () => {
357 const reset = Math.floor(NOW / 1000) + 600;
358 let call = 0;
359 const { layer, requests } = harness(() =>
360 ++call === 1
361 ? json(
362 { message: "API rate limit exceeded" },
363 {
364 status: 403,
365 headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) },
366 },
367 )
368 : json({ ok: true }),
369 );
370 return Effect.gen(function* () {
371 yield* TestClock.setTime(NOW);
372 const api = yield* GitHubApi.GitHubApi;
373 const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" };
374 yield* Effect.flip(api.rest(read));
375 expect((yield* Effect.flip(api.rest(read)))._tag).toBe("SourceControlRateLimitPausedError");
376 const merged = yield* api
377 .rest({
378 host: "github.com",
379 operation: "merge",
380 method: "PUT",
381 path: "repos/acme/web/pulls/7/merge",
382 })
383 .pipe(Effect.provideService(SourceControlRateLimit.Interactive, true));
384 expect(merged.status).toBe(200);
385 expect(requests).toHaveLength(2);
386 }).pipe(Effect.provide(layer));
387 });
388
389 it.effect("reads an untyped GraphQL quota error as a rate limit", () => {
390 const reset = Math.floor(NOW / 1000) + 900;
391 const { layer } = harness(() =>
392 json(
393 { errors: [{ message: "API rate limit already exceeded for user ID 1." }] },
394 { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } },
395 ),
396 );
397 return Effect.gen(function* () {
398 yield* TestClock.setTime(NOW);
399 const api = yield* GitHubApi.GitHubApi;
400 const error = yield* Effect.flip(
401 api.graphql({ host: "github.com", operation: "lookup", query: "query { viewer { id } }" }),
402 );
403 expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 });
404 }).pipe(Effect.provide(layer));
405 });
406
407 it.effect("maps REST 403 with an exhausted quota to a rate limit, and 304 to an answer", () => {
408 let call = 0;
409 const { layer } = harness(() =>
410 ++call === 1
411 ? new Response(null, { status: 304 })
412 : json(
413 { message: "API rate limit exceeded" },
414 {
415 status: 403,
416 headers: { "retry-after": "60" },
417 },
418 ),
419 );
420 return Effect.gen(function* () {
421 yield* TestClock.setTime(NOW);
422 const api = yield* GitHubApi.GitHubApi;
423 const notModified = yield* api.rest({
424 host: "github.com",
425 operation: "checks",
426 path: "repos/acme/web/pulls/7",
427 ifNoneMatch: '"abc"',
428 });
429 expect(notModified.status).toBe(304);
430 const error = yield* Effect.flip(
431 api.rest({ host: "github.com", operation: "checks", path: "repos/acme/web/pulls/7" }),
432 );
433 expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: NOW + 60_000 });
434 }).pipe(Effect.provide(layer));
435 });
436
437 it.effect("drops a refused token so the next request asks the source again", () => {
438 const { layer, requests, invalidations } = harness((request) =>
439 request.headers.authorization === "Bearer first"
440 ? new Response(null, { status: 401 })
441 : json({ ok: true }),
442 );
443 return Effect.gen(function* () {
444 const api = yield* GitHubApi.GitHubApi;
445 const refused = yield* Effect.flip(
446 api.rest({ host: "github.com", operation: "user", path: "user" }),
447 );
448 expect(refused._tag).toBe("GitHubApiAuthenticationError");
449 expect(invalidations()).toBe(1);
450 const answered = yield* api.rest({ host: "github.com", operation: "user", path: "user" });
451 expect(answered.status).toBe(200);
452 expect(requests.map((request) => request.headers.authorization)).toEqual([
453 "Bearer first",
454 "Bearer second",
455 ]);
456 }).pipe(Effect.provide(layer));
457 });
458
459 it.effect("refuses to send a pinned credential to another host", () => {
460 const { layer, requests } = harness(() => json({}));
461 return Effect.gen(function* () {
462 const api = yield* GitHubApi.GitHubApi;
463 const error = yield* Effect.flip(
464 api.rest({ host: "git.acme.internal", operation: "user", path: "user" }),
465 ).pipe(
466 Effect.provideService(GitHubApi.PinnedGitHubCredential, {
467 host: "github.com",
468 token: Redacted.make("pinned"),
469 credentialFingerprint: "github.com:pinned",
470 }),
471 );
472 expect(error._tag).toBe("GitHubApiAuthenticationError");
473 expect(requests).toHaveLength(0);
474 }).pipe(Effect.provide(layer));
475 });
476});
477
478describe("GitHubCredentials", () => {
479 const credentialsWith = (run: SourceControlHost.SourceControlHost["Service"]["process"]["run"]) =>
480 GitHubCredentials.layer.pipe(
481 Layer.provide(TestSourceControlHost.layer({ process: { run } })),
482 Layer.provide(NodeCrypto.layer),
483 );
484
485 it.effect("fails with GitHubCliMissingError when gh is not on PATH", () =>
486 Effect.gen(function* () {
487 const credentials = yield* GitHubCredentials.GitHubCredentials;
488 const error = yield* Effect.flip(credentials.get("git.acme.internal"));
489 expect(error._tag).toBe("GitHubCliMissingError");
490 }).pipe(
491 Effect.provide(
492 credentialsWith(() =>
493 Effect.fail(
494 new VcsProcessSpawnError({
495 operation: "GitHubCredentials.get",
496 command: "gh",
497 cwd: "/",
498 cause: PlatformError.systemError({
499 _tag: "NotFound",
500 module: "ChildProcess",
501 method: "spawn",
502 }),
503 }),
504 ),
505 ),
506 ),
507 ),
508 );
509
510 it.effect("does not cache a gh failure that is not about signing in", () => {
511 let calls = 0;
512 return Effect.gen(function* () {
513 const credentials = yield* GitHubCredentials.GitHubCredentials;
514 const first = yield* Effect.flip(credentials.get("github.com"));
515 expect(first._tag).toBe("GitHubCliFailedError");
516 expect((yield* credentials.get("github.com")).source).toBe("gh");
517 expect(calls).toBe(2);
518 }).pipe(
519 Effect.provide(
520 credentialsWith(() =>
521 ++calls === 1
522 ? Effect.fail(
523 new VcsProcessTimeoutError({
524 operation: "GitHubCredentials.get",
525 command: "gh",
526 cwd: "/",
527 timeoutMs: 10_000,
528 }),
529 )
530 : Effect.succeed({
531 exitCode: ChildProcessSpawner.ExitCode(0),
532 stdout: "token\n",
533 stderr: "",
534 stdoutTruncated: false,
535 stderrTruncated: false,
536 }),
537 ),
538 ),
539 );
540 });
541
542 it.effect("fails with GitHubNotSignedInError when gh prints no token", () =>
543 Effect.gen(function* () {
544 const credentials = yield* GitHubCredentials.GitHubCredentials;
545 const error = yield* Effect.flip(credentials.get("git.acme.internal"));
546 expect(error._tag).toBe("GitHubNotSignedInError");
547 }).pipe(
548 Effect.provide(
549 credentialsWith(() =>
550 Effect.succeed({
551 exitCode: ChildProcessSpawner.ExitCode(0),
552 stdout: "\n",
553 stderr: "",
554 stdoutTruncated: false,
555 stderrTruncated: false,
556 }),
557 ),
558 ),
559 ),
560 );
561});