| 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,6 +23,7 @@ import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; |
| 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | 23 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 24 | 24 | ||
| 25 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 25 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 26 | import * as GitHubApi from "./GitHubApi.ts"; | ||
| 26 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; | 27 | import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; |
| 27 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; | 28 | import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; |
| 28 | import { | 29 | import { |
| ... | @@ -35,11 +36,7 @@ import { | ... | @@ -35,11 +36,7 @@ import { |
| 35 | const DEFAULT_TIMEOUT_MS = 30_000; | 36 | const DEFAULT_TIMEOUT_MS = 30_000; |
| 36 | 37 | ||
| 37 | /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ | 38 | /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ |
| 38 | export const PinnedGitHubCredential = Context.Reference<{ | 39 | export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential; |
| 39 | readonly host: string; | ||
| 40 | readonly token: Redacted.Redacted<string>; | ||
| 41 | readonly credentialFingerprint: string; | ||
| 42 | } | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); | ||
| 43 | 40 | ||
| 44 | export const AllowGitHubReserve = Context.Reference<boolean>( | 41 | export const AllowGitHubReserve = Context.Reference<boolean>( |
| 45 | "t3/sourceControl/AllowGitHubReserve", | 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); | ||