diff --git a/packages/source-control-github/src/server/GitHubCredentials.test.ts b/packages/source-control-github/src/server/GitHubCredentials.test.ts index 61bfc59cc3f87..07bdeca17e41c 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.test.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.test.ts @@ -3,7 +3,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest" import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; -import { ChildProcessSpawner } from "effect/process"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { DEFAULT_SERVER_SETTINGS } from "@t3tools/contracts"; import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; @@ -29,11 +32,18 @@ function harness( hosts: Record = {}, signedOut: ReadonlyArray = [], tokens: Record = {}, + repoAccounts: + | Record + | ((cwd: string, key: string) => Effect.Effect) = {}, + defaultTokens: Record = {}, ) { const calls: Array> = []; + const directories: Array = []; + const configReads: Array = []; const run: SourceControlHost.SourceControlHost["Service"]["process"]["run"] = (input) => Effect.sync(() => { calls.push(input.args); + directories.push(input.cwd); const user = input.args[input.args.indexOf("--user") + 1]; if (input.args.includes("--user") && user !== undefined && signedOut.includes(user)) { return { @@ -46,7 +56,9 @@ function harness( } return { exitCode: ChildProcessSpawner.ExitCode(0), - stdout: input.args.includes("--user") ? `token-for-${user}\n` : "active-token\n", + stdout: input.args.includes("--user") + ? `token-for-${user}\n` + : `${defaultTokens[input.cwd] ?? "active-token"}\n`, stderr: "", stdoutTruncated: false, stderrTruncated: false, @@ -59,11 +71,23 @@ function harness( sourceControlHosts: { github: { hosts: hostSettings(hosts), tokens } }, }), process: { run }, + git: { + readConfigValue: (cwd, key) => + Effect.sync(() => { + configReads.push([cwd, key]); + }).pipe( + Effect.andThen( + typeof repoAccounts === "function" + ? repoAccounts(cwd, key) + : Effect.succeed(repoAccounts[cwd] ?? null), + ), + ), + }, }), ), Layer.provide(NodeCrypto.layer), ); - return { layer, calls }; + return { layer, calls, directories, configReads }; } describe("GitHubCredentials", () => { @@ -74,15 +98,174 @@ describe("GitHubCredentials", () => { vi.unstubAllEnvs(); }); + it.effect("uses the repository's account without sharing the default token", () => { + const { layer, calls, directories, configReads } = harness( + {}, + [], + {}, + { + "/work": "work", + "/work-tree": "work", + }, + ); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + expect(Redacted.value((yield* credentials.get("github.com", "/personal")).token)).toBe( + "active-token", + ); + expect(Redacted.value((yield* credentials.get(" GITHUB.COM ", "/work")).token)).toBe( + "token-for-work", + ); + expect(Redacted.value((yield* credentials.get("github.com", "/work-tree")).token)).toBe( + "token-for-work", + ); + expect(Redacted.value((yield* credentials.get("github.com", "/personal")).token)).toBe( + "active-token", + ); + expect(calls).toEqual([ + ["auth", "token", "--hostname", "github.com"], + ["auth", "token", "--hostname", "github.com", "--user", "work"], + ]); + expect(directories).toEqual(["/personal", "/work"]); + expect(configReads).toContainEqual(["/work-tree", "credential.https://github.com.username"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("honors includeIf gitdir in a main checkout and its linked worktree", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const temporary = yield* fs.makeTempDirectoryScoped({ prefix: "t3-gh-account-" }); + const directory = yield* fs.realPath(temporary); + const main = path.join(directory, "main"); + const worktree = path.join(directory, "linked"); + const globalConfig = path.join(directory, "gitconfig"); + const accountConfig = path.join(directory, "account"); + const git = (cwd: string, ...args: Array) => + spawner + .string( + ChildProcess.make("git", args, { + cwd, + env: { GIT_CONFIG_GLOBAL: globalConfig, GIT_CONFIG_NOSYSTEM: "1" }, + extendEnv: true, + }), + ) + .pipe(Effect.orDie); + yield* fs.writeFileString(globalConfig, ""); + yield* git(directory, "init", "-q", main); + yield* git( + main, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-qm", + "initial", + ); + yield* git(main, "worktree", "add", "-qb", "linked", worktree); + yield* fs.writeFileString( + accountConfig, + '[credential "https://github.com"]\nusername = work\n', + ); + yield* fs.writeFileString( + globalConfig, + `[includeIf "gitdir:${main}/"]\npath = ${accountConfig}\n`, + ); + const { layer, calls, directories } = harness({}, [], {}, (cwd, key) => + git(cwd, "config", "--get", key).pipe(Effect.map((value) => value.trim())), + ); + yield* Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + for (const cwd of [main, worktree]) { + expect(Redacted.value((yield* credentials.get("github.com", cwd)).token)).toBe( + "token-for-work", + ); + } + expect(calls).toEqual([["auth", "token", "--hostname", "github.com", "--user", "work"]]); + expect(directories).toEqual([main]); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect( + "keeps directory-selected wrapper accounts separate without a configured username", + () => { + const { layer, calls, directories } = harness( + {}, + [], + {}, + {}, + { "/work": "wrapper-work", "/personal": "wrapper-personal" }, + ); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + for (const cwd of ["/personal", "/work", "/personal", "/work"]) { + expect(Redacted.value((yield* credentials.get("github.com", cwd)).token)).toBe( + `wrapper-${cwd.slice(1)}`, + ); + } + expect(directories).toEqual(["/personal", "/work"]); + expect(calls.every((args) => !args.includes("--user"))).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("falls back to the existing account choice for an unknown repository username", () => { + const { layer, calls } = harness( + { "github.com": { account: "personal" } }, + ["missing"], + {}, + { "/work": "missing" }, + ); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + expect(Redacted.value((yield* credentials.get("github.com", "/work")).token)).toBe( + "token-for-personal", + ); + expect(calls).toEqual([ + ["auth", "token", "--hostname", "github.com", "--user", "missing"], + ["auth", "token", "--hostname", "github.com", "--user", "personal"], + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "refreshes the repository account after invalidation without evicting another account", + () => { + const { layer, calls } = harness({}, [], {}, { "/a": "personal", "/b": "work" }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + yield* credentials.get("github.com", "/a"); + yield* credentials.get("github.com", "/b"); + yield* credentials.invalidate("github.com", "/b"); + yield* credentials.get("github.com", "/a"); + yield* credentials.get("github.com", "/b"); + expect(calls.filter((args) => args.includes("personal"))).toHaveLength(1); + expect(calls.filter((args) => args.includes("work"))).toHaveLength(2); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("lets an environment token win over a pinned account, as gh does", () => { vi.stubEnv("GH_TOKEN", "from-env"); - const { layer, calls } = harness({ "github.com": { account: "work" } }); + const { layer, calls, configReads } = harness( + { "github.com": { account: "work" } }, + [], + {}, + { "/repo": "another" }, + ); return Effect.gen(function* () { const credentials = yield* GitHubCredentials.GitHubCredentials; - const credential = yield* credentials.get("github.com"); + const credential = yield* credentials.get("github.com", "/repo"); expect(Redacted.value(credential.token)).toBe("from-env"); expect(credential.source).toBe("env"); expect(calls).toEqual([]); + expect(configReads).toEqual([]); }).pipe(Effect.provide(layer)); }); diff --git a/packages/source-control-github/src/server/GitHubCredentials.ts b/packages/source-control-github/src/server/GitHubCredentials.ts index b2d0b7d0ea34b..5413f2c2b1661 100644 --- a/packages/source-control-github/src/server/GitHubCredentials.ts +++ b/packages/source-control-github/src/server/GitHubCredentials.ts @@ -81,8 +81,9 @@ export class GitHubCredentials extends Context.Service< { readonly get: ( host: string, + cwd?: string, ) => Effect.Effect; - readonly invalidate: (host: string) => Effect.Effect; + readonly invalidate: (host: string, cwd?: string) => Effect.Effect; } >()("@t3tools/source-control-github/server/GitHubCredentials") {} @@ -130,7 +131,7 @@ export const make = Effect.gen(function* () { Effect.orDie, ); - const fromGh = (host: string, account: string | undefined) => + const fromGh = (host: string, account: string | undefined, cwd: string) => process .run({ operation: "GitHubCredentials.get", @@ -142,7 +143,7 @@ export const make = Effect.gen(function* () { host, ...(account === undefined ? [] : ["--user", account]), ], - cwd: workingDirectory, + cwd, // Never let gh print the token into a debug log. env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" }, timeoutMs: 10_000, @@ -183,20 +184,24 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => null), ); - const cacheKey = (host: string, account: string | undefined) => - account === undefined ? host : `${host}\u0000${account}`; + const repositoryAccount = (host: string, cwd: string | undefined) => + cwd === undefined + ? Effect.succeed(undefined) + : sourceControlHost.git.readConfigValue(cwd, `credential.https://${host}.username`).pipe( + Effect.map((value) => value?.trim() || undefined), + Effect.orElseSucceed(() => undefined), + ); + + // Wrappers may select an unnamed active account by directory. Named accounts share a token + // across checkouts, but an active login from one directory must not leak into another. + const cacheKey = (host: string, account: string | undefined, cwd: string) => + `${host}\u0000${account ?? ""}\u0000${account === undefined ? cwd : ""}`; const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) { - const [host = key, choice] = key.split("\u0000"); + const [host = key, account, directory] = key.split("\u0000"); + const cwd = directory || (yield* HostProcess.WorkingDirectory); const fromEnv = environmentToken(host, environment); - const token = - fromEnv ?? - (yield* fromGh(host, choice).pipe( - Effect.catchTags({ - GitHubNotSignedInError: (error) => - choice === undefined ? Effect.fail(error) : fromGh(host, undefined), - }), - )); + const token = fromEnv ?? (yield* fromGh(host, account || undefined, cwd)); return { host, token: Redacted.make(token), @@ -218,7 +223,7 @@ export const make = Effect.gen(function* () { }); return GitHubCredentials.of({ - get: Effect.fn("GitHubCredentials.get")(function* (rawHost) { + get: Effect.fn("GitHubCredentials.get")(function* (rawHost, cwd) { const host = normalizeHost(rawHost); const choice = yield* hostChoice(host); if (choice?.enabled === false) { @@ -233,14 +238,36 @@ export const make = Effect.gen(function* () { fingerprint: yield* fingerprintOf(host, saved), } satisfies GitHubCredential; } - return yield* Cache.get(cache, cacheKey(host, choice?.account)); + const directory = cwd ?? workingDirectory; + const account = + environmentToken(host, environment) === null + ? yield* repositoryAccount(host, cwd) + : undefined; + const active = Cache.get(cache, cacheKey(host, undefined, directory)); + const selected = + choice?.account === undefined + ? active + : Cache.get(cache, cacheKey(host, choice.account, directory)).pipe( + Effect.catchTags({ GitHubNotSignedInError: () => active }), + ); + return yield* ( + account === undefined + ? selected + : Cache.get(cache, cacheKey(host, account, directory)).pipe( + Effect.catchTags({ GitHubNotSignedInError: () => selected }), + ) + ).pipe(Effect.provideService(HostProcess.WorkingDirectory, directory)); }), - invalidate: (rawHost) => { + invalidate: Effect.fn("GitHubCredentials.invalidate")(function* (rawHost, cwd) { const host = normalizeHost(rawHost); - return hostChoice(host).pipe( - Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))), + const choice = yield* hostChoice(host); + const account = yield* repositoryAccount(host, cwd); + yield* Effect.forEach( + new Set([account, choice?.account, undefined]), + (account) => Cache.invalidate(cache, cacheKey(host, account, cwd ?? workingDirectory)), + { discard: true }, ); - }, + }), }); }); diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 23cadae857751..88a181e0c855d 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -101,7 +101,7 @@ type ApiCall = type ApiAnswer = Effect.Effect; const mockedExecute = vi.fn<(call: ApiCall) => ApiAnswer>(); -const defaultCredential = (host: string) => +const defaultCredential = (host: string, _cwd?: string) => Effect.succeed({ token: Redacted.make("token"), fingerprint: `${host}:token` }); const mockedCredential = vi.fn(defaultCredential); const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => @@ -113,7 +113,7 @@ const mockApi = Layer.succeed( GitHubRest.GitHubRest, GitHubRest.GitHubRest.of({ rest: (input) => mockedExecute({ kind: "rest", ...input }), - credential: (host) => mockedCredential(host), + credential: (host, cwd) => mockedCredential(host, cwd), }), ); @@ -183,8 +183,12 @@ const layer = it.layer( Layer.provideMerge(mockHttp), Layer.provideMerge( Layer.succeed(GitHubCredentials.GitHubCredentials, { - get: (host) => - Effect.map(mockedCredential(host), (held) => ({ ...held, host, source: "gh" as const })), + get: (host, cwd) => + Effect.map(mockedCredential(host, cwd), (held) => ({ + ...held, + host, + source: "gh" as const, + })), invalidate: () => Effect.void, }), ), @@ -723,6 +727,91 @@ it.effect( ); layer("GitHubPullRequestApi.layer", (it) => { + it.effect("keeps summaries and watch batches separate by repository credential", () => + Effect.gen(function* () { + mockedCredential.mockImplementation((host, cwd) => { + const account = cwd?.startsWith("/work") ? "work" : "personal"; + return Effect.succeed({ token: Redacted.make(account), fingerprint: `${host}:${account}` }); + }); + mockedExecute.mockImplementation((call) => { + assert.strictEqual(call.kind, "graphql"); + if (call.kind !== "graphql") return Effect.die("Expected GraphQL"); + const variables = call.variables ?? {}; + const prefixes = [ + ...new Set(Object.keys(variables).flatMap((key) => /^_\d+_/.exec(key) ?? [])), + ]; + const data = Object.fromEntries( + prefixes.map((prefix) => { + const owner = variables[`${prefix}owner`]; + expect(call.authorization).toBe(`Bearer ${owner}`); + return [ + `${prefix}repository`, + { + pullRequest: { + ...coreResponse().data.repository.pullRequest, + number: variables[`${prefix}number`], + latestReviews: { nodes: [] }, + comments: { totalCount: 0, nodes: [] }, + reviews: { totalCount: 0, nodes: [] }, + reviewThreads: { totalCount: 0 }, + }, + }, + ]; + }), + ); + return Effect.succeed(output(encodeJson({ data }))); + }); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const inputs = [ + { ...pullRequestInput, cwd: "/work", repository: "work/repo" }, + { ...pullRequestInput, cwd: "/work-tree", repository: "work/repo", number: 8 }, + { ...pullRequestInput, cwd: "/personal", repository: "personal/repo" }, + ]; + const summaries = yield* Effect.forEach(inputs, cli.getPullRequestSummary, { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + expect((yield* Fiber.join(summaries)).map((pr) => pr.number)).toEqual([7, 8, 7]); + const watches = yield* Effect.forEach(inputs, cli.getPullRequestWatchFingerprint, { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + expect(yield* Fiber.join(watches)).toHaveLength(3); + expect(mockedExecute).toHaveBeenCalledTimes(4); + expect( + [0, 1, 2, 3] + .map((index) => callAt(index)) + .filter((call) => call.kind === "graphql" && call.authorization === "Bearer work"), + ).toHaveLength(2); + }), + ); + + it.effect("uses the repository credential for detail reads and merge mutations", () => + Effect.gen(function* () { + mockedCredential.mockImplementation((host, cwd) => + Effect.succeed({ + token: Redacted.make(cwd === "/work" ? "work" : "personal"), + fingerprint: `${host}:${cwd}`, + }), + ); + route( + ["query PullRequestCore", coreResponse()], + ["query PullRequestActionState", actionState()], + ); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + for (const cwd of ["/personal", "/work"]) { + const start = mockedExecute.mock.calls.length; + yield* cli.getPullRequestDetail({ ...pullRequestInput, cwd }); + yield* cli.runPullRequestAction({ ...pullRequestInput, cwd, action: "merge" }); + const calls = mockedExecute.mock.calls.slice(start).map(([call]) => call); + expect(calls.length).toBeGreaterThanOrEqual(3); + for (const call of calls) { + if (call.kind === "graphql") expect(call.authorization).toBe(`Bearer ${cwd.slice(1)}`); + } + } + }), + ); + it.effect("loads the complete hover card with one GraphQL request", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index c25699714d147..49f27105a1d2b 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -942,7 +942,7 @@ export const make = Effect.gen(function* () { function* (input: { readonly cwd: string; readonly host: string }) { const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); const host = input.host.toLowerCase(); - const { token, fingerprint: key } = yield* api.credential(host); + const { token, fingerprint: key } = yield* api.credential(host, input.cwd); const credential = { host, token, credentialFingerprint: key }; return yield* Effect.acquireUseRelease( Effect.sync(() => { @@ -960,7 +960,13 @@ export const make = Effect.gen(function* () { return { ...credential, ...cached.value }; // Pin verification so an auth switch cannot poison its cache entry. const identity = yield* api - .rest({ host, operation: "getRoutingIdentity", path: "user", allowReserve: true }) + .rest({ + cwd: input.cwd, + host, + operation: "getRoutingIdentity", + path: "user", + allowReserve: true, + }) .pipe( Effect.flatMap((response) => decodeRoutingIdentity(response.body).pipe(Effect.mapError(unavailable)), @@ -972,6 +978,7 @@ export const make = Effect.gen(function* () { const { viewer } = yield* client.RoutingIdentityViewer({}).pipe( GitHubGraphQlClient.call({ host, + cwd: input.cwd, operation: "getRoutingIdentity", allowReserve: true, }), @@ -1052,6 +1059,7 @@ export const make = Effect.gen(function* () { effect.pipe( GitHubGraphQlClient.call({ host: input.host, + cwd: input.cwd, operation: input.operation, ...(input.allowReserve === true ? { allowReserve: true } : {}), }), @@ -1191,7 +1199,7 @@ export const make = Effect.gen(function* () { /** A REST read whose JSON answer is decoded the same way. */ const restRead = (input: RestReadInput): Effect.Effect => api - .rest({ host: input.host, operation: input.operation, path: input.path }) + .rest({ cwd: input.cwd, host: input.host, operation: input.operation, path: input.path }) .pipe( Effect.flatMap((response) => decodeWith(input.cwd, input.operation, input.decode, response.body), @@ -1212,11 +1220,12 @@ export const make = Effect.gen(function* () { input: RestReadInput, ) { const host = GitHubRequestPolicy.normalizeHost(input.host); - const { token, fingerprint } = yield* api.credential(host); + const { token, fingerprint } = yield* api.credential(host, input.cwd); const key = `${fingerprint}\0${host}\0${input.path}`; const previous = yield* Cache.get(validators, key); const response = yield* api .rest({ + cwd: input.cwd, host, operation: input.operation, path: input.path, @@ -1253,6 +1262,7 @@ export const make = Effect.gen(function* () { const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; return api .rest({ + cwd: input.cwd, host: input.host, operation: "getPullRequestDiff", path: @@ -1305,6 +1315,7 @@ export const make = Effect.gen(function* () { } const { owner, name } = parseRepositorySelector(input.repository); const refsResponse = yield* api.rest({ + cwd: input.cwd, host: input.host, operation: "getPullRequestDiffFileContents", path: @@ -1341,6 +1352,7 @@ export const make = Effect.gen(function* () { const readFile = (revision: string, filePath: string) => api .rest({ + cwd: input.cwd, host: input.host, operation: "getPullRequestDiffFileContents", accept: "application/vnd.github.raw+json", @@ -1648,17 +1660,29 @@ export const make = Effect.gen(function* () { A, | Exclude | GitHubPullRequestReadError - | GitHubRequestPolicy.GitHubApiNotFoundError, + | GitHubRequestPolicy.GitHubApiError, Exclude > => - effect.pipe( - Effect.provideService(GitHubGraphQlClient.GitHubGraphQlTarget, { - host: input.host, - operation: input.operation, - }), + api.credential(input.host, input.cwd).pipe( + Effect.flatMap(({ token, fingerprint }) => + effect.pipe( + Effect.provideService(GitHubGraphQlClient.GitHubGraphQlTarget, { + host: input.host, + cwd: input.cwd, + operation: input.operation, + }), + Effect.provideService(GitHubRequestPolicy.PinnedGitHubCredential, { + host: GitHubRequestPolicy.normalizeHost(input.host), + token, + credentialFingerprint: fingerprint, + }), + ), + ), Effect.mapError((error) => !isGraphQlClientError(error) - ? (error as Exclude) + ? (error as + | Exclude + | GitHubRequestPolicy.GitHubApiError) : isNotFoundOnly(error) ? new GitHubRequestPolicy.GitHubApiNotFoundError({ host: input.host, @@ -1911,10 +1935,8 @@ export const make = Effect.gen(function* () { }, { concurrency: "unbounded" }, ).pipe( - Effect.provideService(GitHubGraphQlClient.GitHubGraphQlTarget, { - host: input.host, - operation: "listPullRequestStackMemberships", - }), + (effect) => + batchedRead({ ...input, operation: "listPullRequestStackMemberships" }, effect), Effect.map((items) => ({ ...batch, items })), ); }), @@ -2111,6 +2133,7 @@ export const make = Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); return api .rest({ + cwd: input.cwd, host: input.host, operation: "getPullRequestDiff", path: `repos/${owner}/${name}/pulls/${input.number}`, @@ -2313,6 +2336,7 @@ export const make = Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); return api .rest({ + cwd: input.cwd, host: input.host, operation: "setReviewerRequest", method: input.requested ? "POST" : "DELETE", @@ -2340,6 +2364,7 @@ export const make = Effect.gen(function* () { if (input.applied) { return api .rest({ + cwd: input.cwd, host: input.host, operation: "setLabels", method: "POST", @@ -2352,6 +2377,7 @@ export const make = Effect.gen(function* () { input.labels, (label) => api.rest({ + cwd: input.cwd, host: input.host, operation: "setLabels", method: "DELETE", @@ -2523,6 +2549,7 @@ export const make = Effect.gen(function* () { currentRuns.some((current) => current.id === run.id) ? api .rest({ + cwd: input.cwd, host: input.host, operation: "approveWorkflowRun", method: "POST", @@ -2634,6 +2661,7 @@ export const make = Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); return api .rest({ + cwd: input.cwd, host: input.host, operation: "submitReview", method: "POST", diff --git a/packages/source-control-github/src/server/GitHubRequestPolicy.ts b/packages/source-control-github/src/server/GitHubRequestPolicy.ts index a34467aaa4bba..dceaab9f1442c 100644 --- a/packages/source-control-github/src/server/GitHubRequestPolicy.ts +++ b/packages/source-control-github/src/server/GitHubRequestPolicy.ts @@ -199,6 +199,7 @@ export type GitHubAnswer = { ); export interface GitHubRequest { + readonly cwd?: string | undefined; readonly host: string; readonly operation: string; /** The quota the request spends: `core` for REST, `graphql` for GraphQL. */ @@ -227,6 +228,7 @@ export class GitHubRequestPolicy extends Context.Service< /** The credential a request to `host` would carry right now. */ readonly credential: ( host: string, + cwd?: string, ) => Effect.Effect< { readonly token: Redacted.Redacted; readonly fingerprint: string }, GitHubApiError @@ -243,7 +245,7 @@ export const make = Effect.gen(function* () { const credential: GitHubRequestPolicy["Service"]["credential"] = Effect.fn( "GitHubRest.credential", - )(function* (host) { + )(function* (host, cwd) { const normalized = normalizeHost(host); const pinned = yield* PinnedGitHubCredential; if (pinned !== null) { @@ -256,7 +258,7 @@ export const make = Effect.gen(function* () { } return { token: pinned.token, fingerprint: pinned.credentialFingerprint }; } - const held = yield* credentials.get(normalized); + const held = yield* credentials.get(normalized, cwd); return { token: held.token, fingerprint: held.fingerprint }; }); @@ -271,7 +273,7 @@ export const make = Effect.gen(function* () { "github.host": host, "github.operation": input.operation, }); - const { token, fingerprint } = yield* credential(host); + const { token, fingerprint } = yield* credential(host, input.cwd); const scope = yield* SourceControlRateLimit.CredentialScope; // A refusal pauses the whole host, whichever quota it spent: the key PullRequestService // records its own backoff under. @@ -302,7 +304,7 @@ export const make = Effect.gen(function* () { } case "Unauthorized": // The source may hold a newer token than the one that was refused. - yield* credentials.invalidate(host); + yield* credentials.invalidate(host, input.cwd); return yield* new GitHubApiAuthenticationError(context); case "NotFound": return yield* new GitHubApiNotFoundError(context); diff --git a/packages/source-control-github/src/server/GitHubRest.test.ts b/packages/source-control-github/src/server/GitHubRest.test.ts index 6c3f653120ee9..5f298918cd24d 100644 --- a/packages/source-control-github/src/server/GitHubRest.test.ts +++ b/packages/source-control-github/src/server/GitHubRest.test.ts @@ -16,6 +16,7 @@ import * as GitHubQuota from "./GitHubQuota.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; +import * as HostProcess from "@t3tools/shared/HostProcess"; const NOW = Date.parse("2026-10-05T12:00:00.000Z"); @@ -89,6 +90,56 @@ describe("environmentToken", () => { }); describe("GitHubRest", () => { + it.effect("sends repository-selected gh tokens on both reads and writes", () => { + const authorization: Array = []; + const layer = GitHubRest.layerWithDependencies.pipe( + Layer.provide( + Layer.mergeAll( + NodeCrypto.layer, + TestSourceControlHost.layer({ + git: { readConfigValue: (cwd) => Effect.succeed(cwd === "/work" ? "work" : null) }, + process: { + run: (input) => + Effect.succeed( + TestSourceControlHost.processOutput( + input.args.includes("--user") ? "work-token" : "personal-token", + ), + ), + }, + }), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + authorization.push(request.headers.authorization); + return Effect.succeed(HttpClientResponse.fromWeb(request, json({ ok: true }))); + }), + ), + ), + ), + Layer.provide(Layer.succeed(HostProcess.Environment, {})), + ); + return Effect.gen(function* () { + const api = yield* GitHubRest.GitHubRest; + for (const cwd of ["/personal", "/work"]) { + for (const method of ["GET", "POST"] as const) { + yield* api.rest({ + cwd, + host: "github.com", + operation: "pullRequests", + method, + path: "repos/acme/web/pulls", + }); + } + } + expect(authorization).toEqual([ + "Bearer personal-token", + "Bearer personal-token", + "Bearer work-token", + "Bearer work-token", + ]); + }).pipe(Effect.provide(layer)); + }); + it.effect("traces the operation, path and quota, never the query string or the token", () => { const spans: Array = []; const tracer = Tracer.make({ diff --git a/packages/source-control-github/src/server/GitHubRest.ts b/packages/source-control-github/src/server/GitHubRest.ts index 6ed65eebabf77..f6cc0cbdccd2d 100644 --- a/packages/source-control-github/src/server/GitHubRest.ts +++ b/packages/source-control-github/src/server/GitHubRest.ts @@ -27,6 +27,7 @@ export interface GitHubRestResponse { } export interface GitHubRestInput { + readonly cwd?: string | undefined; readonly host: string; readonly operation: string; readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; @@ -51,6 +52,7 @@ export class GitHubRest extends Context.Service< /** The credential a request to `host` would carry right now. */ readonly credential: ( host: string, + cwd?: string, ) => Effect.Effect< { readonly token: Redacted.Redacted; readonly fingerprint: string }, GitHubApiError @@ -127,6 +129,7 @@ export const make = Effect.gen(function* () { */ const send = Effect.fn("GitHubRest.send")(function* (input: { readonly host: string; + readonly cwd?: string | undefined; readonly operation: string; readonly request: HttpClientRequest.HttpClientRequest; readonly maxResponseBytes: number; @@ -144,6 +147,7 @@ export const make = Effect.gen(function* () { return yield* policy.request( { host, + cwd: input.cwd, operation: input.operation, // Every REST path this reads today spends `core`; a `search/` read would need its own // resource here. @@ -209,6 +213,7 @@ export const make = Effect.gen(function* () { Effect.flatMap((interactive) => send({ host: input.host, + cwd: input.cwd, operation: input.operation, request, maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index a8dfbabf7d282..7a2d14b22ae58 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -86,6 +86,7 @@ const node = (number: number, headRefName: string, owner = "acme") => ({ */ interface GraphQlCall { readonly host: string; + readonly authorization: string | undefined; readonly operationName: string; readonly query: string; readonly lookups: ReadonlyArray>>; @@ -128,6 +129,7 @@ function graphQlHttp(respond: GraphQlRespond, calls: Array) { ); const call: GraphQlCall = { host: new URL(request.url).hostname.replace(/^api\./, ""), + authorization: request.headers["authorization"], operationName: sent.operationName, query: sent.query, lookups, @@ -165,6 +167,7 @@ function harness(input: { /** The credential a GraphQL read is sent with; github.com's token by default. */ readonly credential?: ( host: string, + cwd?: string, ) => Effect.Effect< GitHubCredentials.GitHubCredential, GitHubCredentials.GitHubCredentialUnavailableError @@ -361,6 +364,30 @@ describe("GitHubSourceControlProvider change request lookups by head", () => { }); describe("GitHubSourceControlProvider.getChangeRequest", () => { + it.effect("reads URL-only PRs with the account of the supplied project checkout", () => { + const { layer, graphqlCalls } = harness({ + remotes: "", + credential: (host, cwd) => + Effect.succeed({ + host, + token: Redacted.make(cwd === "/work" ? "work" : "personal"), + source: "gh", + fingerprint: `${host}:${cwd}`, + }), + graphql: () => ({ pullRequest: node(42, "feature") }), + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + for (const cwd of ["/personal", "/work"]) { + yield* gh.getChangeRequest({ cwd, reference: "https://github.com/acme/web/pull/42" }); + } + assert.deepStrictEqual( + graphqlCalls.map((call) => call.authorization), + ["Bearer personal", "Bearer work"], + ); + }).pipe(Effect.provide(layer)); + }); + it.effect("reads a pull request by number, and by URL on its own repository", () => { const { layer, graphqlCalls } = harness({ remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index b4c2b5107fe06..13fad95123e88 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -283,11 +283,11 @@ export const makeDiscovery = Effect.gen(function* () { * A host turned off in Settings is claimed too, so its error says so * instead of "unsupported host". */ - refineUnknownRemote: ({ context }) => { + refineUnknownRemote: ({ cwd, context }) => { const url = URL.parse(context.provider.baseUrl); if (!url?.host) return Effect.succeed(null); // A timeout or keyring failure propagates: the registry does not keep that answer. - return api.credential(url.host).pipe( + return api.credential(url.host, cwd).pipe( Effect.as(true), Effect.catchTags({ GitHubCliMissingError: () => Effect.succeed(false), @@ -450,6 +450,7 @@ const contextHost = (context: SourceControlProvider.SourceControlProviderContext export const make = Effect.gen(function* () { const api = yield* GitHubRest.GitHubRest; + const policy = yield* GitHubRequestPolicy.GitHubRequestPolicy; const sourceControlHost = yield* SourceControlHost.SourceControlHost; const process = sourceControlHost.process; const environment = yield* HostProcess.Environment; @@ -519,8 +520,17 @@ export const make = Effect.gen(function* () { decodeDetail: string, effect: Effect.Effect, ) => - effect.pipe( - GitHubGraphQlClient.call(target), + policy.credential(target.host, target.cwd).pipe( + Effect.flatMap(({ token, fingerprint }) => + effect.pipe( + GitHubGraphQlClient.call(target), + Effect.provideService(GitHubRequestPolicy.PinnedGitHubCredential, { + host: GitHubRequestPolicy.normalizeHost(target.host), + token, + credentialFingerprint: fingerprint, + }), + ), + ), Effect.mapError((error) => GraphQLClientError.isGraphQLClientError(error) ? failure(decodeDetail, error) @@ -565,6 +575,7 @@ export const make = Effect.gen(function* () { const result = yield* graphqlRead( { host: locator.host, + cwd: input.cwd, operation: "listPullRequestsByHead", allowReserve: input.allowReserve, // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred @@ -624,7 +635,7 @@ export const make = Effect.gen(function* () { const locator = parsed.kind === "url" ? parsed.locator : yield* resolveRepository(input); const decodeDetail = "GitHub returned an invalid pull request."; const result = yield* graphqlRead( - { host: locator.host, operation: "getPullRequest", allowReserve: true }, + { host: locator.host, cwd: input.cwd, operation: "getPullRequest", allowReserve: true }, decodeDetail, client.PullRequestByNumber({ owner: locator.owner, @@ -643,10 +654,12 @@ export const make = Effect.gen(function* () { const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* ( locator: GitHubRepositoryLocator, + cwd: string, ) { const response = yield* rest( { host: locator.host, + cwd, operation: "getRepository", path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, allowReserve: true, @@ -662,9 +675,11 @@ export const make = Effect.gen(function* () { const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( host: string, + cwd: string, ) { const response = yield* rest({ host, + cwd, operation: "getViewer", path: "user", allowReserve: true, @@ -729,7 +744,7 @@ export const make = Effect.gen(function* () { // prefix. Without the default branch that collision cannot be ruled out, and the checkout // would reset the local default branch to the fork's commit, so it fails instead. const defaultBranch = isCrossRepository - ? yield* readRepository(base).pipe( + ? yield* readRepository(base, input.cwd).pipe( Effect.map((repository) => repository.default_branch ?? null), ) : null; @@ -746,7 +761,10 @@ export const make = Effect.gen(function* () { const known = remoteFor(headNameWithOwner); if (known !== null) return known; const [owner, name] = headNameWithOwner.split("/"); - const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); + const fork = yield* readRepository( + { host: base.host, owner: owner!, name: name! }, + input.cwd, + ); const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); return yield* git.ensureRemote({ cwd: input.cwd, @@ -848,6 +866,7 @@ export const make = Effect.gen(function* () { const result = yield* api .rest({ host: input.url.host, + cwd: input.cwd, operation: "resolveLink", path: endpoint, maxResponseBytes: 1_000_000, @@ -939,6 +958,7 @@ export const make = Effect.gen(function* () { ); yield* rest({ host: locator.host, + cwd: input.cwd, operation: "createPullRequest", method: "POST", allowReserve: true, @@ -971,10 +991,14 @@ export const make = Effect.gen(function* () { const locator = parseGitHubRepositorySelector(input.repository, fallbackHost) ?? (/^[^/\s]+$/.test(bareName) - ? { host: fallbackHost, owner: yield* readViewerLogin(fallbackHost), name: bareName } + ? { + host: fallbackHost, + owner: yield* readViewerLogin(fallbackHost, input.cwd), + name: bareName, + } : null); if (locator === null) return yield* failure("Repositories are named owner/name."); - return repositoryCloneUrls(yield* readRepository(locator)); + return repositoryCloneUrls(yield* readRepository(locator, input.cwd)); }, (effect, input) => effect.pipe( @@ -989,7 +1013,7 @@ export const make = Effect.gen(function* () { input.repository, (environment.GH_HOST ?? "github.com").toLowerCase(), ); - const viewer = locator === null ? null : yield* readViewerLogin(locator.host); + const viewer = locator === null ? null : yield* readViewerLogin(locator.host, input.cwd); const owner = locator?.owner ?? viewer; const name = locator?.name ?? input.repository.trim(); const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); @@ -997,6 +1021,7 @@ export const make = Effect.gen(function* () { const response = yield* rest( { host, + cwd: input.cwd, operation: "createRepository", method: "POST", allowReserve: true, @@ -1027,7 +1052,7 @@ export const make = Effect.gen(function* () { host: contextHost(input.context), context: input.context, }); - const repository = yield* readRepository(locator); + const repository = yield* readRepository(locator, input.cwd); const branch = repository.default_branch?.trim() ?? ""; return branch.length > 0 ? branch : null; }, diff --git a/packages/source-control-github/src/server/gitHubGraphQlClient.ts b/packages/source-control-github/src/server/gitHubGraphQlClient.ts index 427369ea510d9..cc5024e7ae0b7 100644 --- a/packages/source-control-github/src/server/gitHubGraphQlClient.ts +++ b/packages/source-control-github/src/server/gitHubGraphQlClient.ts @@ -22,6 +22,7 @@ export class GitHubGraphQlTarget extends Context.Service< GitHubGraphQlTarget, { readonly host: string; + readonly cwd?: string | undefined; readonly operation: string; /** Overrides `SourceControlRateLimit.Interactive` for this one call. */ readonly allowReserve?: boolean | undefined; @@ -74,6 +75,7 @@ export class GitHubGraphQlAuth extends GraphQLMiddleware.Service< return yield* policy.request( { host, + cwd: target.cwd, operation: target.operation, resource: "graphql", allowReserve: target.allowReserve ?? (yield* SourceControlRateLimit.Interactive), diff --git a/packages/source-control-github/src/server/githubStackActions.ts b/packages/source-control-github/src/server/githubStackActions.ts index 1f2e52b4ff430..53df16535647c 100644 --- a/packages/source-control-github/src/server/githubStackActions.ts +++ b/packages/source-control-github/src/server/githubStackActions.ts @@ -129,6 +129,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* const endpoint = `repos/${input.repository}`; const read = yield* api.rest({ host: input.host, + cwd: input.cwd, operation: "runGitHubStackAction", path: `${endpoint}/stacks?pull_request=${input.number}`, }); @@ -183,6 +184,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* ).pipe( GitHubGraphQlClient.call({ host: input.host, + cwd: input.cwd, operation: "runGitHubStackAction", allowReserve: true, }), @@ -207,6 +209,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* // copy of every lower layer into the one above it. The cascade moves each layer's own commits. yield* cascadeRebaseStack({ host: input.host, + cwd: input.cwd, repository: input.repository, base: stack.base, layers: open.map((layer) => ({ @@ -237,6 +240,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* ); const request = yield* api.rest({ host: input.host, + cwd: input.cwd, operation: "runGitHubStackAction", method: "PUT", path: `${endpoint}/pulls/${input.number}/merge-async`, @@ -258,6 +262,7 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); const poll = yield* api.rest({ host: input.host, + cwd: input.cwd, operation: "runGitHubStackAction", path: `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, }); diff --git a/packages/source-control-github/src/server/githubStackRebase.ts b/packages/source-control-github/src/server/githubStackRebase.ts index 2b854dec12e40..75fc7ff3f2ad3 100644 --- a/packages/source-control-github/src/server/githubStackRebase.ts +++ b/packages/source-control-github/src/server/githubStackRebase.ts @@ -37,6 +37,7 @@ function remoteUrl(host: string, repository: string): string { export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (input: { readonly host: string; + readonly cwd?: string; readonly repository: string; readonly base: string; readonly layers: ReadonlyArray; @@ -45,7 +46,7 @@ export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (inp const api = yield* GitHubRest.GitHubRest; const { process } = yield* SourceControlHost.SourceControlHost; const fileSystem = yield* FileSystem.FileSystem; - const { token } = yield* api.credential(input.host); + const { token } = yield* api.credential(input.host, input.cwd); const first = input.layers[0]; if (first === undefined) return 0; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-stack-rebase-" }).pipe(