| author | |
| committer | |
| log | 1564aaa5d29deb2520d1211f9f35dd6150b1512e |
| tree | ab3678972001689273a3aa7d0f1c5f96c31df67b |
| parent | 7bf6de174171ffcf0215bf55556f0e3ee7e6d78f |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Redacted from "effect/Redacted"; | |
| 6 | import * as PlatformError from "effect/PlatformError"; | |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 8 | import * as Tracer from "effect/Tracer"; | |
| 9 | import { ChildProcessSpawner } from "effect/process"; | |
| 10 | import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; | |
| 11 | import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http"; | |
| 12 | ||
| 13 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 14 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 15 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | |
| 16 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | |
| 17 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | |
| 18 | ||
| 19 | const NOW = Date.parse("2026-10-05T12:00:00.000Z"); | |
| 20 | ||
| 21 | function 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 | ||
| 57 | const json = (body: unknown, init?: ResponseInit) => | |
| 58 | new Response(JSON.stringify(body), { | |
| 59 | ...init, | |
| 60 | headers: { "content-type": "application/json", ...init?.headers }, | |
| 61 | }); | |
| 62 | ||
| 63 | describe("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 | ||
| 79 | describe("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 | ||
| 98 | describe("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 | ||
| 414 | describe("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 @@ |
| 1 | import * as Clock from "effect/Clock"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Data from "effect/Data"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Redacted from "effect/Redacted"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import * as Semaphore from "effect/Semaphore"; | |
| 11 | import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; | |
| 12 | ||
| 13 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | |
| 14 | import * as GitHubCredentials from "./GitHubCredentials.ts"; | |
| 15 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | |
| 16 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | |
| 17 | ||
| 18 | const DEFAULT_TIMEOUT = Duration.seconds(30); | |
| 19 | const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; | |
| 20 | /** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */ | |
| 21 | const CONCURRENCY = 8; | |
| 22 | const 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 | */ | |
| 29 | export 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 | */ | |
| 40 | export const AllowGitHubReserve = Context.Reference<boolean>( | |
| 41 | "t3/sourceControl/AllowGitHubReserve", | |
| 42 | { defaultValue: () => false }, | |
| 43 | ); | |
| 44 | ||
| 45 | export 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 | ||
| 54 | export 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 | ||
| 63 | export 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 | ||
| 76 | export 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 | */ | |
| 90 | export 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 | ||
| 106 | export type GitHubApiError = | |
| 107 | | GitHubCredentials.GitHubCredentialUnavailableError | |
| 108 | | GitHubApiRequestError | |
| 109 | | GitHubApiAuthenticationError | |
| 110 | | GitHubApiRateLimitError | |
| 111 | | GitHubApiNotFoundError | |
| 112 | | GitHubApiResponseError | |
| 113 | | SourceControlRateLimit.SourceControlRateLimitPausedError; | |
| 114 | ||
| 115 | export interface GitHubRestResponse { | |
| 116 | readonly status: number; | |
| 117 | readonly headers: Readonly<Record<string, string | undefined>>; | |
| 118 | readonly body: string; | |
| 119 | readonly truncated: boolean; | |
| 120 | } | |
| 121 | ||
| 122 | export 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 | ||
| 138 | export 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 | ||
| 146 | export 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 | ||
| 162 | function 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 | */ | |
| 170 | export 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. */ | |
| 185 | const TRACED_QUERY_MAX_CHARS = 4_000; | |
| 186 | ||
| 187 | /** A stable short id for a GraphQL document, so traces group by query without its text. */ | |
| 188 | function 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. */ | |
| 198 | function 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 | ||
| 216 | const 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. */ | |
| 227 | function 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 | ||
| 237 | const 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 }] }`. */ | |
| 251 | const 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 | ||
| 271 | function 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. */ | |
| 291 | type Answer = Data.TaggedEnum<{ | |
| 292 | Ok: {}; | |
| 293 | RateLimited: {}; | |
| 294 | Unauthorized: {}; | |
| 295 | NotFound: {}; | |
| 296 | Failed: { readonly messages: ReadonlyArray<string> | undefined }; | |
| 297 | }>; | |
| 298 | const 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 | */ | |
| 305 | function 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. */ | |
| 349 | export 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 | ||
| 593 | export const layer = Layer.effect(GitHubApi, make); |
apps/server/src/sourceControl/GitHubCli.ts+2-5| ... | ... | @@ -23,6 +23,7 @@ import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; |
| 23 | 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 24 | 24 | |
| 25 | 25 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 26 | import * as GitHubApi from "./GitHubApi.ts"; | |
| 26 | 27 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; |
| 27 | 28 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; |
| 28 | 29 | import { |
| ... | ... | @@ -35,11 +36,7 @@ import { |
| 35 | 36 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 36 | 37 | |
| 37 | 38 | /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ |
| 38 | export 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 }); | |
| 39 | export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential; | |
| 43 | 40 | |
| 44 | 41 | export const AllowGitHubReserve = Context.Reference<boolean>( |
| 45 | 42 | "t3/sourceControl/AllowGitHubReserve", |
apps/server/src/sourceControl/GitHubCredentials.ts created+189| ... | ... | @@ -0,0 +1,189 @@ |
| 1 | import * as Cache from "effect/Cache"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Crypto from "effect/Crypto"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Hex from "effect/encoding/Hex"; | |
| 7 | import * as Exit from "effect/Exit"; | |
| 8 | import * as Layer from "effect/Layer"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import * as PlatformError from "effect/PlatformError"; | |
| 11 | import * as Redacted from "effect/Redacted"; | |
| 12 | import * as Schema from "effect/Schema"; | |
| 13 | ||
| 14 | import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; | |
| 15 | ||
| 16 | import * 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. */ | |
| 19 | const TOKEN_TTL = Duration.minutes(5); | |
| 20 | /** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */ | |
| 21 | const MISSING_TTL = Duration.seconds(10); | |
| 22 | ||
| 23 | export const GitHubCredentialSource = Schema.Literals(["env", "gh"]); | |
| 24 | export type GitHubCredentialSource = typeof GitHubCredentialSource.Type; | |
| 25 | ||
| 26 | export 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. */ | |
| 35 | export 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. */ | |
| 45 | export 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. */ | |
| 55 | export 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. */ | |
| 65 | export 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 | */ | |
| 74 | export 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 | ||
| 85 | function 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. */ | |
| 90 | function 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 | */ | |
| 99 | export 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. */ | |
| 116 | export 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 | ||
| 189 | export const layer = Layer.effect(GitHubCredentials, make); |