From 0209974dd46def0881e01cc00f645170c13c3c87 Mon Sep 17 00:00:00 2001 From: Donald Silveira Date: Wed, 23 Sep 2026 19:42:22 -0300 Subject: [PATCH] feat(server): select GitHub CLI accounts per project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run GitHub commands with the selected account’s token without switching `gh`’s active login - Add environment and project account settings, discovery, and guidance --- .../PullRequestProviderRegistry.ts | 8 +- apps/server/src/server.ts | 16 ++- apps/server/src/serverSettings.ts | 1 + .../src/sourceControl/GitHubCli.test.ts | 86 ++++++++++++ apps/server/src/sourceControl/GitHubCli.ts | 87 +++++++++++- .../GitHubCliAccountSelection.test.ts | 118 +++++++++++++++++ .../GitHubCliAccountSelection.ts | 75 +++++++++++ .../GitHubSourceControlProvider.ts | 3 + .../SourceControlProviderDiscovery.ts | 2 + .../settings/SettingInheritance.tsx | 21 +-- .../settings/SourceControlSettings.tsx | 124 +++++++++++++++++- .../src/components/settings/settingsSearch.ts | 7 + docs/user/project-settings.md | 4 +- docs/user/source-control.md | 7 + packages/contracts/src/settings.ts | 13 ++ packages/contracts/src/sourceControl.ts | 9 ++ packages/shared/src/serverSettings.ts | 1 + 17 files changed, 557 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts create mode 100644 apps/server/src/sourceControl/GitHubCliAccountSelection.ts diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 9e8727ed7a77..61ed8d5e89db 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -5,7 +5,6 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; @@ -58,12 +57,7 @@ export const make = Effect.map( ); export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( - Layer.provide( - GitHubPullRequestCli.layer.pipe( - Layer.provide(GitHubCli.layer), - Layer.provide(GitHubGraphQlBudget.layer), - ), - ), + Layer.provide(GitHubPullRequestCli.layer.pipe(Layer.provide(GitHubGraphQlBudget.layer))), Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), Layer.provide(ForgejoCli.layer), Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3fd0bb7274a0..ed3d8417bdb7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -61,6 +61,7 @@ import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitHubCliAccountSelection from "./sourceControl/GitHubCliAccountSelection.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -274,6 +275,11 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +// Every server-side GitHub command resolves its checkout's selected `gh` login through this one instance. +const GitHubCliLayerLive = GitHubCli.layer.pipe( + Layer.provide(GitHubCliAccountSelection.layer.pipe(Layer.provide(ServerSettingsLayerLive))), +); + const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); @@ -283,7 +289,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubCli.layer, + GitHubCliLayerLive, GitLabCli.layer, ForgejoCli.layer, ), @@ -329,7 +335,7 @@ const RepositoryIdentityResolverLayerLive = Layer.effect( ).pipe(Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(ProcessRunner.layer)); const PullRequestServiceLive = PullRequestService.layer.pipe( - Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(PullRequestProviderRegistry.layer.pipe(Layer.provide(GitHubCliLayerLive))), // Where the viewed-file marks live for a host that keeps none of its own. Layer.provide(PullRequestFilesViewed.layer), Layer.provide(PullRequestReadCache.layer), @@ -493,7 +499,11 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `GitHubCli` is the registry's own instance, exposed because the asset route fetches // GitHub-hosted pull request media with the repository's credential. Layer.provideMerge( - Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive, GitHubCli.layer), + Layer.mergeAll( + SourceControlProviderRegistryLayerLive, + PullRequestServiceLive, + GitHubCliLayerLive, + ), ), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index ed52282c0237..5a6cc2f82bfe 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -358,6 +358,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet = new Set([ "sourceControlWriterModelSelection", "textGenerationModelSelection", "pullRequestMergeMethod", + "githubCliAccount", ]); // Preserve both enabled states because provider history cannot recover a new opt-in. diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5893c21ff772..ea44ece7a08a 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -243,6 +243,92 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("runs commands as the checkout's selected account without switching gh", () => + Effect.gen(function* () { + const commands: Array<{ args: ReadonlyArray; env: NodeJS.ProcessEnv | undefined }> = + []; + const gh = yield* GitHubCli.make.pipe( + Effect.provideService(GitHubCli.GitHubCliAccountSelection, { + forCwd: (cwd) => + Effect.succeed( + cwd === "/work" + ? { host: "github.com", login: "work-login" } + : cwd === "/enterprise" + ? { host: "github.example.test", login: "enterprise-login" } + : null, + ), + }), + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + Effect.sync(() => { + commands.push({ args: input.args, env: input.env }); + return input.args[0] === "auth" + ? processOutput(`token-for-${input.args[5]}\n`) + : processOutput(""); + }), + }), + ); + yield* gh.execute({ cwd: "/work", args: ["pr", "merge", "1"] }); + yield* gh.execute({ cwd: "/work", args: ["pr", "close", "2"] }); + yield* gh.execute({ cwd: "/enterprise", args: ["pr", "merge", "3"] }); + yield* gh.execute({ cwd: "/personal", args: ["pr", "merge", "4"] }); + + expect(commands.map((command) => command.args.join(" "))).toEqual([ + "auth token --hostname github.com --user work-login", + "pr merge 1", + "pr close 2", + "auth token --hostname github.example.test --user enterprise-login", + "pr merge 3", + "pr merge 4", + ]); + // The lookup ignores an ambient env token, which gh would print instead. + expect(commands[0]?.env).toMatchObject({ GH_TOKEN: "", GITHUB_TOKEN: "" }); + expect(commands[1]?.env).toEqual({ + GH_TOKEN: "token-for-work-login", + GITHUB_TOKEN: "token-for-work-login", + }); + expect(commands[4]?.env).toEqual({ + GH_ENTERPRISE_TOKEN: "token-for-enterprise-login", + GITHUB_ENTERPRISE_TOKEN: "token-for-enterprise-login", + }); + expect(commands[5]?.env).toBeUndefined(); + }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), + ); + + it.effect("fails instead of falling back when the selected account is signed out", () => + Effect.gen(function* () { + const commands: string[] = []; + const gh = yield* GitHubCli.make.pipe( + Effect.provideService(GitHubCli.GitHubCliAccountSelection, { + forCwd: () => Effect.succeed({ host: "github.com", login: "gone" }), + }), + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + commands.push(input.args.join(" ")); + return input.args[0] === "auth" + ? Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.accountToken", + command: "gh", + cwd: "/work", + exitCode: 1, + failureKind: "authentication", + detail: "no oauth token found for github.com account gone", + }), + ) + : Effect.succeed(processOutput("")); + }, + }), + ); + const failure = yield* gh + .execute({ cwd: "/work", args: ["pr", "merge", "1"] }) + .pipe(Effect.flip); + expect(failure._tag).toBe("GitHubCliAccountUnavailableError"); + expect(failure.detail).toContain("gone on github.com"); + expect(commands).toEqual(["auth token --hostname github.com --user gone"]); + }).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))), + ); + it("does not classify a missing cwd as an unavailable gh executable", () => { const context = { command: "gh", cwd: "/repo" } as const; const missingCwd = new VcsProcessSpawnError({ diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index c525740efeae..383e786a2d79 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString, + type GitHubCliAccount, type SourceControlRepositoryVisibility, type VcsError, } from "@t3tools/contracts"; @@ -35,6 +36,24 @@ export const PinnedGitHubCredential = Context.Reference<{ readonly credentialFingerprint: string; } | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); +/** + * The `gh` login a command's checkout is set to use, or null for the CLI's + * active login. Read once when the service is built; the server provides it + * from settings (see `GitHubCliAccountSelection.ts`). + */ +export class GitHubCliAccountSelection extends Context.Reference<{ + readonly forCwd: (cwd: string) => Effect.Effect; +}>("t3/sourceControl/GitHubCliAccountSelection", { + defaultValue: () => ({ forCwd: () => Effect.succeed(null) }), +}) {} + +/** gh reads github.com and GHE.com tenancies from GH_TOKEN, every other host from GH_ENTERPRISE_TOKEN. */ +function tokenEnv(host: string, token: string): NodeJS.ProcessEnv { + return host === "github.com" || host.endsWith(".ghe.com") + ? { GH_TOKEN: token, GITHUB_TOKEN: token } + : { GH_ENTERPRISE_TOKEN: token, GITHUB_ENTERPRISE_TOKEN: token }; +} + export const AllowGitHubReserve = Context.Reference( "t3/sourceControl/AllowGitHubReserve", { defaultValue: () => false }, @@ -117,6 +136,19 @@ export class GitHubCliRateLimitError extends Schema.TaggedError()( + "GitHubCliAccountUnavailableError", + { ...gitHubCliFailureFields, host: Schema.String, login: Schema.String }, +) { + get detail(): string { + return `GitHub CLI account ${this.login} on ${this.host} is not signed in. Run \`gh auth login\` for it or choose another account in Source Control settings.`; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + export class GitHubPullRequestNotFoundError extends Schema.TaggedError()( "GitHubPullRequestNotFoundError", gitHubCliFailureFields, @@ -204,6 +236,7 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError { + const [host = "", login = ""] = key.split("\0"); + return process + .run({ + operation: "GitHubCli.accountToken", + command: "gh", + args: ["auth", "token", "--hostname", host, "--user", login], + cwd: globalThis.process.cwd(), + timeoutMs: DEFAULT_TIMEOUT_MS, + env: { ...tokenEnv(host, ""), GH_DEBUG: "" }, + }) + .pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((token) => + token ? Effect.succeed(Redacted.make(token)) : Effect.fail(null), + ), + ); + }, + { + capacity: 16, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(1) : Duration.zero), + }, + ); const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")( function* (input) { @@ -411,9 +472,33 @@ export const make = Effect.gen(function* () { }); } const token = credential === null ? undefined : Redacted.value(credential.token); + // A pinned credential already names its account; otherwise use the checkout's selection. + // A selected login that cannot be read fails the command rather than running as another. + const account = credential === null ? yield* accountSelection.forCwd(input.cwd) : null; + const accountToken = + account === null + ? null + : yield* Cache.get(accountTokens, `${account.host.toLowerCase()}\0${account.login}`).pipe( + // Only the token is kept. Never attach credential lookup output to an error. + Effect.mapError( + () => + new GitHubCliAccountUnavailableError({ + command: "gh", + cwd: input.cwd, + host: account.host, + login: account.login, + cause: new Error("`gh auth token --user` did not return a token."), + }), + ), + ); const env = credential === null - ? input.env + ? account === null || accountToken === null + ? input.env + : { + ...tokenEnv(account.host.toLowerCase(), Redacted.value(accountToken)), + ...input.env, + } : { ...input.env, GH_HOST: credential.host, diff --git a/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts b/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts new file mode 100644 index 000000000000..298f3655d64d --- /dev/null +++ b/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts @@ -0,0 +1,118 @@ +import { assert, it } from "@effect/vitest"; +import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; +import { ProjectionThreadRepositoryLive } from "../persistence/Layers/ProjectionThreads.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; +import { ProjectionThreadRepository } from "../persistence/Services/ProjectionThreads.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { GitHubCliAccountSelection } from "./GitHubCli.ts"; +import * as GitHubCliAccountSelectionLayer from "./GitHubCliAccountSelection.ts"; + +const at = "2026-09-01T00:00:00.000Z"; +const personal = { host: "github.com", login: "personal" }; +const work = { host: "github.com", login: "work" }; + +const seed = Effect.gen(function* () { + const projects = yield* ProjectionProjectRepository; + const threads = yield* ProjectionThreadRepository; + for (const [id, root] of [ + ["project-work", "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/src/work"], + ["project-personal", "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/src/personal"], + ] as const) { + yield* projects.upsert({ + projectId: ProjectId.make(id), + title: id, + workspaceRoot: root, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + scripts: [], + createdAt: at, + updatedAt: at, + deletedAt: null, + }); + } + yield* threads.upsert({ + threadId: ThreadId.make("thread-work"), + projectId: ProjectId.make("project-work"), + title: "Work thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: "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/worktrees/work-feature", + latestTurnId: null, + createdAt: at, + updatedAt: at, + archivedAt: null, + settledOverride: null, + settledAt: null, + unsettledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); +}); + +const persistence = Layer.mergeAll( + ProjectionProjectRepositoryLive, + ProjectionThreadRepositoryLive, +).pipe(Layer.provideMerge(SqlitePersistenceMemory)); + +it.effect("resolves the project override for its root and its thread worktrees", () => + Effect.gen(function* () { + yield* seed; + const selection = yield* GitHubCliAccountSelection; + assert.deepStrictEqual(yield* selection.forCwd("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/src/work"), work); + assert.deepStrictEqual(yield* selection.forCwd("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/worktrees/work-feature"), work); + assert.deepStrictEqual(yield* selection.forCwd("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/src/personal"), personal); + // Outside any project the environment default applies. + assert.deepStrictEqual(yield* selection.forCwd("/tmp/elsewhere"), personal); + }).pipe( + Effect.provide( + GitHubCliAccountSelectionLayer.layer.pipe( + Layer.provide( + ServerSettings.layerTest({ + githubCliAccount: personal, + projectSettingsOverrides: { + [ProjectId.make("project-work")]: { githubCliAccount: work }, + }, + }), + ), + Layer.provideMerge(persistence), + ), + ), + ), +); + +it.effect("lets a project opt back into the CLI's active login", () => + Effect.gen(function* () { + yield* seed; + const selection = yield* GitHubCliAccountSelection; + assert.strictEqual(yield* selection.forCwd("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/worktrees/work-feature"), null); + assert.deepStrictEqual(yield* selection.forCwd("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/src/personal"), personal); + }).pipe( + Effect.provide( + GitHubCliAccountSelectionLayer.layer.pipe( + Layer.provide( + ServerSettings.layerTest({ + githubCliAccount: personal, + projectSettingsOverrides: { + [ProjectId.make("project-work")]: { githubCliAccount: null }, + }, + }), + ), + Layer.provideMerge(persistence), + ), + ), + ), +); diff --git a/apps/server/src/sourceControl/GitHubCliAccountSelection.ts b/apps/server/src/sourceControl/GitHubCliAccountSelection.ts new file mode 100644 index 000000000000..aeff55c93300 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubCliAccountSelection.ts @@ -0,0 +1,75 @@ +import { ProjectId } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import * as ServerSettings from "../serverSettings.ts"; +import { GitHubCliAccountSelection } from "./GitHubCli.ts"; + +/** + * Resolves the `gh` login for a GitHub command from its cwd: the project + * rooted there, or the project whose thread owns that worktree. + * + * Reads the projection tables directly because the snapshot query depends on + * source control (repository identity), which depends on `GitHubCli`. + */ +export const layer = Layer.effect( + GitHubCliAccountSelection, + Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const sql = yield* SqlClient.SqlClient; + const findProjectId = SqlSchema.findOneOption({ + Request: Schema.Struct({ cwd: Schema.String }), + Result: Schema.Struct({ projectId: ProjectId }), + execute: ({ cwd }) => sql` + SELECT project_id AS "projectId" FROM ( + SELECT project_id, 0 AS rank + FROM projection_projects + WHERE workspace_root = ${cwd} AND deleted_at IS NULL + UNION ALL + SELECT threads.project_id, 1 AS rank + FROM projection_threads AS threads + JOIN projection_projects AS projects + ON projects.project_id = threads.project_id AND projects.deleted_at IS NULL + WHERE threads.worktree_path = ${cwd} AND threads.deleted_at IS NULL + ) + ORDER BY rank + LIMIT 1 + `, + }); + // A checkout rarely changes project; the TTL still picks up one added or removed there. + const projectIds = yield* Cache.makeWith( + (cwd: string) => + findProjectId({ cwd }).pipe( + Effect.map((row) => Option.getOrNull(Option.map(row, ({ projectId }) => projectId))), + ), + { + capacity: 256, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(1) : Duration.zero), + }, + ); + return { + forCwd: Effect.fn("GitHubCliAccountSelection.forCwd")(function* (cwd: string) { + const settings = yield* serverSettings.getSettings; + // Nobody picked an account anywhere: skip the project lookup entirely. + if ( + settings.githubCliAccount === null && + !Object.values(settings.projectSettingsOverrides).some( + (overrides) => overrides.githubCliAccount !== undefined, + ) + ) { + return null; + } + const projectId = yield* Cache.get(projectIds, cwd); + return resolveProjectSettings(settings, projectId).settings.githubCliAccount; + }, Effect.orDie), + }; + }), +); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 372d2a032d79..63b1d5b21078 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -66,6 +66,9 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { status: "authenticated", account: authenticatedAccount.account, host, + accounts: authStatus.accounts.flatMap((entry) => + entry.authenticated ? [{ host: entry.host, login: entry.account }] : [], + ), }); } diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 466e66230649..be49dc69eab0 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -118,12 +118,14 @@ export function providerAuth(input: { readonly account?: string | undefined; readonly host?: string | undefined; readonly detail?: string | undefined; + readonly accounts?: SourceControlProviderAuth["accounts"]; }): SourceControlProviderAuth { return { status: input.status, account: authAccount(input.account), host: authHost(input.host), detail: authDetail(input.detail), + ...(input.accounts !== undefined ? { accounts: input.accounts } : {}), }; } diff --git a/apps/web/src/components/settings/SettingInheritance.tsx b/apps/web/src/components/settings/SettingInheritance.tsx index e08ca680649f..e2d24f501d79 100644 --- a/apps/web/src/components/settings/SettingInheritance.tsx +++ b/apps/web/src/components/settings/SettingInheritance.tsx @@ -39,15 +39,17 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { if (value === null || value === undefined) { return key === "pullRequestMergeMethod" ? "Last selected" - : key === "sidebarAutoSettleAfterDays" - ? "Never" - : key === "defaultModelSelection" - ? "Automatic" - : key === "sourceControlWriterModelSelection" - ? "Text generation model" - : key === "defaultThreadEnvMode" || key === "worktreeSubmodules" - ? "Inherit" - : "Not set"; + : key === "githubCliAccount" + ? "Active gh login" + : key === "sidebarAutoSettleAfterDays" + ? "Never" + : key === "defaultModelSelection" + ? "Automatic" + : key === "sourceControlWriterModelSelection" + ? "Text generation model" + : key === "defaultThreadEnvMode" || key === "worktreeSubmodules" + ? "Inherit" + : "Not set"; } if (typeof value === "boolean") return value ? "On" : "Off"; if (typeof value === "number") { @@ -71,6 +73,7 @@ function formatValue(key: keyof ServerSettings, value: unknown): string { } if (Array.isArray(value)) return `${value.length} ${value.length === 1 ? "item" : "items"}`; if (typeof value === "object") { + if ("login" in value && "host" in value) return `${value.login} @ ${value.host}`; if ("model" in value && typeof value.model === "string") return value.model; if ("mode" in value && typeof value.mode === "string") { return WRITING_STYLE_LABELS[value.mode] ?? value.mode; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 3c9b440e5c55..5bf52d5fce27 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -5,6 +5,7 @@ import * as Option from "effect/Option"; import { useEffect, useState, type ReactNode } from "react"; import type { BackgroundActivitySettings, + GitHubCliAccount, SourceControlProviderKind, SourceControlDiscoveryResult, SourceControlProviderAuth, @@ -18,7 +19,11 @@ import { resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; -import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; +import { + useScopedSettings, + useScopedSettingsMixed, + useUpdateScopedSettings, +} from "./useScopedSettings"; import { useSettingsScope } from "./SettingsScopeContext"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { cn } from "../../lib/utils"; @@ -35,6 +40,7 @@ import { EmptyMedia, EmptyTitle, } from "../ui/empty"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Skeleton } from "../ui/skeleton"; import { NumberField, @@ -62,6 +68,7 @@ import { PolicyTooltip, SettingResetButton, SettingsPageContainer, + SettingsRow, SettingsSearchTarget, SettingsSection, useSettingsSearchTargetId, @@ -277,7 +284,10 @@ function DiscoveryItemRow({ const searchTargetId = useSettingsSearchTargetId(); useEffect(() => { - if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) { + if ( + (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) || + (item.kind === "github" && searchTargetId === searchableSetting("github-cli-account").id) + ) { setIsExpanded(true); } }, [item.kind, searchTargetId]); @@ -425,6 +435,106 @@ function GitFetchIntervalSettings() { ); } +const ACTIVE_GITHUB_CLI_ACCOUNT = "active"; + +function githubCliAccountLabel(account: GitHubCliAccount): string { + return `${account.login} @ ${account.host}`; +} + +/** + * Which signed-in `gh` login GitHub actions use. The server passes that + * login's token to each command, so the CLI's active login stays as it is. + */ +function GitHubCliAccountSettings({ + accounts, +}: { + readonly accounts: ReadonlyArray; +}) { + const { scope } = useSettingsScope(); + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + const mixed = useScopedSettingsMixed(["githubCliAccount"]); + const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; + const selected = settings.githubCliAccount; + const signedOut = + selected !== null && + !accounts.some( + (account) => account.host === selected.host.toLowerCase() && account.login === selected.login, + ); + const options = signedOut ? [...accounts, selected] : accounts; + + return ( + + {selected.login} is not signed in to gh on this server, so GitHub actions fail until you + sign it back in with gh auth login or choose another account. + + ) : null + } + resetAction={ + selected !== null ? ( + updateSettings({ githubCliAccount: null })} + /> + ) : null + } + control={ + + } + /> + ); +} + function SourceControlSectionSkeleton({ title, headerAction, @@ -503,6 +613,7 @@ function EmptySourceControlDiscovery({ export function SourceControlSettingsPanel() { const { scope, environment, connectedEnvironments } = useSettingsScope(); + const { githubCliAccount } = useScopedSettings(); // Discovery scans one machine's tools, so it shows the representative // environment (named in the section title when several are selected); // the settings rows above it fan out like everywhere else. @@ -588,7 +699,14 @@ export function SourceControlSettingsPanel() { headerAction={hasVersionControlSystems ? null : scanButton} > {result.sourceControlProviders.map((item) => ( - + + {/* One login leaves nothing to choose, unless a previous choice needs undoing. */} + {item.kind === "github" && + item.auth.accounts !== undefined && + (item.auth.accounts.length > 1 || githubCliAccount !== null) ? ( + + ) : undefined} + ))} ) : null} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index acc9b7eb7dbe..d84a01caedb4 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -679,6 +679,13 @@ export const SETTINGS_SEARCH_ITEMS = [ environmentOnly: true, scope: "environment-defaults", }, + { + id: "github-cli-account", + title: "GitHub CLI account", + to: "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/settings/source-control", + searchTerms: ["gh login switch personal work company token multiple accounts"], + scope: "project-defaults", + }, { id: "source-control-writing-style", title: "Source control writing style", diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index fe3d96ba472c..b9f7a55ef15a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -41,8 +41,8 @@ and other phone-only settings ignore the filter. ## Defaults and inheritance General contains the model and workspace for new threads. Integrations controls agent browser -access. Source Control contains automatic pull, the default pull request merge method and text -generation. The same rows edit environment defaults or project overrides depending on the +access. Source Control contains automatic pull, the default pull request merge method, the GitHub CLI +account and text generation. The same rows edit environment defaults or project overrides depending on the project crumb. The Project category, shown while a project is selected, holds the project's name, icon, actions, diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 408810571632..f3c6d96637ce 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -17,6 +17,13 @@ Install [GitHub CLI](https://cli.github.com/) 2.81.0 or newer, then sign in: gh auth login ``` +When `gh` holds more than one login, choose which one T3 Code uses under **GitHub CLI account** +in the GitHub entry of **Settings → Source Control**. Select a project to give it a different +account, for example a work login for company repositories. T3 Code passes that login's token to +its own GitHub commands and never switches the login active in `gh`, so terminals and other apps +are unaffected. If the chosen login is signed out, GitHub actions fail until you sign it back in +or pick another account; they never fall back to a different login. + ### Forgejo and Gitea Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3e301201910c..924fb5bd3f98 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -41,6 +41,7 @@ import { type ProviderDriverKind, } from "./providerInstance.ts"; import { PullRequestMergeMethod } from "./pullRequest.ts"; +import { GitHubCliAccount } from "./sourceControl.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -1013,6 +1014,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "sourceControlWriterModelSelection", "sourceControlWritingStyle", "pullRequestMergeMethod", + "githubCliAccount", "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", "continueThreadsAfterServerUpdate", @@ -1040,6 +1042,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), + githubCliAccount: Schema.optionalKey(Schema.NullOr(GitHubCliAccount)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), @@ -1064,6 +1067,7 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet