From c70a6ae4205b600e7770a74b67af4dacedf8dcad Mon Sep 17 00:00:00 2001 From: Ulugh Beck Date: Wed, 7 Oct 2026 22:39:52 +0500 Subject: [PATCH 1/3] feat(server,web): a project can use its own GitHub account Per-host account choice cannot serve a personal and a work project on the same host. A project's githubAccount setting picks the gh login T3 Code uses for its pull requests and for the pull requests it creates; viewers, searches and batched reads are grouped by account. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/git/GitManager.ts | 19 +- .../src/pullRequest/GitHubPullRequestApi.ts | 4 + .../pullRequest/PullRequestService.test.ts | 64 +++++++ .../src/pullRequest/PullRequestService.ts | 169 ++++++++++++------ .../src/sourceControl/GitHubApi.test.ts | 54 ++++++ apps/server/src/sourceControl/GitHubApi.ts | 19 +- .../sourceControl/GitHubCredentials.test.ts | 17 ++ .../src/sourceControl/GitHubCredentials.ts | 19 +- .../GitHubSourceControlProvider.ts | 1 + .../gitHubProjectAccount.test.ts | 85 +++++++++ .../src/sourceControl/gitHubProjectAccount.ts | 83 +++++++++ .../pullRequest/pullRequestList.logic.test.ts | 21 +++ .../pullRequest/pullRequestList.logic.ts | 11 +- .../settings/ProjectDefaultsSettings.tsx | 68 +++++++ .../src/components/settings/settingsSearch.ts | 7 + docs/user/source-control.md | 6 + packages/contracts/src/pullRequest.ts | 3 +- packages/contracts/src/settings.ts | 11 ++ 18 files changed, 590 insertions(+), 71 deletions(-) create mode 100644 apps/server/src/sourceControl/gitHubProjectAccount.test.ts create mode 100644 apps/server/src/sourceControl/gitHubProjectAccount.ts diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 593910ba9a4a..68499a084acd 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -82,6 +82,7 @@ import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import { makeGitHubProjectAccount } from "../sourceControl/gitHubProjectAccount.ts"; import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts"; import type { ChangeRequest } from "@t3tools/contracts"; @@ -2923,18 +2924,24 @@ export const make = Effect.gen(function* () { }, ); + // Everything that reaches the host runs as the checkout's project account. + const { actAs } = yield* makeGitHubProjectAccount; return GitManager.of({ createWorktree, localStatus, - remoteStatus, - status, - branchPullRequest, + remoteStatus: (input, options) => actAs(input, remoteStatus(input, options)), + status: (input) => actAs(input, status(input)), + branchPullRequest: (input, options) => actAs(input, branchPullRequest(input, options)), invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, - resolvePullRequest, - preparePullRequestThread, - runStackedAction, + resolvePullRequest: (input) => actAs(input, resolvePullRequest(input)), + preparePullRequestThread: (input) => actAs(input, preparePullRequestThread(input)), + runStackedAction: (input, options) => + actAs( + { cwd: input.cwd, projectId: input.projectId ?? null }, + runStackedAction(input, options), + ), subscribePullRequestStateChanges: PubSub.subscribe(pullRequestStateChanges).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts index 5e3572915460..1f145a9814f5 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -1753,6 +1753,8 @@ export const make = Effect.gen(function* () { Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) ?.credentialFingerprint ?? null, Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + // A project with its own account is read with its own token, never batched with others. + Context.getOrElse(context, GitHubApi.GitHubAccount, () => null), ]), resolver: (entries) => { const [first] = entries; @@ -1833,6 +1835,8 @@ export const make = Effect.gen(function* () { Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) ?.credentialFingerprint ?? null, Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + // A project with its own account is read with its own token, never batched with others. + Context.getOrElse(context, GitHubApi.GitHubAccount, () => null), ]), resolver: (entries) => { const [first] = entries; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index c2a46ed88978..760d4658e012 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as KeyValueStore from "effect/persistence/KeyValueStore"; import { assert, it } from "@effect/vitest"; +import { GitHubAccount } from "../sourceControl/GitHubApi.ts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; @@ -1279,6 +1280,69 @@ it.effect("tries another workspace on the same host for the viewer", () => }), ); +it.effect("runs a project with its own GitHub account as that account, apart from the rest", () => + Effect.gen(function* () { + const searches: Array<{ + readonly viewer: string; + readonly account: string | null; + readonly repositories: ReadonlyArray; + }> = []; + const statReads: Array<{ readonly account: string | null; readonly count: number }> = []; + const service = yield* makeService({ + settings: { + ...DEFAULT_SERVER_SETTINGS, + projectSettingsOverrides: { ["w1" as ProjectId]: { githubAccount: "work" } }, + }, + projects: [ + project({ id: "p1", title: "personal", workspaceRoot: "/p1", repository: "me/one" }), + project({ id: "p2", title: "other", workspaceRoot: "/p2", repository: "me/two" }), + project({ id: "w1", title: "work", workspaceRoot: "/w1", repository: "Acme/web" }), + ], + providers: [ + fakeProvider("github", { + getViewer: () => + Effect.map(GitHubAccount, (account) => (account === null ? "personal" : account)), + listChangeRequestsAcross: (input) => + Effect.map(GitHubAccount, (account) => { + searches.push({ viewer: input.viewer, account, repositories: input.repositories }); + return { + items: input.repositories.map((repository, index) => + batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"), + ), + truncated: false, + }; + }), + listChangeRequestStats: (input) => + Effect.map(GitHubAccount, (account) => { + statReads.push({ account, count: input.changeRequests.length }); + return input.changeRequests.map((ref) => ({ ...ref, additions: 1, deletions: 1 })); + }), + }), + ], + }); + const result = yield* service.list({ state: "open", involvement: "all" }); + assert.strictEqual(result.entries.length, 3); + assert.sameDeepMembers(searches, [ + { viewer: "personal", account: null, repositories: ["me/one", "me/two"] }, + { viewer: "work", account: "work", repositories: ["Acme/web"] }, + ]); + assert.strictEqual(result.viewers["github.com"], "personal"); + assert.strictEqual(result.viewers["project:w1"], "work"); + + yield* service.listStats({ + refs: result.entries.map(({ projectId, repository, number }) => ({ + projectId, + repository, + number, + })), + }); + assert.sameDeepMembers(statReads, [ + { account: null, count: 2 }, + { account: "work", count: 1 }, + ]); + }), +); + it.effect("routing verifies the current account on the requested host without caching it", () => Effect.gen(function* () { let viewer = "first-account"; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a44bd322adcb..f03c5293fb86 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -77,7 +77,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; +import { AllowGitHubReserve, GitHubAccount } from "../sourceControl/GitHubApi.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; @@ -365,6 +365,12 @@ export interface SupportedProject { readonly repository: string; /** The host the repository lives on, which is the account boundary rather than the kind. */ readonly host: string; + /** + * The GitHub login the project's settings choose (`githubAccount`), or null for the host's own. + * Every provider call for the project runs as it; viewers and batched reads are per host and + * account, so one request never mixes two. + */ + readonly account: string | null; /** * The identity's canonical key, which is what this environment's own records are keyed by. * Unique where `repository` is not: Azure's is a bare name that repeats across an organisation. @@ -531,11 +537,15 @@ function toPullRequestError( }); } +type VerifiedIdentity = Parameters< + Parameters>[1] +>[0]; + function withRateLimitBackoff( api: PullRequestProviderApi, host: string, limits: SourceControlRateLimit.SourceControlRateLimit["Service"], - options?: { readonly viewerAllowsPause: boolean }, + options?: { readonly viewerAllowsPause?: boolean; readonly account?: string | null }, ): PullRequestProviderApi { const key = { provider: api.kind, host }; const protect = ( @@ -571,6 +581,11 @@ function withRateLimitBackoff( ), ), ); + // A project with an account of its own acts as it on every call. Projects without one carry + // nothing, so their reads keep batching across repositories. + const account = options?.account ?? null; + const asAccount = (effect: Effect.Effect) => + account === null ? effect : effect.pipe(Effect.provideService(GitHubAccount, account)); const wrap = , A>( operation: string, @@ -578,12 +593,13 @@ function withRateLimitBackoff( allowPaused = false, ) => (...args: Args) => - protect(operation, call(...args), allowPaused); + asAccount(protect(operation, call(...args), allowPaused)); const interactive = , A>( operation: string, call: (...args: Args) => Effect.Effect, ) => wrap(operation, call, true); + const { getRoutingIdentity, withVerifiedCredential } = api; const wrapped = { kind: api.kind, capabilities: api.capabilities, @@ -594,10 +610,20 @@ function withRateLimitBackoff( options?.viewerAllowsPause === true ? interactive("getViewer", api.getViewer) : wrap("getViewer", api.getViewer), - ...(api.getRoutingIdentity === undefined ? {} : { getRoutingIdentity: api.getRoutingIdentity }), - ...(api.withVerifiedCredential === undefined + ...(getRoutingIdentity === undefined + ? {} + : { + getRoutingIdentity: (input: Parameters[0]) => + asAccount(getRoutingIdentity(input)), + }), + ...(withVerifiedCredential === undefined ? {} - : { withVerifiedCredential: api.withVerifiedCredential }), + : { + withVerifiedCredential: ( + input: Parameters[0], + use: (identity: VerifiedIdentity) => Effect.Effect, + ) => asAccount(withVerifiedCredential(input, use)), + }), listChangeRequests: wrap("listChangeRequests", api.listChangeRequests), ...(api.listChangeRequestsAcross === undefined ? {} @@ -804,11 +830,15 @@ export const make = Effect.gen(function* () { ), ), Effect.flatMap((projects) => - refineUnknownProjectKinds(projects, filter).pipe( - Effect.map((refinedProviders) => ({ refinedProviders, projects })), + Effect.all([ + refineUnknownProjectKinds(projects, filter), + // Unreadable settings leave every project on its host's account. + serverSettings.getSettings.pipe(Effect.orElseSucceed(() => null)), + ]).pipe( + Effect.map(([refinedProviders, settings]) => ({ refinedProviders, projects, settings })), ), ), - Effect.map(({ refinedProviders, projects }) => { + Effect.map(({ refinedProviders, projects, settings }) => { const supported: SupportedProject[] = []; const unimplemented = new Map< string, @@ -862,12 +892,17 @@ export const make = Effect.gen(function* () { else counted.projectCount += 1; continue; } + const account = + kind === "github" && settings !== null + ? resolveProjectSettings(settings, project.id).settings.githubAccount + : null; supported.push({ cursorKey: key, project, - api: withRateLimitBackoff(api, host, rateLimits), + api: withRateLimitBackoff(api, host, rateLimits, { account }), repository, host, + account, remote: kind === "azure-devops" ? identity.canonicalKey @@ -1019,8 +1054,12 @@ export const make = Effect.gen(function* () { * Its failure doubles as the answer to "is this host set up", which is what the provider * switcher shows. */ + const viewerScope = (input: { readonly host: string; readonly account: string | null }) => + input.account === null ? input.host : `${input.host}\u0000${input.account}`; type ResolvedViewer = { readonly host: string; + /** The account it was resolved for, or null for the host's own (see `SupportedProject`). */ + readonly account: string | null; readonly kind: SourceControlProviderKind; readonly viewer: string | null; readonly error: PullRequestProviderError | null; @@ -1030,13 +1069,17 @@ export const make = Effect.gen(function* () { // per read, three reads per page. Only a success is believed for a while: a failure is the // "is this host set up" answer the provider switcher shows, and holding it would keep saying // signed-out after the reader has signed in. - const viewersByHost = new Map(); + const viewersByScope = new Map< + string, + { readonly at: number; readonly result: ResolvedViewer } + >(); const viewerFlights = yield* Cache.makeWith( (key: string): Effect.Effect => { - const [host, kind, roots] = JSON.parse(key) as [ + const [host, kind, roots, account] = JSON.parse(key) as [ string, SourceControlProviderKind, ReadonlyArray, + string | null, ]; const registered = registry.get(kind); if (registered === null) { @@ -1045,20 +1088,27 @@ export const make = Effect.gen(function* () { // Let through a pause: a press the reader is waiting on has to be answered, and the // callers that are not that press are held back at the gate below instead, before they // reach this lookup at all. - const api = withRateLimitBackoff(registered, host, rateLimits, { viewerAllowsPause: true }); + const api = withRateLimitBackoff(registered, host, rateLimits, { + viewerAllowsPause: true, + account, + }); return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd, host }))).pipe( Effect.map((viewer) => ({ host, + account, kind, viewer: viewer as string | null, error: null as PullRequestProviderError | null, })), Effect.tap((result) => - Effect.map(Clock.currentTimeMillis, (at) => viewersByHost.set(host, { at, result })), + Effect.map(Clock.currentTimeMillis, (at) => + viewersByScope.set(viewerScope({ host, account }), { at, result }), + ), ), Effect.catch((error) => Effect.succeed({ host, + account, kind, viewer: null, error, @@ -1075,21 +1125,24 @@ export const make = Effect.gen(function* () { }, ); + /** One viewer per host and account (see `SupportedProject.account`). */ const resolveViewers = ( projects: ReadonlyArray, viewerRoots: WorkspaceProjects["viewerRoots"], options?: { readonly allowPaused: boolean }, ) => Effect.forEach( - [...new Set(projects.map(({ host }) => host))], - (host) => + [...new Set(projects.map(viewerScope))], + (scope) => Effect.flatMap(Clock.currentTimeMillis, (now): Effect.Effect => { - const held = viewersByHost.get(host); + const held = viewersByScope.get(scope); if (held !== undefined && now - held.at <= Duration.toMillis(VIEWER_CACHE_TTL)) { return Effect.succeed(held.result); } + const { api, host, account } = projects.find( + (project) => viewerScope(project) === scope, + )!; const forHost = projects.filter((project) => project.host === host); - const api = forHost[0]!.api; // Every checkout on the host, not just the ones that survived de-duplication: one // unreadable worktree would otherwise report the whole host as signed out. const roots = @@ -1097,7 +1150,7 @@ export const make = Effect.gen(function* () { // Nothing about the caller is in the key. A listing and a press for the same host and // roots are the same lookup, and putting them on separate flights would spawn two of // this host's CLIs on a cold page load, which is the coalescing this exists for. - const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]); + const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort(), account]); if (options?.allowPaused === true) return Cache.get(viewerFlights, key); // The pause is checked here rather than inside the lookup, so that it holds back the // callers nobody is waiting on without splitting the flight they share with a press. @@ -1108,6 +1161,7 @@ export const make = Effect.gen(function* () { Effect.catch((error) => Effect.succeed({ host, + account, kind: api.kind, viewer: null, error: new PullRequestProviderError({ @@ -1228,24 +1282,43 @@ export const make = Effect.gen(function* () { } const viewerResults = yield* resolveViewers(projects, viewerRoots); + const viewerByScope = new Map( + viewerResults.flatMap((result) => + result.viewer === null ? [] : [[viewerScope(result), result.viewer] as const], + ), + ); + const viewerOfProject = (project: SupportedProject) => + viewerByScope.get(viewerScope(project)); + // A project with an account of its own is keyed `project:`, which the page reads + // before the host's own key. const viewers: Record = {}; for (const result of viewerResults) { - if (result.viewer !== null) viewers[result.host] = result.viewer; + if (result.viewer !== null && result.account === null) viewers[result.host] = result.viewer; + } + for (const project of projects) { + const viewer = viewerOfProject(project); + if (project.account !== null && viewer !== undefined) { + viewers[`project:${project.project.id}`] = viewer; + } } - // One summary per host, which is what the viewer lookup already answers for: two GitHub - // hosts sign in separately, so collapsing them by kind would report one as the other. + // One summary per host: two GitHub hosts sign in separately, so collapsing them by kind + // would report one as the other. A host is configured when any of its accounts answered. + const hostResults = Map.groupBy(viewerResults, (result) => result.host); const providers: ReadonlyArray = [ - ...viewerResults.map((result) => ({ - host: result.host, - kind: result.kind, - searchesOnHost: - projects.find((project) => project.host === result.host)?.api.capabilities.search ?? - false, - projectCount: projectCounts.get(result.host) ?? 1, - configured: result.viewer !== null, - detail: result.error === null ? null : providerDetail(result.error), - })), + ...[...hostResults].map(([host, results]) => { + const configured = results.some((result) => result.viewer !== null); + const shown = results.find((result) => result.account === null) ?? results[0]!; + return { + host, + kind: shown.kind, + searchesOnHost: + projects.find((project) => project.host === host)?.api.capabilities.search ?? false, + projectCount: projectCounts.get(host) ?? 1, + configured, + detail: configured || shown.error === null ? null : providerDetail(shown.error), + }; + }), ...[...unimplemented].map(([host, { kind, projectCount }]) => ({ host, kind, @@ -1264,11 +1337,11 @@ export const make = Effect.gen(function* () { continuation === null ? projects : projects.filter(({ cursorKey }) => continuation.has(cursorKey)); - const readable = selected.filter(({ host }) => viewers[host] !== undefined); + const readable = selected.filter((project) => viewerOfProject(project) !== undefined); // A host that could not be read still has projects, and they are absent from the list. // Reporting them keeps "N repositories were unavailable" honest instead of dropping them. const unreadable = selected - .filter(({ host }) => viewers[host] === undefined) + .filter((project) => viewerOfProject(project) === undefined) .map(({ project, repository }) => ({ projectId: project.id, projectTitle: project.title, @@ -1313,7 +1386,7 @@ export const make = Effect.gen(function* () { */ const readRepository = (project: SupportedProject): Effect.Effect => { { - const viewer = viewers[project.host]!; + const viewer = viewerOfProject(project)!; const key = project.cursorKey; const cursor = cursorOf(project); return project.api @@ -1400,7 +1473,7 @@ export const make = Effect.gen(function* () { const separately = () => Effect.forEach(chunk, readRepository, { concurrency: REPOSITORY_CONCURRENCY }); if (readAcross === undefined) return separately(); - const viewer = viewers[first.host]!; + const viewer = viewerOfProject(first)!; const cursor = cursorOf(first); return readAcross({ cwd: first.project.workspaceRoot, @@ -1496,8 +1569,8 @@ export const make = Effect.gen(function* () { }; // A host with a search across repositories is asked once for all of them; everyone else is - // asked once each. Repositories standing at different points of the same listing are - // different questions, so they are grouped by the boundary they carry on from. + // asked once each. Repositories served by different accounts, or standing at different + // points of the same listing, are different questions, so they are grouped apart. const together = new Map>(); const separate: Array = []; for (const project of readable) { @@ -1505,7 +1578,7 @@ export const make = Effect.gen(function* () { separate.push(project); continue; } - const key = `${project.host}\n${cursorOf(project)?.updatedBefore ?? ""}`; + const key = `${viewerScope(project)}\n${cursorOf(project)?.updatedBefore ?? ""}`; const group = together.get(key); if (group === undefined) together.set(key, [project]); else group.push(project); @@ -1584,7 +1657,7 @@ export const make = Effect.gen(function* () { detail: "The GitHub account could not be verified before starting the operation.", }); const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); - const api = project.api.kind === "github" ? registry.get("github") : null; + const api = project.api.kind === "github" ? project.api : null; if ( api?.withVerifiedCredential === undefined || input.host?.toLowerCase() !== project.host.toLowerCase() @@ -1605,7 +1678,7 @@ export const make = Effect.gen(function* () { const routing = Effect.fn("PullRequestService.routing")(function* (input: PullRequestRef) { const project = yield* requireProject(input); - const api = project.api.kind === "github" ? registry.get("github") : null; + const api = project.api.kind === "github" ? project.api : null; if (api?.getRoutingIdentity === undefined) { return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" }); } @@ -2539,7 +2612,7 @@ export const make = Effect.gen(function* () { * The line counts for rows already on the page, which the listing left out because on GitHub * they cost more than everything else on the row put together. * - * One read per host rather than per row, and only for a host whose listing defers them; a row + * One read per host and account rather than per row, and only for a host whose listing defers them; a row * whose host answered with the counts in the first place is not here to be asked about. A ref * that names no project this workspace has, or a repository that is not the one the project's * remote points at, is dropped rather than refused: it is one row's two numbers, and the page @@ -2567,14 +2640,10 @@ export const make = Effect.gen(function* () { } wanted.set(`${project.project.id} ${ref.number}`, { project, number: ref.number }); } - const byHost = new Map>(); - for (const entry of wanted.values()) { - const held = byHost.get(entry.project.host); - if (held === undefined) byHost.set(entry.project.host, [entry]); - else held.push(entry); - } + // One read per host and serving account: a batch is sent with a single token. + const byScope = Map.groupBy(wanted.values(), (entry) => viewerScope(entry.project)); const stats = yield* Effect.forEach( - [...byHost.values()], + [...byScope.values()], (entries) => { const first = entries[0]!; const readStats = first.project.api.listChangeRequestStats; @@ -3320,7 +3389,7 @@ export const make = Effect.gen(function* () { } else { listingsEpoch = ++epochCounter; everyFileRevisionEpoch = ++epochCounter; - viewersByHost.clear(); + viewersByScope.clear(); yield* Cache.invalidateAll(viewerFlights); } if (options?.notifyReaders) { diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index 60e2c6f1b45d..71aaad9274ec 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -523,3 +523,57 @@ describe("GitHubCredentials", () => { ), ); }); + +describe("GitHubApi accounts", () => { + it.effect("sends every request under GitHubAccount with that account's token", () => { + const requests: Array = []; + const invalidated: Array = []; + const credentials = Layer.succeed( + GitHubCredentials.GitHubCredentials, + GitHubCredentials.GitHubCredentials.of({ + get: (host, account) => + Effect.succeed({ + host, + token: Redacted.make(account ?? "host"), + source: "gh" as const, + fingerprint: `${host}:${account ?? "host"}`, + }), + invalidate: (_host, account) => Effect.sync(() => void invalidated.push(account)), + }), + ); + const http = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url.endsWith("/refused") ? json({}, { status: 401 }) : json({}), + ), + ); + }), + ); + const layer = GitHubApi.layer.pipe( + Layer.provide(Layer.mergeAll(credentials, http)), + Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(SourceControlRateLimit.layer), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + yield* api.rest({ host: "github.com", operation: "read", path: "repos/acme/web" }); + const asWork = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(GitHubApi.GitHubAccount, "work")); + yield* asWork(api.rest({ host: "github.com", operation: "read", path: "repos/acme/web" })); + yield* asWork( + api.rest({ host: "github.com", operation: "read", path: "refused" }).pipe(Effect.flip), + ); + expect(requests.map((request) => request.headers.authorization)).toEqual([ + "Bearer host", + "Bearer work", + "Bearer work", + ]); + expect(invalidated).toEqual(["work"]); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 5f29e8b4053d..90106a92bb3e 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -32,6 +32,15 @@ export const PinnedGitHubCredential = Context.Reference<{ readonly credentialFingerprint: string; } | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); +/** + * The `gh` login the current operation acts as, when its project has one of its own. Every + * request made under it, including node-id writes that name no repository, uses that account. + * Unset, requests use the account Settings choose for the host. + */ +export const GitHubAccount = Context.Reference("t3/sourceControl/GitHubAccount", { + 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 @@ -154,7 +163,7 @@ export class GitHubApi extends Context.Service< /** 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. */ + /** The credential a request to `host` would carry right now, under `GitHubAccount`. */ readonly credential: ( host: string, ) => Effect.Effect< @@ -362,7 +371,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, yield* GitHubAccount); return { token: held.token, fingerprint: held.fingerprint }; }, ); @@ -489,9 +498,9 @@ export const make = Effect.gen(function* () { }), // 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)))), + GitHubAccount.pipe( + Effect.flatMap((account) => credentials.invalidate(host, account)), + ).pipe(Effect.andThen(Effect.fail(new GitHubApiAuthenticationError(context)))), NotFound: () => Effect.fail(new GitHubApiNotFoundError(context)), Failed: ({ messages }) => Effect.fail( diff --git a/apps/server/src/sourceControl/GitHubCredentials.test.ts b/apps/server/src/sourceControl/GitHubCredentials.test.ts index 734a309f002f..f7c203f9e5d0 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.test.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.test.ts @@ -167,4 +167,21 @@ describe("GitHubCredentials", () => { ); }).pipe(Effect.provide(layer)); }); + + it.effect("uses a project's own account ahead of the host's pinned one", () => { + const { layer, calls } = harness({ "github.com": { account: "personal" } }); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + expect(Redacted.value((yield* credentials.get("github.com", "work")).token)).toBe( + "token-for-work", + ); + expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe( + "token-for-personal", + ); + expect(calls).toEqual([ + ["auth", "token", "--hostname", "github.com", "--user", "work"], + ["auth", "token", "--hostname", "github.com", "--user", "personal"], + ]); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index d47d57ea7ffe..d4323d14dc63 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -82,17 +82,20 @@ export type GitHubCredentialUnavailableError = | GitHubCliFailedError; /** - * 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. + * Where GitHub tokens come from. Callers ask per host, and for a project's own `gh` login when + * it has one, 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, { + /** `account` is a `gh` login to use instead of the one Settings choose for the host. */ readonly get: ( host: string, + account?: string | null, ) => Effect.Effect; /** Drops the held token after GitHub refused it, so the next read asks its source again. */ - readonly invalidate: (host: string) => Effect.Effect; + readonly invalidate: (host: string, account?: string | null) => Effect.Effect; } >()("t3/sourceControl/GitHubCredentials") {} @@ -231,7 +234,7 @@ export const make = Effect.gen(function* () { }); return GitHubCredentials.of({ - get: Effect.fn("GitHubCredentials.get")(function* (rawHost) { + get: Effect.fn("GitHubCredentials.get")(function* (rawHost, account) { const host = normalizeHost(rawHost); const choice = yield* hostChoice(host); if (choice?.enabled === false) { @@ -248,12 +251,14 @@ export const make = Effect.gen(function* () { fingerprint: yield* fingerprintOf(host, saved), } satisfies GitHubCredential; } - return yield* Cache.get(cache, cacheKey(host, choice?.account)); + return yield* Cache.get(cache, cacheKey(host, account ?? choice?.account)); }), - invalidate: (rawHost) => { + invalidate: (rawHost, account) => { const host = normalizeHost(rawHost); return hostChoice(host).pipe( - Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))), + Effect.flatMap((choice) => + Cache.invalidate(cache, cacheKey(host, account ?? choice?.account)), + ), ); }, }); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 223a2ec58a26..9c1ba4d5665a 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -525,6 +525,7 @@ export const make = Effect.gen(function* () { Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) ?.credentialFingerprint ?? "", Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + Context.getOrElse(context, GitHubApi.GitHubAccount, () => null) ?? "", ].join("\0"), resolver: (entries) => { const [first] = entries; diff --git a/apps/server/src/sourceControl/gitHubProjectAccount.test.ts b/apps/server/src/sourceControl/gitHubProjectAccount.test.ts new file mode 100644 index 000000000000..fd6063d5cb30 --- /dev/null +++ b/apps/server/src/sourceControl/gitHubProjectAccount.test.ts @@ -0,0 +1,85 @@ +import { expect, it } from "@effect/vitest"; +import { ProjectId, type ServerSettings as ServerSettingsSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import { ChildProcessSpawner } from "effect/process"; + +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { GitHubAccount } from "./GitHubApi.ts"; +import { makeGitHubProjectAccount } from "./gitHubProjectAccount.ts"; + +const WORK = ProjectId.make("work"); +const PERSONAL = ProjectId.make("personal"); +const ROOTS: Record = { "/code/work": WORK, "/code/personal": PERSONAL }; + +function harness(overrides: ServerSettingsSchema["projectSettingsOverrides"]) { + const gitCalls: string[] = []; + const row = (projectId: ProjectId, workspaceRoot: string): ProjectStore.ProjectRow => ({ + projectId, + title: projectId, + workspaceRoot, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + faviconPath: null, + projectIcon: null, + scripts: [], + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + deletedAt: null, + }); + const layer = Layer.mergeAll( + ServerSettings.ServerSettingsService.layerTest({ projectSettingsOverrides: overrides }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + findActiveByWorkspaceRoot: (root) => { + const projectId = ROOTS[root]; + return Effect.succeed( + projectId === undefined ? Option.none() : Option.some(row(projectId, root)), + ); + }, + }), + Layer.mock(GitVcsDriver.GitVcsDriver)({ + // A worktree's common directory lives in its main checkout. + execute: (input) => + Effect.sync(() => { + gitCalls.push(input.cwd); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "/code/work/.git\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + Path.layer, + ); + return { layer, gitCalls }; +} + +it.effect("runs a project's checkouts and worktrees as its own account", () => { + const { layer, gitCalls } = harness({ [WORK]: { githubAccount: "bek-work" } }); + return Effect.gen(function* () { + const { accountFor, actAs } = yield* makeGitHubProjectAccount; + expect(yield* accountFor({ cwd: "/code/work" })).toBe("bek-work"); + expect(yield* accountFor({ cwd: "/code/personal" })).toBeNull(); + expect(yield* accountFor({ cwd: "/worktrees/work/feature" })).toBe("bek-work"); + expect(yield* accountFor({ cwd: "/worktrees/work/feature" })).toBe("bek-work"); + expect(gitCalls).toEqual(["/worktrees/work/feature"]); + expect(yield* actAs({ cwd: "/worktrees/work/feature" }, GitHubAccount)).toBe("bek-work"); + expect(yield* actAs({ cwd: "/code/personal" }, GitHubAccount)).toBeNull(); + }).pipe(Effect.provide(layer)); +}); + +it.effect("asks nothing of Git or the project store without project overrides", () => { + const { layer, gitCalls } = harness({}); + return Effect.gen(function* () { + const { accountFor } = yield* makeGitHubProjectAccount; + expect(yield* accountFor({ cwd: "/worktrees/work/feature" })).toBeNull(); + expect(gitCalls).toEqual([]); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/server/src/sourceControl/gitHubProjectAccount.ts b/apps/server/src/sourceControl/gitHubProjectAccount.ts new file mode 100644 index 000000000000..ec1e2b22dce5 --- /dev/null +++ b/apps/server/src/sourceControl/gitHubProjectAccount.ts @@ -0,0 +1,83 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import type { ProjectId } from "@t3tools/contracts"; +import { + hasProjectSettingsOverrides, + resolveProjectSettings, +} from "@t3tools/shared/projectSettings"; + +import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import { GitHubAccount } from "./GitHubApi.ts"; + +/** + * Runs GitHub work as the project's own `gh` login (its `githubAccount` setting) for paths that + * only know a working directory. A worktree belongs to the project whose checkout shares its Git + * directory. Without any project overrides this answers at once and runs nothing. + */ +export const makeGitHubProjectAccount = Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const projects = yield* ProjectStore.ProjectStoreV2; + const git = yield* GitVcsDriver.GitVcsDriver; + const path = yield* Path.Path; + // A worktree's main checkout never changes, so it is asked of Git once. + const mainCheckouts = new Map(); + + const projectAt = (workspaceRoot: string) => + projects.findActiveByWorkspaceRoot(workspaceRoot).pipe( + Effect.map((project) => Option.getOrNull(project)?.projectId ?? null), + Effect.orElseSucceed(() => null), + ); + + const mainCheckoutOf = (cwd: string) => { + const held = mainCheckouts.get(cwd); + if (held !== undefined) return Effect.succeed(Option.some(held)); + return git + .execute({ + operation: "GitHubProjectAccount.commonDir", + cwd, + args: ["rev-parse", "--path-format=absolute", "--git-common-dir"], + timeoutMs: 5_000, + }) + .pipe( + Effect.map((result) => path.dirname(result.stdout.trim())), + Effect.tap((root) => Effect.sync(() => mainCheckouts.set(cwd, root))), + Effect.option, + ); + }; + + const projectOf = Effect.fnUntraced(function* (cwd: string) { + const direct = yield* projectAt(cwd); + if (direct !== null) return direct; + const main = yield* mainCheckoutOf(cwd); + return Option.isNone(main) ? null : yield* projectAt(main.value); + }); + + /** The login the project at `cwd` (or `projectId`, when the caller knows it) chooses, or null. */ + const accountFor = Effect.fnUntraced(function* (input: { + readonly cwd: string; + readonly projectId?: ProjectId | null | undefined; + }) { + const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => null)); + if (settings === null || !hasProjectSettingsOverrides(settings)) return null; + const projectId = input.projectId ?? (yield* projectOf(input.cwd)); + return projectId === null + ? null + : resolveProjectSettings(settings, projectId).settings.githubAccount; + }); + + const actAs = ( + input: { readonly cwd: string; readonly projectId?: ProjectId | null | undefined }, + effect: Effect.Effect, + ) => + accountFor(input).pipe( + Effect.flatMap((account) => + account === null ? effect : effect.pipe(Effect.provideService(GitHubAccount, account)), + ), + ); + + return { accountFor, actAs }; +}); diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index 3bd486fa9e9c..1a683944f02d 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -1405,6 +1405,27 @@ describe('who "I" am, per server', () => { ).toEqual([1]); }); + it("matches a project's rows to the GitHub account that project uses", () => { + const byWork = { login: "work-account", name: null, avatarUrl: null }; + const work = "work-project" as ProjectId; + const merged = mergePullRequestLists([ + [ + ENV_1, + answer({ "github.com": "Bilal", [`project:${work}`]: "work-account" }, [ + entry({ number: 1, author: byBilal }), + entry({ number: 2, author: byWork, projectId: work }), + entry({ number: 3, author: byBilal, projectId: work }), + ]), + ], + ])!; + + expect( + filterPullRequestsByInvolvement(merged.entries, merged.viewers, "authored").map( + (row) => row.number, + ), + ).toEqual([1, 2]); + }); + it("names the servers with more rows and no cursor to reach them by", () => { const merged = mergePullRequestLists([ [ diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index 04f4711aeaa8..07c1ddaf9222 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -145,8 +145,15 @@ export function pullRequestEntryViewer( viewers: PullRequestViewers, ): string | null { // The environment's own answer first; a plain host key is what a single-environment listing - // still writes, and what the snapshot from one carries. - return normalize(viewers[pullRequestViewerKey(entry)] ?? viewers[entry.host]); + // still writes, and what the snapshot from one carries. A project with a GitHub account of its + // own is keyed `project:` and wins over the host. + const project = `project:${entry.projectId}`; + return normalize( + viewers[`${entry.environmentId ?? ""} ${project}`] ?? + viewers[project] ?? + viewers[pullRequestViewerKey(entry)] ?? + viewers[entry.host], + ); } /** diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index bfd776855307..d339c50b88e9 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -16,6 +16,8 @@ import { sortProviderInstanceEntries, } from "../../providerInstances"; import { useEnvironments } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { sourceControlEnvironment } from "../../state/sourceControl"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; import { resolveEnvModeLabel, WORKTREE_SUBMODULES_LABELS } from "../BranchToolbar.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; @@ -25,6 +27,7 @@ import { TraitsPicker } from "../chat/TraitsPicker"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { toastManager } from "../ui/toast"; import { Switch } from "../ui/switch"; +import { groupGitHubAccounts } from "./GitHubAccountSettings.logic"; import type { ProjectSettingsCategory } from "./ProjectSettingsPanel"; import { searchableSetting } from "./settingsSearch"; import { useSettingsScope } from "./SettingsScopeContext"; @@ -41,6 +44,9 @@ import { useUpdateScopedSettings, } from "./useScopedSettings"; +/** Sentinel select value for "the account Settings choose for the host"; logins have no spaces. */ +const HOST_ACCOUNT = "host account"; + /** * Rows for the settings a project may override. The same rows edit * environment defaults at an environment scope and project overrides at a @@ -83,6 +89,25 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const mixedMergeMethod = useScopedSettingsMixed(["pullRequestMergeMethod"]); const modelSource = useScopedSettingSource(["defaultModelSelection"]); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; + const mixedGitHubAccount = useScopedSettingsMixed(["githubAccount"]); + // The logins `gh` holds on the project's environment, which is what a project can pick from. + const discovery = useEnvironmentQuery( + category === "source-control" && isProjectScope && target + ? sourceControlEnvironment.discovery({ environmentId: target.environmentId, input: {} }) + : null, + ); + const githubLogins = [ + ...new Set( + (discovery.data?.sourceControlProviders ?? []) + .filter((provider) => provider.kind === "github") + .flatMap((provider) => + groupGitHubAccounts(provider.auth.accounts ?? []).flatMap((group) => group.selectable), + ), + ), + ]; + if (settings.githubAccount !== null && !githubLogins.includes(settings.githubAccount)) { + githubLogins.push(settings.githubAccount); + } const unavailable = connectedEnvironments.length === 0; // File-backed keys show their effective value; the target already carries // the checkout's t3.json, and a null file here only fills the built-in. @@ -487,6 +512,49 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting } /> + {isProjectScope ? ( + updateSettings({ githubAccount: null })} + /> + ) : null + } + control={ + + } + /> + ) : null} ) : ( <> diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 853886d81fcd..38caf72fe9a0 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -737,6 +737,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["pull request merge squash rebase last selected"], }, + { + id: "github-project-account", + title: "GitHub account", + to: "/settings/source-control", + scope: "project", + searchTerms: ["github gh account login user work personal multiple accounts project"], + }, { id: "source-control", title: "Source control", diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 344f4c97917d..bc650c256b60 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -24,6 +24,12 @@ If `gh` is signed in to several accounts or hosts, expand **GitHub** in the same the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence over that choice; a host turned off stays off either way. +A project can use a different account than the rest, for example a work project next to your +personal ones. Choose the project under **Applying settings for** in Settings → Source Control, +then pick its **GitHub account**. Its pull request reviews, actions and the pull requests T3 Code +creates for it use that account. Pushes keep using your Git credentials, and agents' own `gh` +commands still use `gh`'s active account. + ### Forgejo and Gitea Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 2cd0e45cc8e0..055f4c8831e2 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -631,7 +631,8 @@ export const PullRequestListResult = Schema.Struct({ * The signed-in account per host, which is what involvement filtering compares. Keyed by * host rather than by provider kind: two GitHub hosts are two accounts. A host that could * not be read is absent rather than present-and-undefined, because an open-keyed record - * cannot carry an optional value through the JSON codec. + * cannot carry an optional value through the JSON codec. A project with a GitHub account of its + * own (its `githubAccount` setting) is keyed `project:`. */ viewers: Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), providers: Schema.Array(PullRequestProviderSummary), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b93a1d354746..7ab4d38b8ac2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1201,6 +1201,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "branchNamePrefix", "branchNameInstructions", "pullRequestMergeMethod", + "githubAccount", "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", "continueThreadsAfterServerUpdate", @@ -1232,6 +1233,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ branchNamePrefix: Schema.optionalKey(TrimmedString), branchNameInstructions: Schema.optionalKey(TrimmedString), pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), + githubAccount: Schema.optionalKey(TrimmedNonEmptyString), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), @@ -1456,6 +1458,14 @@ export const ServerSettings = Schema.Struct({ pullRequestMergeMethod: Schema.NullOr(PullRequestMergeMethod).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + /** + * The `gh` login T3 Code uses for a project's GitHub work: reading and acting on its pull + * requests and creating them. Set per project; `null` uses the account Settings choose for the + * host (see `github.hosts`). + */ + githubAccount: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Legacy single-instance-per-driver settings. Continues to be the source // of truth until `providerInstances` (below) lands per-driver migration @@ -1766,6 +1776,7 @@ export const ServerSettingsPatch = Schema.Struct({ ), sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), + githubAccount: Schema.optionalKey(Schema.NullOr(TrimmedNonEmptyString)), observability: Schema.optionalKey( Schema.Struct({ otlpTracesUrl: Schema.optionalKey(TrimmedString), From 639855d48f6efe9dd4f8b80cb43bedb8a34d5e14 Mon Sep 17 00:00:00 2001 From: Ulugh Beck Date: Wed, 7 Oct 2026 23:22:39 +0500 Subject: [PATCH 2/3] fix(server): a project's own GitHub account no longer falls back to another login When the login a project chose is no longer signed in to gh, its GitHub work now fails with a not-signed-in error instead of running as gh's active login. The host's pinned account keeps its documented fallback. Co-Authored-By: Claude Opus 5.5 --- .../sourceControl/GitHubCredentials.test.ts | 15 +++++++++ .../src/sourceControl/GitHubCredentials.ts | 31 +++++++++++++------ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/server/src/sourceControl/GitHubCredentials.test.ts b/apps/server/src/sourceControl/GitHubCredentials.test.ts index f7c203f9e5d0..e8e86a52f0aa 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.test.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.test.ts @@ -184,4 +184,19 @@ describe("GitHubCredentials", () => { ]); }).pipe(Effect.provide(layer)); }); + + it.effect( + "refuses a project's own account that gh no longer holds instead of acting as another", + () => { + const { layer, calls } = harness({ "github.com": { account: "work" } }, ["work"]); + return Effect.gen(function* () { + const credentials = yield* GitHubCredentials.GitHubCredentials; + const error = yield* Effect.flip(credentials.get("github.com", "work")); + expect(error._tag).toBe("GitHubNotSignedInError"); + expect(calls).toEqual([["auth", "token", "--hostname", "github.com", "--user", "work"]]); + // The same login pinned for the host keeps its documented fallback to the active login. + expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token"); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index d4323d14dc63..84abee89d0f7 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -194,22 +194,29 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => null), ); - /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */ - const cacheKey = (host: string, account: string | undefined) => - account === undefined ? host : `${host}\u0000${account}`; + /** + * Cache key: the host plus its pinned account, so a changed pin misses the cache. A project's + * own account is kept apart from the same login pinned for the host, because only the host's + * pin may fall back to another login. + */ + const cacheKey = (host: string, account: string | undefined, project = false) => + account === undefined ? host : `${host}\u0000${account}${project ? "\u0000project" : ""}`; const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) { - const [host = key, choice] = key.split("\u0000"); + const [host = key, choice, project] = key.split("\u0000"); // An environment token wins over a pinned account, exactly as it does in gh. const fromEnv = environmentToken(host, environment); - // A pinned login gh no longer holds (logged out, expired) falls back to the active one, - // which is what discovery reports as the account in use. + // A host's pinned login gh no longer holds (logged out, expired) falls back to the active + // one, which is what discovery reports as the account in use. A project's own login does + // not: acting as someone else there would put that project's work under the wrong account. const token = fromEnv ?? (yield* fromGh(host, choice).pipe( Effect.catchTags({ GitHubNotSignedInError: (error) => - choice === undefined ? Effect.fail(error) : fromGh(host, undefined), + choice === undefined || project !== undefined + ? Effect.fail(error) + : fromGh(host, undefined), }), )); return { @@ -251,13 +258,19 @@ export const make = Effect.gen(function* () { fingerprint: yield* fingerprintOf(host, saved), } satisfies GitHubCredential; } - return yield* Cache.get(cache, cacheKey(host, account ?? choice?.account)); + return yield* Cache.get( + cache, + account ? cacheKey(host, account, true) : cacheKey(host, choice?.account), + ); }), invalidate: (rawHost, account) => { const host = normalizeHost(rawHost); return hostChoice(host).pipe( Effect.flatMap((choice) => - Cache.invalidate(cache, cacheKey(host, account ?? choice?.account)), + Cache.invalidate( + cache, + account ? cacheKey(host, account, true) : cacheKey(host, choice?.account), + ), ), ); }, From cd05c6adb8040f048fa581cfe566ff9807767ab8 Mon Sep 17 00:00:00 2001 From: Ulugh Beck Date: Thu, 8 Oct 2026 11:59:36 +0500 Subject: [PATCH 3/3] test(server): follow main's GitHub quota layer in the account test Co-Authored-By: Claude Opus 5.5 --- apps/server/src/sourceControl/GitHubApi.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index 71aaad9274ec..4cc1010b8f1a 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -555,7 +555,7 @@ describe("GitHubApi accounts", () => { ); const layer = GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); return Effect.gen(function* () {