From ec9f830f11b3fe85ed5925757f733e966120333d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:33:49 -0700 Subject: [PATCH 1/9] feat(server): GitHub API transport that uses gh only for the token Adds GitHubCredentials (env token, else `gh auth token`, cached and dropped on a 401) and GitHubApi (REST and GraphQL over HttpClient with the per-host rate-limit pause, GraphQL budget, ETag revalidation and GHE/GHE.com host mapping). Nothing calls it yet. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/sourceControl/GitHubApi.test.ts | 218 +++++++++ apps/server/src/sourceControl/GitHubApi.ts | 442 ++++++++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 7 +- .../src/sourceControl/GitHubCredentials.ts | 151 ++++++ 4 files changed, 813 insertions(+), 5 deletions(-) create mode 100644 apps/server/src/sourceControl/GitHubApi.test.ts create mode 100644 apps/server/src/sourceControl/GitHubApi.ts create mode 100644 apps/server/src/sourceControl/GitHubCredentials.ts diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts new file mode 100644 index 000000000000..6d343018f7f3 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http"; + +import * as GitHubApi from "./GitHubApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +const NOW = Date.parse("2026-10-05T12:00:00.000Z"); + +function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Response) { + const requests: Array = []; + let tokens = ["first", "second"]; + let invalidations = 0; + const credentials = Layer.succeed( + GitHubCredentials.GitHubCredentials, + GitHubCredentials.GitHubCredentials.of({ + get: (host) => + Effect.sync(() => ({ + host, + token: Redacted.make(tokens[0]!), + source: "gh" as const, + fingerprint: `${host}:${tokens[0]}`, + })), + invalidate: () => + Effect.sync(() => { + invalidations++; + tokens = tokens.slice(1); + }), + }), + ); + const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request); + return Effect.succeed(HttpClientResponse.fromWeb(request, respond(request))); + }), + ); + const layer = GitHubApi.layer.pipe( + Layer.provide(Layer.mergeAll(credentials, http)), + Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(SourceControlRateLimit.layer), + ); + return { layer, requests, invalidations: () => invalidations }; +} + +const json = (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "content-type": "application/json", ...init?.headers }, + }); + +describe("gitHubApiUrls", () => { + it("maps github.com, GHE.com and GitHub Enterprise Server", () => { + expect(GitHubApi.gitHubApiUrls("GitHub.com")).toEqual({ + rest: "https://api.github.com", + graphql: "https://api.github.com/graphql", + }); + expect(GitHubApi.gitHubApiUrls("acme.ghe.com").graphql).toBe( + "https://api.acme.ghe.com/graphql", + ); + expect(GitHubApi.gitHubApiUrls("git.acme.internal")).toEqual({ + rest: "https://git.acme.internal/api/v3", + graphql: "https://git.acme.internal/api/graphql", + }); + }); +}); + +describe("environmentToken", () => { + it("follows gh's precedence per kind of host", () => { + const env = { GH_TOKEN: "gh", GITHUB_TOKEN: "github", GH_ENTERPRISE_TOKEN: "ghe" }; + expect(GitHubCredentials.environmentToken("github.com", env)).toBe("gh"); + expect(GitHubCredentials.environmentToken("acme.ghe.com", { GITHUB_TOKEN: "github" })).toBe( + "github", + ); + expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBe("ghe"); + expect(GitHubCredentials.environmentToken("git.acme.internal", { GH_TOKEN: "gh" })).toBeNull(); + }); +}); + +describe("GitHubApi", () => { + it.effect("sends GraphQL with the token and records the reported budget", () => { + const { layer, requests } = harness(() => + json({ + data: { + viewer: { login: "julius" }, + rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-10-05T13:00:00Z" }, + }, + }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const body = yield* api.graphql({ + host: "github.com", + operation: "viewer", + query: "query { viewer { login } }", + }); + expect(body).toContain('"login":"julius"'); + expect(requests[0]!.url).toBe("https://api.github.com/graphql"); + expect(requests[0]!.headers.authorization).toBe("Bearer first"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails a GraphQL answer that carries errors, naming GitHub's reason", () => { + const { layer } = harness(() => + json({ data: null, errors: [{ type: "FORBIDDEN", message: "Resource not accessible" }] }), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.graphql({ host: "github.com", operation: "detail", query: "query { viewer { id } }" }), + ); + expect(error._tag).toBe("GitHubApiResponseError"); + expect(error.message).toContain("Resource not accessible"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("pauses the host after a GraphQL RATE_LIMITED answer until the reset", () => { + const reset = Math.floor(NOW / 1000) + 600; + const { layer, requests } = harness(() => + json( + { errors: [{ type: "RATE_LIMITED", message: "API rate limit exceeded" }] }, + { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, + ), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const first = yield* Effect.flip( + api.graphql({ host: "github.com", operation: "summary", query: "query { viewer { id } }" }), + ); + expect(first).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); + const second = yield* Effect.flip( + api.rest({ host: "github.com", operation: "stack", path: "repos/acme/web/stacks" }), + ); + expect(second).toMatchObject({ + _tag: "SourceControlRateLimitPausedError", + retryAt: reset * 1000, + }); + expect(requests).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps REST 403 with an exhausted quota to a rate limit, and 304 to an answer", () => { + let call = 0; + const { layer } = harness(() => + ++call === 1 + ? new Response(null, { status: 304 }) + : json( + { message: "API rate limit exceeded" }, + { + status: 403, + headers: { "retry-after": "60" }, + }, + ), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const notModified = yield* api.rest({ + host: "github.com", + operation: "checks", + path: "repos/acme/web/pulls/7", + ifNoneMatch: '"abc"', + }); + expect(notModified.status).toBe(304); + const error = yield* Effect.flip( + api.rest({ host: "github.com", operation: "checks", path: "repos/acme/web/pulls/7" }), + ); + expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: NOW + 60_000 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("drops a refused token so the next request asks the source again", () => { + const { layer, requests, invalidations } = harness((request) => + request.headers.authorization === "Bearer first" + ? new Response(null, { status: 401 }) + : json({ ok: true }), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const refused = yield* Effect.flip( + api.rest({ host: "github.com", operation: "user", path: "user" }), + ); + expect(refused._tag).toBe("GitHubApiAuthenticationError"); + expect(invalidations()).toBe(1); + const answered = yield* api.rest({ host: "github.com", operation: "user", path: "user" }); + expect(answered.status).toBe(200); + expect(requests.map((request) => request.headers.authorization)).toEqual([ + "Bearer first", + "Bearer second", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("refuses to send a pinned credential to another host", () => { + const { layer, requests } = harness(() => json({})); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.rest({ host: "git.acme.internal", operation: "user", path: "user" }), + ).pipe( + Effect.provideService(GitHubApi.PinnedGitHubCredential, { + host: "github.com", + token: Redacted.make("pinned"), + credentialFingerprint: "github.com:pinned", + }), + ); + expect(error._tag).toBe("GitHubApiAuthenticationError"); + expect(requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts new file mode 100644 index 000000000000..f9a3e791a3cb --- /dev/null +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -0,0 +1,442 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/http"; + +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; +import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +const DEFAULT_TIMEOUT = Duration.seconds(30); +const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +/** Polite to GitHub's secondary limits, which punish bursts of concurrent requests. */ +const CONCURRENCY = 8; +const API_VERSION = "2022-11-28"; + +/** + * A credential already verified for one host. Every request made under it must target that + * host, so a page cannot read one account's data with another's token mid-flight. Server-local: + * never put its value in RPC payloads or cache keys. + */ +export const PinnedGitHubCredential = Context.Reference<{ + readonly host: string; + readonly token: Redacted.Redacted; + readonly credentialFingerprint: string; +} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); + +export class GitHubApiRequestError extends Schema.TaggedError()( + "GitHubApiRequestError", + { host: Schema.String, operation: Schema.String, cause: Schema.Defect() }, +) { + get detail(): string { + return `Could not reach GitHub at ${this.host}.`; + } + + override get message(): string { + return `GitHub API failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitHubApiAuthenticationError extends Schema.TaggedError()( + "GitHubApiAuthenticationError", + { host: Schema.String, operation: Schema.String }, +) { + get detail(): string { + return `GitHub refused the credential for ${this.host}. Run \`gh auth login --hostname ${this.host}\` and retry.`; + } + + override get message(): string { + return `GitHub API failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitHubApiRateLimitError extends Schema.TaggedError()( + "GitHubApiRateLimitError", + { + host: Schema.String, + operation: Schema.String, + retryAt: Schema.optionalKey(Schema.Finite), + }, +) { + get detail(): string { + return "GitHub API rate limit exceeded."; + } + + override get message(): string { + return `GitHub API failed in ${this.operation}: ${this.detail}`; + } +} + +export class GitHubApiNotFoundError extends Schema.TaggedError()( + "GitHubApiNotFoundError", + { host: Schema.String, operation: Schema.String }, +) { + get detail(): string { + return "GitHub could not find the requested resource, or the credential cannot see it."; + } + + override get message(): string { + return `GitHub API failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * GitHub answered with a failure that is none of the above. `graphqlErrors` carries GitHub's own + * error messages for a GraphQL answer: they name the field and the reason, never a token. + */ +export class GitHubApiResponseError extends Schema.TaggedError()( + "GitHubApiResponseError", + { + host: Schema.String, + operation: Schema.String, + status: Schema.Int, + graphqlErrors: Schema.optionalKey(Schema.Array(Schema.String)), + }, +) { + get detail(): string { + return this.graphqlErrors !== undefined && this.graphqlErrors.length > 0 + ? `GitHub returned an error: ${this.graphqlErrors.join("; ")}` + : `GitHub returned HTTP ${this.status}.`; + } + + override get message(): string { + return `GitHub API failed in ${this.operation}: ${this.detail}`; + } +} + +export type GitHubApiError = + | GitHubCredentials.GitHubCredentialUnavailableError + | GitHubApiRequestError + | GitHubApiAuthenticationError + | GitHubApiRateLimitError + | GitHubApiNotFoundError + | GitHubApiResponseError + | SourceControlRateLimit.SourceControlRateLimitPausedError; + +export interface GitHubRestResponse { + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + readonly truncated: boolean; +} + +export interface GitHubRestInput { + readonly host: string; + readonly operation: string; + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Relative to the API root, e.g. `repos/acme/web/pulls/7`; query string included. */ + readonly path: string; + readonly body?: unknown; + /** Defaults to `application/vnd.github+json`. */ + readonly accept?: string; + /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ + readonly ifNoneMatch?: string; + readonly maxResponseBytes?: number; + /** Interactive reads may run through a pause, the way they may spend the GraphQL reserve. */ + readonly allowReserve?: boolean; +} + +export interface GitHubGraphQlInput { + readonly host: string; + readonly operation: string; + readonly query: string; + readonly variables?: Readonly>; + readonly allowReserve?: boolean; +} + +export class GitHubApi extends Context.Service< + GitHubApi, + { + /** The raw JSON body of a successful GraphQL answer, with `rateLimit` recorded. */ + readonly graphql: (input: GitHubGraphQlInput) => Effect.Effect; + readonly rest: (input: GitHubRestInput) => Effect.Effect; + /** The credential a request to `host` would carry right now. */ + readonly credential: ( + host: string, + ) => Effect.Effect< + { readonly token: Redacted.Redacted; readonly fingerprint: string }, + GitHubApiError + >; + } +>()("t3/sourceControl/GitHubApi") {} + +function normalizeHost(host: string): string { + return host.trim().toLowerCase(); +} + +/** + * Where the API for a host lives. github.com and GHE.com data residency serve it from an `api.` + * subdomain; GitHub Enterprise Server serves it under `/api` on the instance itself. + */ +export function gitHubApiUrls(host: string): { readonly rest: string; readonly graphql: string } { + const normalized = normalizeHost(host); + if (normalized === "github.com") { + return { rest: "https://api.github.com", graphql: "https://api.github.com/graphql" }; + } + if (normalized.endsWith(".ghe.com")) { + return { + rest: `https://api.${normalized}`, + graphql: `https://api.${normalized}/graphql`, + }; + } + return { rest: `https://${normalized}/api/v3`, graphql: `https://${normalized}/api/graphql` }; +} + +/** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ +function retryAtFrom( + headers: Readonly>, + now: number, +): number | undefined { + const fromRetryAfter = SourceControlRateLimit.retryAtFromHeader(headers["retry-after"], now); + if (fromRetryAfter !== undefined) return fromRetryAfter; + const reset = Number(headers["x-ratelimit-reset"]) * 1_000; + return Number.isFinite(reset) && reset > now ? reset : undefined; +} + +function isRateLimited( + status: number, + headers: Readonly>, + body: string, +) { + return ( + status === 429 || + (status === 403 && + (headers["x-ratelimit-remaining"] === "0" || + headers["retry-after"] !== undefined || + /rate limit/i.test(body))) + ); +} + +interface GraphQlErrors { + readonly messages: ReadonlyArray; + readonly types: ReadonlyArray; +} + +const decodeGraphQlErrors = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + errors: Schema.NonEmptyArray( + Schema.Struct({ + type: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), + ), + }), + ), +); + +function graphqlErrors(body: string): GraphQlErrors | null { + return Option.match(decodeGraphQlErrors(body), { + onNone: () => null, + onSome: ({ errors }) => ({ + messages: errors.flatMap((error) => (error.message === undefined ? [] : [error.message])), + types: errors.flatMap((error) => (error.type === undefined ? [] : [error.type])), + }), + }); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const credentials = yield* GitHubCredentials.GitHubCredentials; + const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const limits = yield* SourceControlRateLimit.SourceControlRateLimit; + const gate = yield* Semaphore.make(CONCURRENCY); + + const credential: GitHubApi["Service"]["credential"] = Effect.fn("GitHubApi.credential")( + function* (host) { + const normalized = normalizeHost(host); + const pinned = yield* PinnedGitHubCredential; + if (pinned !== null) { + // A pinned page only ever talks to the host it verified. + if (pinned.host !== normalized) { + return yield* new GitHubApiAuthenticationError({ + host: normalized, + operation: "credential", + }); + } + return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; + } + const held = yield* credentials.get(normalized); + return { token: held.token, fingerprint: held.fingerprint }; + }, + ); + + /** + * Sends one request under the host's rate-limit pause and fails a refusal with the error that + * says why. `onSuccess` sees the answer only once it is known to be one. + */ + const send = Effect.fn("GitHubApi.send")(function* (input: { + readonly host: string; + readonly operation: string; + readonly request: HttpClientRequest.HttpClientRequest; + readonly maxResponseBytes: number; + readonly allowReserve: boolean; + readonly acceptNotModified: boolean; + /** GitHub answers a throttled GraphQL document with HTTP 200 and a `RATE_LIMITED` error. */ + readonly graphql?: boolean; + }) { + const host = normalizeHost(input.host); + const { token, fingerprint } = yield* credential(host); + const scope = yield* SourceControlRateLimit.CredentialScope; + const key = { provider: "github" as const, host }; + const run = Effect.gen(function* () { + const lease = yield* limits.check( + key, + input.allowReserve ? { allowPaused: true } : undefined, + ); + const response: HttpClientResponse.HttpClientResponse = yield* httpClient + .execute( + input.request.pipe( + HttpClientRequest.bearerToken(Redacted.value(token)), + HttpClientRequest.setHeaders({ + "x-github-api-version": API_VERSION, + "user-agent": "t3code", + }), + ), + ) + .pipe( + Effect.timeout(DEFAULT_TIMEOUT), + Effect.mapError( + (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), + ), + ); + const collected = yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: input.maxResponseBytes, + }).pipe( + // 204, 304 and many refusals carry no body at all, which is an empty answer, not a failure. + Effect.catchIf( + (error) => error.reason._tag === "EmptyBodyError", + () => Effect.succeed({ text: "", truncated: false }), + ), + Effect.mapError( + (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), + ), + ); + const headers = response.headers; + const status = response.status; + const throttled = + input.graphql === true && + status === 200 && + (graphqlErrors(collected.text)?.types.includes("RATE_LIMITED") ?? false); + if ( + !throttled && + ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) + ) { + yield* limits.recordSuccess({ ...key, lease }); + return { status, headers, body: collected.text, truncated: collected.truncated }; + } + if (throttled || isRateLimited(status, headers, collected.text)) { + const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); + yield* limits.recordRateLimit({ ...key, lease, retryAt }); + return yield* new GitHubApiRateLimitError({ + host, + operation: input.operation, + ...(retryAt === undefined ? {} : { retryAt }), + }); + } + if (status === 401) { + // The source may hold a newer token than the one that was refused. + yield* credentials.invalidate(host); + return yield* new GitHubApiAuthenticationError({ host, operation: input.operation }); + } + if (status === 404) { + return yield* new GitHubApiNotFoundError({ host, operation: input.operation }); + } + const errors = graphqlErrors(collected.text); + return yield* new GitHubApiResponseError({ + host, + operation: input.operation, + status, + ...(errors === null ? {} : { graphqlErrors: errors.messages }), + }); + }); + // The rate-limit scope follows the credential, so a pause on one account never blocks another. + return yield* gate + .withPermit(run) + .pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope || fingerprint)); + }); + + const rest: GitHubApi["Service"]["rest"] = (input) => { + const url = `${gitHubApiUrls(input.host).rest}/${input.path.replace(/^\/+/, "")}`; + const base = HttpClientRequest.make(input.method ?? "GET")(url).pipe( + HttpClientRequest.setHeader("accept", input.accept ?? "application/vnd.github+json"), + ); + const withEtag = + input.ifNoneMatch === undefined + ? base + : base.pipe(HttpClientRequest.setHeader("if-none-match", input.ifNoneMatch)); + const request = + input.body === undefined + ? withEtag + : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)); + return send({ + host: input.host, + operation: input.operation, + request, + maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + allowReserve: input.allowReserve === true, + acceptNotModified: input.ifNoneMatch !== undefined, + }); + }; + + const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")( + function* (input) { + const host = normalizeHost(input.host); + const { fingerprint } = yield* credential(host); + const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; + return yield* Effect.gen(function* () { + const query = yield* budget.query( + host, + input.query, + input.allowReserve === true ? { allowReserve: true } : undefined, + ); + const response = yield* send({ + host, + operation: input.operation, + request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }), + ), + maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES, + allowReserve: input.allowReserve === true, + acceptNotModified: false, + graphql: true, + }); + yield* budget.observe(host, response.body); + // GitHub answers a failed GraphQL document with HTTP 200 and an `errors` list. `gh api + // graphql` fails those, and callers rely on that to fall back to narrower reads. + const errors = graphqlErrors(response.body); + if (errors === null) return response.body; + if (errors.types.length > 0 && errors.types.every((type) => type === "NOT_FOUND")) { + return yield* new GitHubApiNotFoundError({ host, operation: input.operation }); + } + return yield* new GitHubApiResponseError({ + host, + operation: input.operation, + status: response.status, + graphqlErrors: errors.messages, + }); + }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); + }, + ); + + return GitHubApi.of({ graphql, rest, credential }); +}); + +export const layer = Layer.effect(GitHubApi, make); + +const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); + +/** The JSON body of a REST answer, or none when GitHub sent nothing (204, 304). */ +export function restJson(response: GitHubRestResponse): Option.Option { + return response.body.trim() === "" ? Option.none() : decodeJson(response.body); +} diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 3a13ac69e9ef..0097794c7b9d 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -23,6 +23,7 @@ import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import { @@ -35,11 +36,7 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; /** Server-local credential scope; never put its value in RPC payloads or cache keys. */ -export const PinnedGitHubCredential = Context.Reference<{ - readonly host: string; - readonly token: Redacted.Redacted; - readonly credentialFingerprint: string; -} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); +export const PinnedGitHubCredential = GitHubApi.PinnedGitHubCredential; export const AllowGitHubReserve = Context.Reference( "t3/sourceControl/AllowGitHubReserve", diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts new file mode 100644 index 000000000000..60872ff92a2b --- /dev/null +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -0,0 +1,151 @@ +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as NodeCrypto from "node:crypto"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */ +const TOKEN_TTL = Duration.minutes(5); +/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */ +const MISSING_TTL = Duration.seconds(10); + +export const GitHubCredentialSource = Schema.Literals(["env", "gh"]); +export type GitHubCredentialSource = typeof GitHubCredentialSource.Type; + +export interface GitHubCredential { + readonly host: string; + readonly token: Redacted.Redacted; + readonly source: GitHubCredentialSource; + /** A digest of host and token: safe for cache keys and rate-limit scopes, never the token. */ + readonly fingerprint: string; +} + +/** + * There is no token for the host. `cli-missing` means nothing in the environment and no `gh` + * to ask; `unauthenticated` means `gh` is there but holds no login for the host. + */ +export class GitHubCredentialUnavailableError extends Schema.TaggedError()( + "GitHubCredentialUnavailableError", + { + host: Schema.String, + reason: Schema.Literals(["cli-missing", "unauthenticated"]), + }, +) { + get detail(): string { + return this.reason === "cli-missing" + ? `No GitHub credential for ${this.host}: set GH_TOKEN or install the GitHub CLI and run \`gh auth login\`.` + : `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.`; + } + + override get message(): string { + return this.detail; + } +} + +/** + * Where GitHub tokens come from. Callers ask per host and never see how the token was found, + * so another source (an in-app OAuth login) slots in here without touching any of them. + */ +export class GitHubCredentials extends Context.Service< + GitHubCredentials, + { + readonly get: ( + host: string, + ) => Effect.Effect; + /** Drops the held token after GitHub refused it, so the next read asks its source again. */ + readonly invalidate: (host: string) => Effect.Effect; + } +>()("t3/sourceControl/GitHubCredentials") {} + +function normalizeHost(host: string): string { + return host.trim().toLowerCase(); +} + +/** Hosts gh treats as GitHub.com-like for `GH_TOKEN`: github.com and GHE.com data residency. */ +function isGitHubDotCom(host: string): boolean { + return host === "github.com" || host.endsWith(".ghe.com"); +} + +/** The environment variables gh itself reads for a host, in its precedence order. */ +export function environmentToken( + host: string, + env: Readonly>, +): string | null { + const names = isGitHubDotCom(host) + ? ["GH_TOKEN", "GITHUB_TOKEN"] + : ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]; + for (const name of names) { + const value = env[name]?.trim(); + if (value) return value; + } + return null; +} + +export function credentialFingerprint(host: string, token: string): string { + return `${host}:${NodeCrypto.createHash("sha256").update(token).digest("hex")}`; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + + const fromGh = (host: string) => + process + .run({ + operation: "GitHubCredentials.get", + command: "gh", + args: ["auth", "token", "--hostname", host], + cwd: globalThis.process.cwd(), + // Never let gh print the token into a debug log. + env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, + timeoutMs: 10_000, + }) + .pipe( + Effect.map((output) => output.stdout.trim()), + Effect.catch((error) => + Effect.succeed( + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? ("cli-missing" as const) + : "", + ), + ), + ); + + const lookup = Effect.fn("GitHubCredentials.lookup")(function* (host: string) { + const fromEnv = environmentToken(host, globalThis.process.env); + const token = fromEnv ?? (yield* fromGh(host)); + if (token === "cli-missing" || token === "") { + return yield* new GitHubCredentialUnavailableError({ + host, + reason: token === "cli-missing" ? "cli-missing" : "unauthenticated", + }); + } + return { + host, + token: Redacted.make(token), + source: fromEnv !== null ? "env" : "gh", + fingerprint: credentialFingerprint(host, token), + } satisfies GitHubCredential; + }); + + const cache = yield* Cache.makeWith(lookup, { + capacity: 32, + timeToLive: (exit) => (Exit.isSuccess(exit) ? TOKEN_TTL : MISSING_TTL), + }); + + return GitHubCredentials.of({ + get: (host) => Cache.get(cache, normalizeHost(host)), + invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)), + }); +}); + +export const layer = Layer.effect(GitHubCredentials, make); From 01dc74e821b2420322e6a2665782e34222952b54 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:09:40 -0700 Subject: [PATCH 2/9] refactor(server): GitHub credential and API failures are tagged errors, not reasons switched on GitHubCredentialUnavailableError's reason becomes GitHubCliMissingError and GitHubNotSignedInError, and gh's output is no longer folded into a sentinel string. GitHubApi classifies each answer once into a tagged enum and matches on it, instead of re-reading the status and GraphQL error types in each branch. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/sourceControl/GitHubApi.test.ts | 54 ++++++ apps/server/src/sourceControl/GitHubApi.ts | 172 ++++++++++-------- .../src/sourceControl/GitHubCredentials.ts | 59 +++--- 3 files changed, 182 insertions(+), 103 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index 6d343018f7f3..5062314e279a 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -2,13 +2,17 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; +import * as PlatformError from "effect/PlatformError"; import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/process"; +import { VcsProcessSpawnError } from "@t3tools/contracts"; import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http"; import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; const NOW = Date.parse("2026-10-05T12:00:00.000Z"); @@ -216,3 +220,53 @@ describe("GitHubApi", () => { }).pipe(Effect.provide(layer)); }); }); + +describe("GitHubCredentials", () => { + const credentialsWith = (run: VcsProcess.VcsProcess["Service"]["run"]) => + GitHubCredentials.layer.pipe(Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run }))); + + it.effect("fails with GitHubCliMissingError when gh is not on PATH", () => + Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const error = yield* Effect.flip(credentials.get("git.acme.internal")); + expect(error._tag).toBe("GitHubCliMissingError"); + }).pipe( + Effect.provide( + credentialsWith(() => + Effect.fail( + new VcsProcessSpawnError({ + operation: "GitHubCredentials.get", + command: "gh", + cwd: "/", + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ), + ), + ), + ), + ); + + it.effect("fails with GitHubNotSignedInError when gh prints no token", () => + Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const error = yield* Effect.flip(credentials.get("git.acme.internal")); + expect(error._tag).toBe("GitHubNotSignedInError"); + }).pipe( + Effect.provide( + credentialsWith(() => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index f9a3e791a3cb..5db9be9ce0a5 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -1,5 +1,6 @@ import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -200,25 +201,6 @@ function retryAtFrom( return Number.isFinite(reset) && reset > now ? reset : undefined; } -function isRateLimited( - status: number, - headers: Readonly>, - body: string, -) { - return ( - status === 429 || - (status === 403 && - (headers["x-ratelimit-remaining"] === "0" || - headers["retry-after"] !== undefined || - /rate limit/i.test(body))) - ); -} - -interface GraphQlErrors { - readonly messages: ReadonlyArray; - readonly types: ReadonlyArray; -} - const decodeGraphQlErrors = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ @@ -232,14 +214,53 @@ const decodeGraphQlErrors = Schema.decodeUnknownOption( ), ); -function graphqlErrors(body: string): GraphQlErrors | null { - return Option.match(decodeGraphQlErrors(body), { - onNone: () => null, - onSome: ({ errors }) => ({ - messages: errors.flatMap((error) => (error.message === undefined ? [] : [error.message])), - types: errors.flatMap((error) => (error.type === undefined ? [] : [error.type])), - }), - }); +/** What one GitHub answer means, decided once from its status, headers and body. */ +type Answer = Data.TaggedEnum<{ + Ok: {}; + RateLimited: {}; + Unauthorized: {}; + NotFound: {}; + Failed: { readonly messages: ReadonlyArray | undefined }; +}>; +const Answer = Data.taggedEnum(); + +/** + * GitHub reports a failed GraphQL document with HTTP 200 and an `errors` list, so a GraphQL body + * is read for its error types as well as its status. `gh api graphql` failed those too, and + * callers rely on that to fall back to narrower reads. + */ +function classify(input: { + readonly status: number; + readonly headers: Readonly>; + readonly body: string; + readonly graphql: boolean; + readonly acceptNotModified: boolean; +}): Answer { + const { status, headers, body } = input; + const errors = input.graphql + ? Option.getOrUndefined(decodeGraphQlErrors(body))?.errors + : undefined; + const types = errors?.flatMap((error) => (error.type === undefined ? [] : [error.type])) ?? []; + const messages = errors?.flatMap((error) => (error.message === undefined ? [] : [error.message])); + if ( + status === 429 || + types.includes("RATE_LIMITED") || + (status === 403 && + (headers["x-ratelimit-remaining"] === "0" || + headers["retry-after"] !== undefined || + /rate limit/i.test(body))) + ) { + return Answer.RateLimited(); + } + if (status === 401) return Answer.Unauthorized(); + if (status === 404 || (types.length > 0 && types.every((type) => type === "NOT_FOUND"))) { + return Answer.NotFound(); + } + if (errors !== undefined) return Answer.Failed({ messages }); + if ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) { + return Answer.Ok(); + } + return Answer.Failed({ messages: undefined }); } /** @public Service construction is part of the canonical Effect module API. */ @@ -280,7 +301,7 @@ export const make = Effect.gen(function* () { readonly maxResponseBytes: number; readonly allowReserve: boolean; readonly acceptNotModified: boolean; - /** GitHub answers a throttled GraphQL document with HTTP 200 and a `RATE_LIMITED` error. */ + /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ readonly graphql?: boolean; }) { const host = normalizeHost(input.host); @@ -323,41 +344,52 @@ export const make = Effect.gen(function* () { ); const headers = response.headers; const status = response.status; - const throttled = - input.graphql === true && - status === 200 && - (graphqlErrors(collected.text)?.types.includes("RATE_LIMITED") ?? false); - if ( - !throttled && - ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) - ) { - yield* limits.recordSuccess({ ...key, lease }); - return { status, headers, body: collected.text, truncated: collected.truncated }; - } - if (throttled || isRateLimited(status, headers, collected.text)) { - const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); - yield* limits.recordRateLimit({ ...key, lease, retryAt }); - return yield* new GitHubApiRateLimitError({ - host, - operation: input.operation, - ...(retryAt === undefined ? {} : { retryAt }), - }); - } - if (status === 401) { - // The source may hold a newer token than the one that was refused. - yield* credentials.invalidate(host); - return yield* new GitHubApiAuthenticationError({ host, operation: input.operation }); - } - if (status === 404) { - return yield* new GitHubApiNotFoundError({ host, operation: input.operation }); - } - const errors = graphqlErrors(collected.text); - return yield* new GitHubApiResponseError({ - host, - operation: input.operation, - status, - ...(errors === null ? {} : { graphqlErrors: errors.messages }), - }); + const context = { host, operation: input.operation }; + return yield* Answer.$match( + classify({ + status, + headers, + body: collected.text, + graphql: input.graphql === true, + acceptNotModified: input.acceptNotModified, + }), + { + Ok: () => + limits + .recordSuccess({ ...key, lease }) + .pipe( + Effect.as({ + status, + headers, + body: collected.text, + truncated: collected.truncated, + }), + ), + RateLimited: () => + Effect.gen(function* () { + const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); + yield* limits.recordRateLimit({ ...key, lease, retryAt }); + return yield* new GitHubApiRateLimitError({ + ...context, + ...(retryAt === undefined ? {} : { retryAt }), + }); + }), + // The source may hold a newer token than the one that was refused. + Unauthorized: () => + credentials + .invalidate(host) + .pipe(Effect.andThen(Effect.fail(new GitHubApiAuthenticationError(context)))), + NotFound: () => Effect.fail(new GitHubApiNotFoundError(context)), + Failed: ({ messages }) => + Effect.fail( + new GitHubApiResponseError({ + ...context, + status, + ...(messages === undefined ? {} : { graphqlErrors: messages }), + }), + ), + }, + ); }); // The rate-limit scope follows the credential, so a pause on one account never blocks another. return yield* gate @@ -412,19 +444,7 @@ export const make = Effect.gen(function* () { graphql: true, }); yield* budget.observe(host, response.body); - // GitHub answers a failed GraphQL document with HTTP 200 and an `errors` list. `gh api - // graphql` fails those, and callers rely on that to fall back to narrower reads. - const errors = graphqlErrors(response.body); - if (errors === null) return response.body; - if (errors.types.length > 0 && errors.types.every((type) => type === "NOT_FOUND")) { - return yield* new GitHubApiNotFoundError({ host, operation: input.operation }); - } - return yield* new GitHubApiResponseError({ - host, - operation: input.operation, - status: response.status, - graphqlErrors: errors.messages, - }); + return response.body; }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); }, ); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index 60872ff92a2b..8696f482ca8c 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -27,21 +27,27 @@ export interface GitHubCredential { readonly fingerprint: string; } -/** - * There is no token for the host. `cli-missing` means nothing in the environment and no `gh` - * to ask; `unauthenticated` means `gh` is there but holds no login for the host. - */ -export class GitHubCredentialUnavailableError extends Schema.TaggedError()( - "GitHubCredentialUnavailableError", - { - host: Schema.String, - reason: Schema.Literals(["cli-missing", "unauthenticated"]), - }, +/** Nothing in the environment, and no `gh` on PATH to ask. */ +export class GitHubCliMissingError extends Schema.TaggedError()( + "GitHubCliMissingError", + { host: Schema.String }, +) { + get detail(): string { + return `No GitHub credential for ${this.host}: set GH_TOKEN, or install the GitHub CLI and run \`gh auth login\`.`; + } + + override get message(): string { + return this.detail; + } +} + +/** `gh` is installed but holds no login for the host. */ +export class GitHubNotSignedInError extends Schema.TaggedError()( + "GitHubNotSignedInError", + { host: Schema.String }, ) { get detail(): string { - return this.reason === "cli-missing" - ? `No GitHub credential for ${this.host}: set GH_TOKEN or install the GitHub CLI and run \`gh auth login\`.` - : `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.`; + return `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.`; } override get message(): string { @@ -49,6 +55,9 @@ export class GitHubCredentialUnavailableError extends Schema.TaggedError + error._tag === "VcsProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? new GitHubCliMissingError({ host }) + : new GitHubNotSignedInError({ host }), + ), Effect.map((output) => output.stdout.trim()), - Effect.catch((error) => - Effect.succeed( - error._tag === "VcsProcessSpawnError" && - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" - ? ("cli-missing" as const) - : "", - ), + Effect.filterOrFail( + (token) => token !== "", + () => new GitHubNotSignedInError({ host }), ), ); const lookup = Effect.fn("GitHubCredentials.lookup")(function* (host: string) { const fromEnv = environmentToken(host, globalThis.process.env); const token = fromEnv ?? (yield* fromGh(host)); - if (token === "cli-missing" || token === "") { - return yield* new GitHubCredentialUnavailableError({ - host, - reason: token === "cli-missing" ? "cli-missing" : "unauthenticated", - }); - } return { host, token: Redacted.make(token), From 00ff7c754f453aad524b9a910d4abe7b5116346c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:38:08 -0700 Subject: [PATCH 3/9] refactor(server): GitHub transport errors say it in message, hash with effect/Crypto, read the host env reference The credential and API errors drop the separate detail getter; the sentence lives in message. The credential fingerprint is a SHA-256 from effect/Crypto, and the env token and gh's working directory come from HostProcessEnvironment and HostProcessWorkingDirectory instead of globalThis.process. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/sourceControl/GitHubApi.test.ts | 6 ++- apps/server/src/sourceControl/GitHubApi.ts | 48 +++++-------------- .../src/sourceControl/GitHubCredentials.ts | 38 ++++++++------- 3 files changed, 38 insertions(+), 54 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index 5062314e279a..64b06f0e6a13 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; @@ -223,7 +224,10 @@ describe("GitHubApi", () => { describe("GitHubCredentials", () => { const credentialsWith = (run: VcsProcess.VcsProcess["Service"]["run"]) => - GitHubCredentials.layer.pipe(Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run }))); + GitHubCredentials.layer.pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })), + Layer.provide(NodeServices.layer), + ); it.effect("fails with GitHubCliMissingError when gh is not on PATH", () => Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 5db9be9ce0a5..f6f65faaeee8 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -36,12 +36,8 @@ export class GitHubApiRequestError extends Schema.TaggedError 0 ? `GitHub returned an error: ${this.graphqlErrors.join("; ")}` : `GitHub returned HTTP ${this.status}.`; } - - override get message(): string { - return `GitHub API failed in ${this.operation}: ${this.detail}`; - } } export type GitHubApiError = @@ -355,16 +335,14 @@ export const make = Effect.gen(function* () { }), { Ok: () => - limits - .recordSuccess({ ...key, lease }) - .pipe( - Effect.as({ - status, - headers, - body: collected.text, - truncated: collected.truncated, - }), - ), + limits.recordSuccess({ ...key, lease }).pipe( + Effect.as({ + status, + headers, + body: collected.text, + truncated: collected.truncated, + }), + ), RateLimited: () => Effect.gen(function* () { const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index 8696f482ca8c..7fe739dd43f0 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -1,13 +1,16 @@ import * as Cache from "effect/Cache"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Hex from "effect/encoding/Hex"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; -import * as NodeCrypto from "node:crypto"; + +import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -32,12 +35,8 @@ export class GitHubCliMissingError extends Schema.TaggedError + crypto.digest("SHA-256", new TextEncoder().encode(token)).pipe( + Effect.map((digest) => `${host}:${Hex.encode(digest)}`), + // Hashing a string in memory has no platform failure worth a typed error. + Effect.orDie, + ); const fromGh = (host: string) => process @@ -111,7 +113,7 @@ export const make = Effect.gen(function* () { operation: "GitHubCredentials.get", command: "gh", args: ["auth", "token", "--hostname", host], - cwd: globalThis.process.cwd(), + cwd: workingDirectory, // Never let gh print the token into a debug log. env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, timeoutMs: 10_000, @@ -132,13 +134,13 @@ export const make = Effect.gen(function* () { ); const lookup = Effect.fn("GitHubCredentials.lookup")(function* (host: string) { - const fromEnv = environmentToken(host, globalThis.process.env); + const fromEnv = environmentToken(host, environment); const token = fromEnv ?? (yield* fromGh(host)); return { host, token: Redacted.make(token), source: fromEnv !== null ? "env" : "gh", - fingerprint: credentialFingerprint(host, token), + fingerprint: yield* fingerprintOf(host, token), } satisfies GitHubCredential; }); From f4dd7791231a1e0e34d8d566d18930fbad056428 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:36:54 -0700 Subject: [PATCH 4/9] chore(server): drop the unused restJson helper Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/sourceControl/GitHubApi.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index f6f65faaeee8..036381a92333 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -431,10 +431,3 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(GitHubApi, make); - -const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); - -/** The JSON body of a REST answer, or none when GitHub sent nothing (204, 304). */ -export function restJson(response: GitHubRestResponse): Option.Option { - return response.body.trim() === "" ? Option.none() : decodeJson(response.body); -} From 914cfdd40aade4152f3a9ecce80aa2e094af6447 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:48:15 -0700 Subject: [PATCH 5/9] fix(server): GitHub transport hardening from review - An enterprise env token only goes to the host GH_HOST names. gh sends it to any non-github.com host, but here the host comes from a remote URL. - One deadline covers the response headers and body, so a host that stalls mid-body cannot hold a slot of the shared gate for minutes. - A GraphQL answer is NOT_FOUND only when every error is NOT_FOUND. - A GraphQL body cut at the byte cap fails instead of passing as an answer. - A gh failure that is not "no login" (a timeout, a locked keyring) is its own error and is not cached. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/sourceControl/GitHubApi.test.ts | 69 ++++++++++++++++++- apps/server/src/sourceControl/GitHubApi.ts | 44 +++++++----- .../src/sourceControl/GitHubCredentials.ts | 41 +++++++++-- 3 files changed, 131 insertions(+), 23 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index 64b06f0e6a13..0698af541aea 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -6,7 +6,7 @@ import * as Redacted from "effect/Redacted"; import * as PlatformError from "effect/PlatformError"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/process"; -import { VcsProcessSpawnError } from "@t3tools/contracts"; +import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http"; import * as GitHubApi from "./GitHubApi.ts"; @@ -82,7 +82,14 @@ describe("environmentToken", () => { expect(GitHubCredentials.environmentToken("acme.ghe.com", { GITHUB_TOKEN: "github" })).toBe( "github", ); - expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBe("ghe"); + // An enterprise token only goes to the host GH_HOST names, never to whatever a remote says. + expect(GitHubCredentials.environmentToken("git.acme.internal", env)).toBeNull(); + expect( + GitHubCredentials.environmentToken("git.acme.internal", { + ...env, + GH_HOST: "git.acme.internal", + }), + ).toBe("ghe"); expect(GitHubCredentials.environmentToken("git.acme.internal", { GH_TOKEN: "gh" })).toBeNull(); }); }); @@ -125,6 +132,32 @@ describe("GitHubApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "reads a NOT_FOUND answer with another error as a failure, not a missing resource", + () => { + const { layer } = harness(() => + json({ + data: null, + errors: [ + { type: "NOT_FOUND", message: "Could not resolve" }, + { message: "Something went wrong" }, + ], + }), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.graphql({ + host: "github.com", + operation: "detail", + query: "query { viewer { id } }", + }), + ); + expect(error._tag).toBe("GitHubApiResponseError"); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("pauses the host after a GraphQL RATE_LIMITED answer until the reset", () => { const reset = Math.floor(NOW / 1000) + 600; const { layer, requests } = harness(() => @@ -254,6 +287,38 @@ describe("GitHubCredentials", () => { ), ); + it.effect("does not cache a gh failure that is not about signing in", () => { + let calls = 0; + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const first = yield* Effect.flip(credentials.get("github.com")); + expect(first._tag).toBe("GitHubCliFailedError"); + expect((yield* credentials.get("github.com")).source).toBe("gh"); + expect(calls).toBe(2); + }).pipe( + Effect.provide( + credentialsWith(() => + ++calls === 1 + ? Effect.fail( + new VcsProcessTimeoutError({ + operation: "GitHubCredentials.get", + command: "gh", + cwd: "/", + timeoutMs: 10_000, + }), + ) + : Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "token\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), + ), + ); + }); + it.effect("fails with GitHubNotSignedInError when gh prints no token", () => Effect.gen(function* () { const credentials = yield* GitHubCredentials.GitHubCredentials; diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 036381a92333..1677a48cc9c3 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -233,7 +233,10 @@ function classify(input: { return Answer.RateLimited(); } if (status === 401) return Answer.Unauthorized(); - if (status === 404 || (types.length > 0 && types.every((type) => type === "NOT_FOUND"))) { + if ( + status === 404 || + (errors !== undefined && errors.every((error) => error.type === "NOT_FOUND")) + ) { return Answer.NotFound(); } if (errors !== undefined) return Answer.Failed({ messages }); @@ -283,6 +286,7 @@ export const make = Effect.gen(function* () { readonly acceptNotModified: boolean; /** Reads the body for GraphQL `errors`, which GitHub sends with HTTP 200. */ readonly graphql?: boolean; + readonly timeout?: Duration.Duration | undefined; }) { const host = normalizeHost(input.host); const { token, fingerprint } = yield* credential(host); @@ -293,8 +297,10 @@ export const make = Effect.gen(function* () { key, input.allowReserve ? { allowPaused: true } : undefined, ); - const response: HttpClientResponse.HttpClientResponse = yield* httpClient - .execute( + // One deadline covers the headers and the body: a host that answers headers and then stalls + // must not hold a slot of the shared gate for undici's own five-minute body timeout. + const { response, collected } = yield* Effect.gen(function* () { + const response: HttpClientResponse.HttpClientResponse = yield* httpClient.execute( input.request.pipe( HttpClientRequest.bearerToken(Redacted.value(token)), HttpClientRequest.setHeaders({ @@ -302,22 +308,20 @@ export const make = Effect.gen(function* () { "user-agent": "t3code", }), ), - ) - .pipe( - Effect.timeout(DEFAULT_TIMEOUT), - Effect.mapError( - (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), + ); + const collected = yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: input.maxResponseBytes, + }).pipe( + // 204, 304 and many refusals carry no body at all, which is an empty answer. + Effect.catchIf( + (error) => error.reason._tag === "EmptyBodyError", + () => Effect.succeed({ text: "", truncated: false }), ), ); - const collected = yield* collectUint8StreamText({ - stream: response.stream, - maxBytes: input.maxResponseBytes, + return { response, collected }; }).pipe( - // 204, 304 and many refusals carry no body at all, which is an empty answer, not a failure. - Effect.catchIf( - (error) => error.reason._tag === "EmptyBodyError", - () => Effect.succeed({ text: "", truncated: false }), - ), + Effect.timeout(input.timeout ?? DEFAULT_TIMEOUT), Effect.mapError( (cause) => new GitHubApiRequestError({ host, operation: input.operation, cause }), ), @@ -421,6 +425,14 @@ export const make = Effect.gen(function* () { acceptNotModified: false, graphql: true, }); + // A body cut at the byte cap hides any `errors` past the cut and cannot be decoded. + if (response.truncated) { + return yield* new GitHubApiResponseError({ + host, + operation: input.operation, + status: response.status, + }); + } yield* budget.observe(host, response.body); return response.body; }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index 7fe739dd43f0..7207373bcf31 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -6,6 +6,7 @@ import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; @@ -50,8 +51,21 @@ export class GitHubNotSignedInError extends Schema.TaggedError()( + "GitHubCliFailedError", + { host: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `The GitHub CLI could not hand over a credential for ${this.host}. Check \`gh auth status\` on the server.`; + } +} + /** There is no token for the host. */ -export type GitHubCredentialUnavailableError = GitHubCliMissingError | GitHubNotSignedInError; +export type GitHubCredentialUnavailableError = + | GitHubCliMissingError + | GitHubNotSignedInError + | GitHubCliFailedError; /** * Where GitHub tokens come from. Callers ask per host and never see how the token was found, @@ -77,14 +91,20 @@ function isGitHubDotCom(host: string): boolean { return host === "github.com" || host.endsWith(".ghe.com"); } -/** The environment variables gh itself reads for a host, in its precedence order. */ +/** + * The environment token for a host, in gh's precedence order. gh hands `GH_ENTERPRISE_TOKEN` to + * any non-github.com host; here it only goes to the host `GH_HOST` names, because a remote URL + * picks the host and a hostile one must not receive an enterprise token. + */ export function environmentToken( host: string, env: Readonly>, ): string | null { const names = isGitHubDotCom(host) ? ["GH_TOKEN", "GITHUB_TOKEN"] - : ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]; + : env.GH_HOST?.trim().toLowerCase() === host + ? ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] + : []; for (const name of names) { const value = env[name]?.trim(); if (value) return value; @@ -124,7 +144,10 @@ export const make = Effect.gen(function* () { error.cause instanceof PlatformError.PlatformError && error.cause.reason._tag === "NotFound" ? new GitHubCliMissingError({ host }) - : new GitHubNotSignedInError({ host }), + : // gh exits non-zero with "no oauth token" when it has no login for the host. + error._tag === "VcsProcessExitError" + ? new GitHubNotSignedInError({ host }) + : new GitHubCliFailedError({ host, cause: error }), ), Effect.map((output) => output.stdout.trim()), Effect.filterOrFail( @@ -146,7 +169,15 @@ export const make = Effect.gen(function* () { const cache = yield* Cache.makeWith(lookup, { capacity: 32, - timeToLive: (exit) => (Exit.isSuccess(exit) ? TOKEN_TTL : MISSING_TTL), + // A transient gh failure (a timeout, a locked keyring) is asked again on the next read. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? TOKEN_TTL + : Exit.findErrorOption(exit).pipe( + Option.exists((error) => error._tag === "GitHubCliFailedError"), + ) + ? Duration.zero + : MISSING_TTL, }); return GitHubCredentials.of({ From f452f903cc4f28407e54a4949f9bcbb53c22ceae Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:19:42 -0700 Subject: [PATCH 6/9] fix(server): interactive GitHub requests go through a background rate-limit pause AllowGitHubReserve moves to GitHubApi and is honored by rest and graphql, so a user's merge, comment or review is not refused because a background sweep exhausted the quota. A request can still override it with allowReserve. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/sourceControl/GitHubApi.test.ts | 33 +++++++++++++++++ apps/server/src/sourceControl/GitHubApi.ts | 37 +++++++++++++------ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index 0698af541aea..1714f8468cbf 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -184,6 +184,39 @@ describe("GitHubApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect("lets an interactive request through a pause a background read recorded", () => { + const reset = Math.floor(NOW / 1000) + 600; + let call = 0; + const { layer, requests } = harness(() => + ++call === 1 + ? json( + { message: "API rate limit exceeded" }, + { + status: 403, + headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) }, + }, + ) + : json({ ok: true }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; + yield* Effect.flip(api.rest(read)); + expect((yield* Effect.flip(api.rest(read)))._tag).toBe("SourceControlRateLimitPausedError"); + const merged = yield* api + .rest({ + host: "github.com", + operation: "merge", + method: "PUT", + path: "repos/acme/web/pulls/7/merge", + }) + .pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); + expect(merged.status).toBe(200); + expect(requests).toHaveLength(2); + }).pipe(Effect.provide(layer)); + }); + it.effect("maps REST 403 with an exhausted quota to a rate limit, and 304 to an answer", () => { let call = 0; const { layer } = harness(() => diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 1677a48cc9c3..b23c5a3ffe55 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -32,6 +32,16 @@ export const PinnedGitHubCredential = Context.Reference<{ readonly credentialFingerprint: string; } | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); +/** + * Set by interactive callers (a user's read or write, not a background sweep). Requests made + * under it may spend the GraphQL reserve and go through a rate-limit pause: a user acting on a + * pull request should not be refused because a background read exhausted the quota. + */ +export const AllowGitHubReserve = Context.Reference( + "t3/sourceControl/AllowGitHubReserve", + { defaultValue: () => false }, +); + export class GitHubApiRequestError extends Schema.TaggedError()( "GitHubApiRequestError", { host: Schema.String, operation: Schema.String, cause: Schema.Defect() }, @@ -120,7 +130,7 @@ export interface GitHubRestInput { /** Revalidates a cached answer. A 304 is returned rather than failed, and is free. */ readonly ifNoneMatch?: string; readonly maxResponseBytes?: number; - /** Interactive reads may run through a pause, the way they may spend the GraphQL reserve. */ + /** Overrides `AllowGitHubReserve` for this one request. */ readonly allowReserve?: boolean; } @@ -392,14 +402,18 @@ export const make = Effect.gen(function* () { input.body === undefined ? withEtag : withEtag.pipe(HttpClientRequest.bodyJsonUnsafe(input.body)); - return send({ - host: input.host, - operation: input.operation, - request, - maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, - allowReserve: input.allowReserve === true, - acceptNotModified: input.ifNoneMatch !== undefined, - }); + return AllowGitHubReserve.pipe( + Effect.flatMap((interactive) => + send({ + host: input.host, + operation: input.operation, + request, + maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, + allowReserve: input.allowReserve ?? interactive, + acceptNotModified: input.ifNoneMatch !== undefined, + }), + ), + ); }; const graphql: GitHubApi["Service"]["graphql"] = Effect.fn("GitHubApi.graphql")( @@ -407,11 +421,12 @@ export const make = Effect.gen(function* () { const host = normalizeHost(input.host); const { fingerprint } = yield* credential(host); const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; + const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve); return yield* Effect.gen(function* () { const query = yield* budget.query( host, input.query, - input.allowReserve === true ? { allowReserve: true } : undefined, + allowReserve ? { allowReserve: true } : undefined, ); const response = yield* send({ host, @@ -421,7 +436,7 @@ export const make = Effect.gen(function* () { HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }), ), maxResponseBytes: DEFAULT_MAX_RESPONSE_BYTES, - allowReserve: input.allowReserve === true, + allowReserve, acceptNotModified: false, graphql: true, }); From ac627ca08bdb5fd5e8cc240c1fc7c53fee39cf1c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:21:06 -0700 Subject: [PATCH 7/9] fix(server): a refused GitHub REST request says GitHub's reason REST failure bodies carry message and errors ("A pull request already exists for acme:feature"); they now reach the error message the way GraphQL errors did, instead of only "GitHub returned HTTP 422". Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/sourceControl/GitHubApi.test.ts | 32 +++++++++++ apps/server/src/sourceControl/GitHubApi.ts | 55 ++++++++++++++++--- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index 1714f8468cbf..a47964a455ac 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -184,6 +184,38 @@ describe("GitHubApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect("carries GitHub's own reason for a refused REST request", () => { + const { layer } = harness(() => + json( + { + message: "Validation Failed", + errors: [ + { + resource: "PullRequest", + code: "custom", + message: "A pull request already exists for acme:feature.", + }, + ], + }, + { status: 422 }, + ), + ); + return Effect.gen(function* () { + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.rest({ + host: "github.com", + operation: "createPullRequest", + method: "POST", + path: "repos/acme/web/pulls", + }), + ); + expect(error.message).toBe( + "GitHub returned an error: Validation Failed; A pull request already exists for acme:feature.", + ); + }).pipe(Effect.provide(layer)); + }); + it.effect("lets an interactive request through a pause a background read recorded", () => { const reset = Math.floor(NOW / 1000) + 600; let call = 0; diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index b23c5a3ffe55..670726fc1228 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -83,8 +83,9 @@ export class GitHubApiNotFoundError extends Schema.TaggedError()( "GitHubApiResponseError", @@ -92,12 +93,12 @@ export class GitHubApiResponseError extends Schema.TaggedError 0 - ? `GitHub returned an error: ${this.graphqlErrors.join("; ")}` + return this.githubErrors !== undefined && this.githubErrors.length > 0 + ? `GitHub returned an error: ${this.githubErrors.join("; ")}` : `GitHub returned HTTP ${this.status}.`; } } @@ -204,6 +205,46 @@ const decodeGraphQlErrors = Schema.decodeUnknownOption( ), ); +/** A REST failure body: `{ message, errors: [{ message } | { resource, field, code }] }`. */ +const decodeRestErrors = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + message: Schema.optional(Schema.String), + errors: Schema.optional( + Schema.Array( + Schema.Union([ + Schema.String, + Schema.Struct({ + message: Schema.optional(Schema.String), + field: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), + }), + ]), + ), + ), + }), + ), +); + +function restErrorMessages(body: string): ReadonlyArray | undefined { + return Option.match(decodeRestErrors(body), { + onNone: () => undefined, + onSome: (decoded) => { + const details = (decoded.errors ?? []).flatMap((error) => + typeof error === "string" + ? [error] + : error.message !== undefined + ? [error.message] + : error.field !== undefined && error.code !== undefined + ? [`${error.field} ${error.code}`] + : [], + ); + const messages = [...(decoded.message === undefined ? [] : [decoded.message]), ...details]; + return messages.length > 0 ? messages : undefined; + }, + }); +} + /** What one GitHub answer means, decided once from its status, headers and body. */ type Answer = Data.TaggedEnum<{ Ok: {}; @@ -253,7 +294,7 @@ function classify(input: { if ((status >= 200 && status < 300) || (status === 304 && input.acceptNotModified)) { return Answer.Ok(); } - return Answer.Failed({ messages: undefined }); + return Answer.Failed({ messages: input.graphql ? undefined : restErrorMessages(body) }); } /** @public Service construction is part of the canonical Effect module API. */ @@ -377,7 +418,7 @@ export const make = Effect.gen(function* () { new GitHubApiResponseError({ ...context, status, - ...(messages === undefined ? {} : { graphqlErrors: messages }), + ...(messages === undefined ? {} : { githubErrors: messages }), }), ), }, From 1ae0ddcc9b02fe41409e471d3c2ce8289e13d0a9 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:27:05 -0700 Subject: [PATCH 8/9] fix(server): an exhausted GraphQL quota pauses the host even without a RATE_LIMITED type Found against live GitHub: with the GraphQL quota at zero, GitHub answers HTTP 200 with an untyped "API rate limit already exceeded" error and x-ratelimit-remaining: 0. That read as an ordinary failure, so background sweeps kept asking instead of waiting for the reset. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../server/src/sourceControl/GitHubApi.test.ts | 18 ++++++++++++++++++ apps/server/src/sourceControl/GitHubApi.ts | 6 ++++++ 2 files changed, 24 insertions(+) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index a47964a455ac..20c95c987d9f 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -249,6 +249,24 @@ describe("GitHubApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect("reads an untyped GraphQL quota error as a rate limit", () => { + const reset = Math.floor(NOW / 1000) + 900; + const { layer } = harness(() => + json( + { errors: [{ message: "API rate limit already exceeded for user ID 1." }] }, + { headers: { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) } }, + ), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const error = yield* Effect.flip( + api.graphql({ host: "github.com", operation: "lookup", query: "query { viewer { id } }" }), + ); + expect(error).toMatchObject({ _tag: "GitHubApiRateLimitError", retryAt: reset * 1000 }); + }).pipe(Effect.provide(layer)); + }); + it.effect("maps REST 403 with an exhausted quota to a rate limit, and 304 to an answer", () => { let call = 0; const { layer } = harness(() => diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 670726fc1228..747deb115efa 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -276,6 +276,12 @@ function classify(input: { if ( status === 429 || types.includes("RATE_LIMITED") || + // An exhausted GraphQL quota can answer HTTP 200 with an untyped "API rate limit already + // exceeded" error; the headers say the same thing. + (input.graphql && errors !== undefined && headers["x-ratelimit-remaining"] === "0") || + (input.graphql && + messages !== undefined && + messages.some((message) => /rate limit (already )?exceeded/i.test(message))) || (status === 403 && (headers["x-ratelimit-remaining"] === "0" || headers["retry-after"] !== undefined || From 1d17986c492c6c5aa26119974de487b6c6d6bf5c Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:12:15 -0700 Subject: [PATCH 9/9] feat(server): GitHub requests are traced by operation, path, query and cost GitHubApi.send and GitHubApi.graphql spans now carry the operation, REST path, response status, GitHub's rate-limit headers, a pause or refusal and its retry time, and for GraphQL the query document (capped at 4,000 chars), a short hash to group by, and the cost and remaining points GitHub reported. Variables are never recorded: user text travels there. The HTTP client's own span is turned off for these requests because it records url.full and the query string, which can carry SHAs and branch names; only the path is kept. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/sourceControl/GitHubApi.test.ts | 73 ++++++++++++ apps/server/src/sourceControl/GitHubApi.ts | 112 ++++++++++++++++-- 2 files changed, 172 insertions(+), 13 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index 20c95c987d9f..85fe9a2dcfbe 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as PlatformError from "effect/PlatformError"; import * as TestClock from "effect/testing/TestClock"; +import * as Tracer from "effect/Tracer"; import { ChildProcessSpawner } from "effect/process"; import { VcsProcessSpawnError, VcsProcessTimeoutError } from "@t3tools/contracts"; import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/http"; @@ -118,6 +119,78 @@ describe("GitHubApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "traces the operation, path, query and cost, never the query string or variables", + () => { + const spans: Array = []; + const tracer = Tracer.make({ + span: (options) => { + const span = new Tracer.NativeSpan(options); + spans.push(span); + return span; + }, + }); + const { layer } = harness((request) => + request.url.endsWith("/graphql") + ? json( + { + data: { + viewer: { login: "julius" }, + rateLimit: { + cost: 3, + limit: 5000, + remaining: 4990, + resetAt: "2026-10-05T13:00:00Z", + }, + }, + }, + { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, + ) + : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + yield* api.graphql({ + host: "github.com", + operation: "getPullRequestDetail", + query: "query($body: String!) { viewer { login } }", + variables: { body: "secret user text" }, + }); + yield* api.rest({ + host: "github.com", + operation: "listWorkflowRuns", + path: "repos/acme/web/actions/runs?head_sha=abc123&branch=feat%2Fx", + }); + const byName = (name: string) => spans.filter((span) => span.name === name); + const graphql = Object.fromEntries(byName("GitHubApi.graphql")[0]!.attributes); + expect(graphql).toMatchObject({ + "github.operation": "getPullRequestDetail", + "github.graphql.query": "query($body: String!) { viewer { login } }", + "github.graphql.cost": 3, + "github.graphql.remaining": 4990, + }); + expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); + const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); + expect(rest).toMatchObject({ + "github.operation": "listWorkflowRuns", + "github.kind": "rest", + "url.path": "/repos/acme/web/actions/runs", + "http.response.status_code": 200, + "github.ratelimit.remaining": 4800, + }); + // Nothing recorded carries the query string, the variables, or the token. + const everything = spans + .flatMap((span) => [...span.attributes].map(([key, value]) => `${key}=${String(value)}`)) + .join("\n"); + expect(everything).not.toContain("head_sha"); + expect(everything).not.toContain("secret user text"); + expect(everything).not.toContain("first"); + expect(spans.some((span) => span.attributes.has("url.full"))).toBe(false); + }).pipe(Effect.provide(layer), Effect.withTracer(tracer)); + }, + ); + it.effect("fails a GraphQL answer that carries errors, naming GitHub's reason", () => { const { layer } = harness(() => json({ data: null, errors: [{ type: "FORBIDDEN", message: "Resource not accessible" }] }), diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 747deb115efa..7b1a9f316a9c 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -181,6 +181,48 @@ export function gitHubApiUrls(host: string): { readonly rest: string; readonly g return { rest: `https://${normalized}/api/v3`, graphql: `https://${normalized}/api/graphql` }; } +/** GraphQL documents longer than this are cut in traces; the hash still identifies them. */ +const TRACED_QUERY_MAX_CHARS = 4_000; + +/** A stable short id for a GraphQL document, so traces group by query without its text. */ +function queryHash(query: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < query.length; index++) { + hash ^= query.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +/** The numeric rate-limit headers GitHub sends, as span attributes. */ +function rateLimitAttributes( + headers: Readonly>, +): Record { + const attributes: Record = {}; + for (const [header, name] of [ + ["x-ratelimit-limit", "github.ratelimit.limit"], + ["x-ratelimit-remaining", "github.ratelimit.remaining"], + ["x-ratelimit-used", "github.ratelimit.used"], + ["x-ratelimit-reset", "github.ratelimit.reset"], + ] as const) { + const value = Number(headers[header]); + if (headers[header] !== undefined && Number.isFinite(value)) attributes[name] = value; + } + const resource = headers["x-ratelimit-resource"]; + if (resource !== undefined) attributes["github.ratelimit.resource"] = resource; + return attributes; +} + +const decodeGraphQlCost = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + rateLimit: Schema.Struct({ cost: Schema.Number, remaining: Schema.Number }), + }), + }), + ), +); + /** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ function retryAtFrom( headers: Readonly>, @@ -346,26 +388,46 @@ export const make = Effect.gen(function* () { readonly timeout?: Duration.Duration | undefined; }) { const host = normalizeHost(input.host); + // Only the path: a query string can carry a SHA or a branch, and never needs to be in a trace. + yield* Effect.annotateCurrentSpan({ + "github.host": host, + "github.operation": input.operation, + "github.kind": input.graphql === true ? "graphql" : "rest", + "http.request.method": input.request.method, + "url.path": new URL(input.request.url).pathname, + }); const { token, fingerprint } = yield* credential(host); const scope = yield* SourceControlRateLimit.CredentialScope; const key = { provider: "github" as const, host }; const run = Effect.gen(function* () { - const lease = yield* limits.check( - key, - input.allowReserve ? { allowPaused: true } : undefined, - ); - // One deadline covers the headers and the body: a host that answers headers and then stalls - // must not hold a slot of the shared gate for undici's own five-minute body timeout. - const { response, collected } = yield* Effect.gen(function* () { - const response: HttpClientResponse.HttpClientResponse = yield* httpClient.execute( - input.request.pipe( - HttpClientRequest.bearerToken(Redacted.value(token)), - HttpClientRequest.setHeaders({ - "x-github-api-version": API_VERSION, - "user-agent": "t3code", + const lease = yield* limits + .check(key, input.allowReserve ? { allowPaused: true } : undefined) + .pipe( + Effect.tapError((paused) => + Effect.annotateCurrentSpan({ + "github.paused": true, + "github.retry_at": paused.retryAt, }), ), ); + // One deadline covers the headers and the body: a host that answers headers and then stalls + // must not hold a slot of the shared gate for undici's own five-minute body timeout. + const { response, collected } = yield* Effect.gen(function* () { + const response: HttpClientResponse.HttpClientResponse = yield* httpClient + .execute( + input.request.pipe( + HttpClientRequest.bearerToken(Redacted.value(token)), + HttpClientRequest.setHeaders({ + "x-github-api-version": API_VERSION, + "user-agent": "t3code", + }), + ), + ) + .pipe( + // `GitHubApi.send` is the request's span. The client's own span would add `url.full` + // and `url.query`, which can carry SHAs and branch names, so it is off for GitHub. + Effect.provideService(HttpClient.TracerDisabledWhen, () => true), + ); const collected = yield* collectUint8StreamText({ stream: response.stream, maxBytes: input.maxResponseBytes, @@ -385,6 +447,10 @@ export const make = Effect.gen(function* () { ); const headers = response.headers; const status = response.status; + yield* Effect.annotateCurrentSpan({ + "http.response.status_code": status, + ...rateLimitAttributes(headers), + }); const context = { host, operation: input.operation }; return yield* Answer.$match( classify({ @@ -408,6 +474,10 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const retryAt = retryAtFrom(headers, yield* Clock.currentTimeMillis); yield* limits.recordRateLimit({ ...key, lease, retryAt }); + yield* Effect.annotateCurrentSpan({ + "github.rate_limited": true, + ...(retryAt === undefined ? {} : { "github.retry_at": retryAt }), + }); return yield* new GitHubApiRateLimitError({ ...context, ...(retryAt === undefined ? {} : { retryAt }), @@ -469,6 +539,15 @@ export const make = Effect.gen(function* () { const { fingerprint } = yield* credential(host); const scope = (yield* SourceControlRateLimit.CredentialScope) || fingerprint; const allowReserve = input.allowReserve ?? (yield* AllowGitHubReserve); + // The document, never its variables: user text (bodies, search terms) travels as variables. + yield* Effect.annotateCurrentSpan({ + "github.operation": input.operation, + "github.graphql.query_hash": queryHash(input.query), + "github.graphql.query": + input.query.length > TRACED_QUERY_MAX_CHARS + ? `${input.query.slice(0, TRACED_QUERY_MAX_CHARS)}…` + : input.query, + }); return yield* Effect.gen(function* () { const query = yield* budget.query( host, @@ -496,6 +575,13 @@ export const make = Effect.gen(function* () { }); } yield* budget.observe(host, response.body); + const cost = Option.getOrUndefined(decodeGraphQlCost(response.body)); + if (cost !== undefined) { + yield* Effect.annotateCurrentSpan({ + "github.graphql.cost": cost.data.rateLimit.cost, + "github.graphql.remaining": cost.data.rateLimit.remaining, + }); + } return response.body; }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); },