diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 697d13e7c472..5a1ae8d092f8 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -14,6 +14,7 @@ function makeProject( workspaceRoot: `/workspaces/${input.id}`, repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index c5a9f2c6bbcb..13c9c323623b 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -26,6 +26,7 @@ function makeProject(id: string, title: string): EnvironmentProject { workspaceRoot: `/workspaces/${id}`, repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index e791cd3b36af..1dc983230e0f 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -18,6 +18,7 @@ function makeProject( workspaceRoot: `/workspaces/${input.id}`, repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 21084f0f5fe5..d8303ace8f29 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -306,6 +306,7 @@ export function buildHomeThreadGroups(input: { pendingTask.creation.projectCwd ?? String(pendingTask.creation.projectId), repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: pendingTask.message.createdAt, updatedAt: pendingTask.message.createdAt, diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts index 8ab043d7cd8b..178db10dc5e9 100644 --- a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts @@ -15,6 +15,7 @@ const projects: ReadonlyArray = [ workspaceRoot: "/workspace/t3code", repositoryIdentity: null, defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + gitHubAccount: null, scripts: [], createdAt: "2026-07-16T08:00:00.000Z", updatedAt: "2026-07-16T08:00:00.000Z", @@ -26,6 +27,7 @@ const projects: ReadonlyArray = [ workspaceRoot: "/workspace/react", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: "2026-07-16T08:00:00.000Z", updatedAt: "2026-07-16T08:00:00.000Z", diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 74fe2f4852a8..7d2375dd05a3 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -260,6 +260,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { workspaceRoot: creation.projectCwd ?? "", repositoryIdentity: null, defaultModelSelection: editingPendingTask.modelSelection ?? null, + gitHubAccount: null, scripts: [], createdAt: editingPendingTask.createdAt, updatedAt: editingPendingTask.createdAt, diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index ab4311524ce4..e801571589a3 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -12,6 +12,7 @@ function makeProject( workspaceRoot: `/workspaces/${input.id}`, repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 731002ea7830..c7dcefaf84f7 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -123,6 +123,7 @@ describe("OrchestrationEngine", () => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + gitHubAccount: null, scripts: [], createdAt: "2026-03-03T00:00:00.000Z", updatedAt: "2026-03-03T00:00:01.000Z", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1f24a4a0200c..11d6188fd98e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -495,6 +495,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti title: event.payload.title, workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, + gitHubAccount: event.payload.gitHubAccount ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -518,6 +519,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.defaultModelSelection !== undefined ? { defaultModelSelection: event.payload.defaultModelSelection } : {}), + ...(event.payload.gitHubAccount !== undefined + ? { gitHubAccount: event.payload.gitHubAccount } + : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad7..c96084a25c25 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -266,6 +266,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + gitHubAccount: null, scripts: [ { id: "script-1", @@ -381,6 +382,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + gitHubAccount: null, scripts: [ { id: "script-1", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf4..4784d2883df0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,6 +1,7 @@ import { ChatAttachment, CheckpointRef, + GitHubAccountRef, IsoDateTime, MessageId, NonNegativeInt, @@ -65,6 +66,7 @@ const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + gitHubAccount: Schema.NullOr(Schema.fromJsonString(GitHubAccountRef)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -234,6 +236,7 @@ function mapProjectShellRow( workspaceRoot: row.workspaceRoot, repositoryIdentity, defaultModelSelection: row.defaultModelSelection, + gitHubAccount: row.gitHubAccount, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -307,6 +310,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + github_account_json AS "gitHubAccount", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -680,6 +684,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + github_account_json AS "gitHubAccount", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -702,6 +707,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + github_account_json AS "gitHubAccount", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1183,6 +1189,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, + gitHubAccount: row.gitHubAccount, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1309,6 +1316,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { title: row.title, workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, + gitHubAccount: row.gitHubAccount, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1757,6 +1765,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: option.value.workspaceRoot, repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, + gitHubAccount: option.value.gitHubAccount, scripts: option.value.scripts, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9531cd5c3afb..ceb798ed5b86 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -33,6 +33,7 @@ const readModel: OrchestrationReadModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + gitHubAccount: null, scripts: [], createdAt: now, updatedAt: now, @@ -46,6 +47,7 @@ const readModel: OrchestrationReadModel = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", }, + gitHubAccount: null, scripts: [], createdAt: now, updatedAt: now, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 100369ae6e36..887f14591d30 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -250,6 +250,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" title: command.title, workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, + gitHubAccount: command.gitHubAccount ?? null, scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -287,6 +288,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.defaultModelSelection !== undefined ? { defaultModelSelection: command.defaultModelSelection } : {}), + ...(command.gitHubAccount !== undefined + ? { gitHubAccount: command.gitHubAccount } + : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0504cb36f9a7..1fe96dec0459 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -211,6 +211,7 @@ export function projectEvent( title: payload.title, workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, + gitHubAccount: payload.gitHubAccount ?? null, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -243,6 +244,9 @@ export function projectEvent( ...(payload.defaultModelSelection !== undefined ? { defaultModelSelection: payload.defaultModelSelection } : {}), + ...(payload.gitHubAccount !== undefined + ? { gitHubAccount: payload.gitHubAccount } + : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, } diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index c1ca6d3104e6..d5667dd33e9c 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { GitHubAccountRef, ModelSelection, ProjectScript } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, @@ -18,6 +18,7 @@ import { const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + gitHubAccount: Schema.NullOr(Schema.fromJsonString(GitHubAccountRef)), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -35,6 +36,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root, default_model_selection_json, + github_account_json, scripts_json, created_at, updated_at, @@ -45,6 +47,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.title}, ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, + ${row.gitHubAccount !== null ? JSON.stringify(row.gitHubAccount) : null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -55,6 +58,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title = excluded.title, workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, + github_account_json = excluded.github_account_json, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -72,6 +76,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + github_account_json AS "gitHubAccount", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -91,6 +96,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { title, workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", + github_account_json AS "gitHubAccount", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 4763f5656538..135af03237d1 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -33,6 +33,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4", }, + gitHubAccount: null, scripts: [], createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d25895671a91..02ea71010715 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -47,6 +47,7 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; +import Migration0035 from "./Migrations/035_ProjectionProjectsGitHubAccount.ts"; /** * Migration loader with all migrations defined inline. @@ -93,6 +94,7 @@ export const migrationEntries = [ [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ProjectionThreadsSettled", Migration0033], [34, "ProjectionThreadsSnoozed", Migration0034], + [35, "ProjectionProjectsGitHubAccount", Migration0035], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_ProjectionProjectsGitHubAccount.ts b/apps/server/src/persistence/Migrations/035_ProjectionProjectsGitHubAccount.ts new file mode 100644 index 000000000000..2b6d15da1db5 --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionProjectsGitHubAccount.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "github_account_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN github_account_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 5632205a2699..7fd5332ad124 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -6,7 +6,13 @@ * * @module ProjectionProjectRepository */ -import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; +import { + GitHubAccountRef, + IsoDateTime, + ModelSelection, + ProjectId, + ProjectScript, +} from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; @@ -19,6 +25,7 @@ export const ProjectionProject = Schema.Struct({ title: Schema.String, workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), + gitHubAccount: Schema.NullOr(GitHubAccountRef), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 15612908079a..e22f3bc92dd8 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -18,6 +18,7 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro title: "Project", workspaceRoot: "/repo/project", defaultModelSelection: null, + gitHubAccount: null, scripts, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 74a4de594a15..b186e9b8a007 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -428,6 +428,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { workspaceRoot: "/workspace", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: now, updatedAt: now, @@ -586,6 +587,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { workspaceRoot: "/workspace", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: now, updatedAt: now, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0b233d7d9f7a..12b01784d2c4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -153,6 +153,7 @@ const makeDefaultOrchestrationReadModel = () => { title: "Default Project", workspaceRoot: "/tmp/default-project", defaultModelSelection, + gitHubAccount: null, scripts: [], createdAt: now, updatedAt: now, @@ -5687,6 +5688,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { title: "Project A", workspaceRoot: "/tmp/project-a", defaultModelSelection, + gitHubAccount: null, scripts: [], createdAt: now, updatedAt: now, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0d36e99bc96..505221096483 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,6 +33,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; +import * as GitHubAccountResolver from "./sourceControl/GitHubAccountResolver.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -558,6 +559,12 @@ export const makeServerLayer = Layer.unwrap( ); return serverApplicationLayer.pipe( + // Ambient per-project GitHub account resolver. Placed above + // `RuntimeServicesLive` (which provides `ProjectionSnapshotQuery`) and + // the global `VcsProcess.layer` so it can consume both; kept in the + // merged runtime context so `GitHubCli.execute` finds it via + // `Effect.serviceOption`. + Layer.provideMerge(GitHubAccountResolver.layer), Layer.provideMerge(RuntimeServicesLive), Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpResponseCompressionLive), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b8102bda9add..6777daae5b13 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -147,6 +147,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa title: "Startup Project", workspaceRoot: "/tmp/startup-project", defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), + gitHubAccount: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/server/src/sourceControl/GitHubAccountResolver.test.ts b/apps/server/src/sourceControl/GitHubAccountResolver.test.ts new file mode 100644 index 000000000000..19c1d5b2a4d5 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubAccountResolver.test.ts @@ -0,0 +1,241 @@ +import { assert, afterEach, describe, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + ProjectId, + ThreadId, + type GitHubAccountRef, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; + +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { + GitHubAccountResolver, + gitHubAccountGhEnv, + layer as resolverLayer, +} from "./GitHubAccountResolver.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +const projectShell = (input: { + readonly id: string; + readonly workspaceRoot: string; + readonly gitHubAccount: GitHubAccountRef | null; +}): OrchestrationProjectShell => ({ + id: ProjectId.make(input.id), + title: input.id, + workspaceRoot: input.workspaceRoot, + defaultModelSelection: null, + gitHubAccount: input.gitHubAccount, + scripts: [], + createdAt: NOW, + updatedAt: NOW, +}); + +const threadShell = (input: { + readonly id: string; + readonly projectId: string; + readonly worktreePath: string | null; +}): OrchestrationThreadShell => + ({ + id: ThreadId.make(input.id), + projectId: ProjectId.make(input.projectId), + worktreePath: input.worktreePath, + branch: null, + latestTurn: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + createdAt: NOW, + updatedAt: NOW, + // Fields the resolver never reads are omitted; cast keeps the fixture terse. + }) as unknown as OrchestrationThreadShell; + +const shellSnapshot = (input: { + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +}): OrchestrationShellSnapshot => ({ + snapshotSequence: 1, + projects: input.projects, + threads: input.threads, + updatedAt: NOW, +}); + +const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockRun = vi.fn(); +const mockGetShellSnapshot = vi.fn(); + +const layerFor = (snapshot: OrchestrationShellSnapshot) => { + mockGetShellSnapshot.mockReturnValue(Effect.succeed(snapshot)); + return resolverLayer.pipe( + Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run: mockRun })), + Layer.provide(Layer.mock(ProjectionSnapshotQuery)({ getShellSnapshot: mockGetShellSnapshot })), + ); +}; + +afterEach(() => { + mockRun.mockReset(); + mockGetShellSnapshot.mockReset(); +}); + +describe("gitHubAccountGhEnv", () => { + it("uses GH_TOKEN for github.com", () => { + assert.deepEqual( + gitHubAccountGhEnv({ account: { host: "github.com", login: "octo" }, token: "t0ken" }), + { GH_HOST: "github.com", GH_TOKEN: "t0ken" }, + ); + }); + + it("uses GH_ENTERPRISE_TOKEN for enterprise hosts", () => { + assert.deepEqual( + gitHubAccountGhEnv({ account: { host: "ghe.corp", login: "octo" }, token: "t0ken" }), + { GH_HOST: "ghe.corp", GH_ENTERPRISE_TOKEN: "t0ken" }, + ); + }); +}); + +describe("GitHubAccountResolver.resolveForCwd", () => { + it.effect("returns null when the owning project has no account", () => + Effect.gen(function* () { + const resolver = yield* GitHubAccountResolver; + const resolved = yield* resolver.resolveForCwd("/repos/app/src"); + assert.equal(resolved, null); + assert.equal(mockRun.mock.calls.length, 0); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [projectShell({ id: "app", workspaceRoot: "/repos/app", gitHubAccount: null })], + threads: [], + }), + ), + ), + ), + ); + + it.effect("resolves the account and token when cwd is inside the workspace root", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("gho_secret\n"))); + const resolver = yield* GitHubAccountResolver; + const resolved = yield* resolver.resolveForCwd("/repos/app/packages/x"); + assert.deepEqual(resolved, { + account: { host: "github.com", login: "octo" }, + token: "gho_secret", + }); + const call = mockRun.mock.calls[0]?.[0]; + assert.deepEqual(call?.args, [ + "auth", + "token", + "--user", + "octo", + "--hostname", + "github.com", + ]); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [ + projectShell({ + id: "app", + workspaceRoot: "/repos/app", + gitHubAccount: { host: "github.com", login: "octo" }, + }), + ], + threads: [], + }), + ), + ), + ), + ); + + it.effect("resolves via a thread worktree that lives outside the workspace root", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput("gho_wt\n"))); + const resolver = yield* GitHubAccountResolver; + const resolved = yield* resolver.resolveForCwd("/state/worktrees/wt-1/sub"); + assert.deepEqual(resolved, { + account: { host: "github.com", login: "octo" }, + token: "gho_wt", + }); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [ + projectShell({ + id: "app", + workspaceRoot: "/repos/app", + gitHubAccount: { host: "github.com", login: "octo" }, + }), + ], + threads: [ + threadShell({ id: "t1", projectId: "app", worktreePath: "/state/worktrees/wt-1" }), + ], + }), + ), + ), + ), + ); + + it.effect("does not match a sibling path with a shared prefix", () => + Effect.gen(function* () { + const resolver = yield* GitHubAccountResolver; + const resolved = yield* resolver.resolveForCwd("/repos/app-2/src"); + assert.equal(resolved, null); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [ + projectShell({ + id: "app", + workspaceRoot: "/repos/app", + gitHubAccount: { host: "github.com", login: "octo" }, + }), + ], + threads: [], + }), + ), + ), + ), + ); + + it.effect("returns null when gh cannot mint a token", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput(""))); + const resolver = yield* GitHubAccountResolver; + const resolved = yield* resolver.resolveForCwd("/repos/app"); + assert.equal(resolved, null); + }).pipe( + Effect.provide( + layerFor( + shellSnapshot({ + projects: [ + projectShell({ + id: "app", + workspaceRoot: "/repos/app", + gitHubAccount: { host: "github.com", login: "octo" }, + }), + ], + threads: [], + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubAccountResolver.ts b/apps/server/src/sourceControl/GitHubAccountResolver.ts new file mode 100644 index 000000000000..0be91d3cea22 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubAccountResolver.ts @@ -0,0 +1,187 @@ +/** + * GitHubAccountResolver - resolves the per-project GitHub account (if any) + * that a `gh`/`git` command running in a given `cwd` should act as. + * + * Projects can attach one of the accounts already authenticated in the `gh` + * CLI (see `gh auth status`). This service maps a working directory back to + * its project — either directly by workspace root or via a thread worktree — + * reads the attached account, and materializes the token for that account with + * `gh auth token --user --hostname `. The token is fetched on + * demand (never persisted by T3) and cached briefly so back-to-back commands + * don't re-shell for every invocation. + * + * Callers merge {@link gitHubAccountGhEnv} into their `gh` process env. Because + * this is resolved through {@link Effect.serviceOption}, `gh` commands work + * unchanged when the resolver is not provided (tests, minimal layers) — they + * simply fall back to the machine-global active account. + * + * @module GitHubAccountResolver + */ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import type { GitHubAccountRef } from "@t3tools/contracts"; + +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +const TOKEN_TTL_MS = 5 * 60_000; +const GH_AUTH_TOKEN_TIMEOUT_MS = 10_000; + +export interface ResolvedGitHubAccount { + readonly account: GitHubAccountRef; + readonly token: string; +} + +export class GitHubAccountResolver extends Context.Service< + GitHubAccountResolver, + { + /** + * Resolve the GitHub account a command running in `cwd` should act as, + * along with a freshly materialized token. Returns null when the owning + * project has no account attached, when the token cannot be resolved, or + * when `cwd` maps to no known project — the caller then falls back to the + * machine-global active account. + */ + readonly resolveForCwd: (cwd: string) => Effect.Effect; + } +>()("t3/sourceControl/GitHubAccountResolver") {} + +/** + * Environment overrides that make a `gh` invocation act as `resolved`'s + * account. `github.com` uses `GH_TOKEN`; any other host is treated as a GitHub + * Enterprise host and uses `GH_ENTERPRISE_TOKEN`. Merged onto the parent env by + * the process runner, so only the delta is returned. + */ +export function gitHubAccountGhEnv(resolved: ResolvedGitHubAccount): NodeJS.ProcessEnv { + const host = resolved.account.host; + const tokenVar = host === "github.com" ? "GH_TOKEN" : "GH_ENTERPRISE_TOKEN"; + return { + GH_HOST: host, + [tokenVar]: resolved.token, + }; +} + +/** + * True when `cwd` is `base` itself or lives beneath it. Compares normalized, + * separator-terminated paths so `/a/repo` does not match `/a/repo-2`. + */ +function isWithin(cwd: string, base: string): boolean { + if (base.length === 0) return false; + const normalizedBase = base.replace(/[/\\]+$/, ""); + if (cwd === normalizedBase) return true; + return cwd.startsWith(`${normalizedBase}/`) || cwd.startsWith(`${normalizedBase}\\`); +} + +interface CachedToken { + readonly token: string; + readonly expiresAtMs: number; +} + +function cacheKey(account: GitHubAccountRef): string { + return `${account.host}\n${account.login}`; +} + +export const make = Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const process = yield* VcsProcess.VcsProcess; + const tokenCache = yield* Ref.make(new Map()); + + /** + * Find the account attached to the project owning `cwd`. Matches the longest + * project workspace root or thread worktree path that contains `cwd`, so a + * worktree checked out outside the project root still resolves. + */ + const findAccountForCwd = (cwd: string): Effect.Effect => + snapshotQuery.getShellSnapshot().pipe( + Effect.map((snapshot) => { + const accountByProjectId = new Map( + snapshot.projects + .filter((project) => project.gitHubAccount !== null) + .map((project) => [project.id, project.gitHubAccount] as const), + ); + + let bestPathLength = -1; + let bestAccount: GitHubAccountRef | null = null; + const consider = (basePath: string, account: GitHubAccountRef | null) => { + if (account === null) return; + if (!isWithin(cwd, basePath)) return; + if (basePath.length > bestPathLength) { + bestPathLength = basePath.length; + bestAccount = account; + } + }; + + for (const project of snapshot.projects) { + consider(project.workspaceRoot, project.gitHubAccount); + } + for (const thread of snapshot.threads) { + if (thread.worktreePath === null) continue; + consider(thread.worktreePath, accountByProjectId.get(thread.projectId) ?? null); + } + + return bestAccount; + }), + // A projection read failure must not break the underlying git/gh command; + // fall back to the ambient account. + Effect.catch(() => Effect.succeed(null)), + ); + + const resolveToken = ( + cwd: string, + account: GitHubAccountRef, + ): Effect.Effect => + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cache = yield* Ref.get(tokenCache); + const cached = cache.get(cacheKey(account)); + if (cached !== undefined && cached.expiresAtMs > now) { + return cached.token; + } + + const result = yield* process + .run({ + operation: "GitHubAccountResolver.resolveToken", + command: "gh", + args: ["auth", "token", "--user", account.login, "--hostname", account.host], + cwd, + timeoutMs: GH_AUTH_TOKEN_TIMEOUT_MS, + }) + .pipe(Effect.option); + + if (result._tag === "None") { + return null; + } + const token = result.value.stdout.trim(); + if (token.length === 0) { + return null; + } + + yield* Ref.update(tokenCache, (current) => { + const next = new Map(current); + next.set(cacheKey(account), { token, expiresAtMs: now + TOKEN_TTL_MS }); + return next; + }); + return token; + }); + + const resolveForCwd: GitHubAccountResolver["Service"]["resolveForCwd"] = (cwd) => + Effect.gen(function* () { + const account = yield* findAccountForCwd(cwd); + if (account === null) { + return null; + } + const token = yield* resolveToken(cwd, account); + if (token === null) { + return null; + } + return { account, token } satisfies ResolvedGitHubAccount; + }); + + return GitHubAccountResolver.of({ resolveForCwd }); +}); + +export const layer = Layer.effect(GitHubAccountResolver, make); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5e..8648b647f511 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,6 +1,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -12,6 +13,7 @@ import { } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { GitHubAccountResolver, gitHubAccountGhEnv } from "./GitHubAccountResolver.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, @@ -307,15 +309,24 @@ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; const execute: GitHubCli["Service"]["execute"] = (input) => - process - .run({ - operation: "GitHubCli.execute", - command: "gh", - args: input.args, - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }) - .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + Effect.serviceOption(GitHubAccountResolver).pipe( + Effect.flatMap((resolverOption) => + Option.isNone(resolverOption) + ? Effect.succeed(null) + : resolverOption.value.resolveForCwd(input.cwd), + ), + Effect.flatMap((resolved) => + process.run({ + operation: "GitHubCli.execute", + command: "gh", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(resolved !== null ? { env: gitHubAccountGhEnv(resolved) } : {}), + }), + ), + Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error)), + ); return GitHubCli.of({ execute, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b5d5d3a55f8f..cfe140fea545 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -82,6 +82,15 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { }); } +function parseGitHubAccounts(input: SourceControlAuthProbeInput) { + return parseGitHubAuthStatus(input.stdout).accounts.map((account) => ({ + host: account.host, + login: account.account, + authenticated: account.authenticated, + active: account.active, + })); +} + export const discovery = { type: "cli", kind: "github", @@ -90,6 +99,7 @@ export const discovery = { versionArgs: ["--version"], authArgs: ["auth", "status", "--json", "hosts"], parseAuth: parseGitHubAuth, + parseAccounts: parseGitHubAccounts, installHint: "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).", } satisfies SourceControlCliDiscoverySpec; diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index e3a6bd1fb205..7f12eaf1fba7 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -1,4 +1,5 @@ import type { + SourceControlAccountInfo, SourceControlProviderAuth, SourceControlProviderDiscoveryItem, SourceControlProviderInfo, @@ -34,6 +35,14 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; + /** + * Parse all accounts the CLI knows about (e.g. every `gh auth status` + * account), so a project can be attached to a specific one. Optional — only + * providers with multi-account support implement it. + */ + readonly parseAccounts?: ( + input: SourceControlAuthProbeInput, + ) => ReadonlyArray; readonly refineUnknownRemote?: ( input: SourceControlUnknownRemoteRefinementInput, ) => SourceControlProviderInfo | null; @@ -254,6 +263,9 @@ export function probeSourceControlProvider(input: { ({ ...item, auth: spec.parseAuth(result), + ...(spec.parseAccounts !== undefined + ? { accounts: spec.parseAccounts(result) } + : {}), }) satisfies SourceControlProviderDiscoveryItem, ), Effect.catch((cause) => diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 59784bf8fac7..5339c395d5d9 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1057,6 +1057,7 @@ function makeProject(overrides: Partial = {}): Project { createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], + gitHubAccount: null, ...rest, }; } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index be93f510c97f..568e292c4d7c 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -12,7 +12,11 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { + GitHubAccountRef, + ScopedThreadRef, + SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -94,6 +98,7 @@ import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../s import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; +import { sourceControlEnvironment } from "../state/sourceControl"; import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { @@ -131,6 +136,7 @@ import { snoozeWakeLabel, type SnoozePreset, } from "./Sidebar.snooze"; +import { GitHubIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; @@ -1005,6 +1011,123 @@ function latestTurnDiff( return null; } +// Sentinel Select value for "attach nothing, use the machine-global default +// gh account". Real accounts encode host+login into an opaque key so the +// Select can round-trip a value back to a GitHubAccountRef. +const GITHUB_DEFAULT_ACCOUNT_VALUE = "__default__"; + +function githubAccountValue(account: { readonly host: string; readonly login: string }): string { + return `${account.host}${account.login}`; +} + +// github.com is the overwhelmingly common host, so hide it to keep the label +// short; surface the host only for GitHub Enterprise / self-hosted accounts. +function githubAccountLabel(account: { + readonly host: string; + readonly login: string; +}): string { + return account.host === "github.com" ? account.login : `${account.login} · ${account.host}`; +} + +// Per-project GitHub account selector. Reads the same source-control discovery +// query the settings panel uses (scoped to the member's environment) and only +// offers authenticated accounts. Selecting an option attaches that account to +// the project; "Use default account" clears it back to the machine default. +function ProjectGitHubAccountField({ + member, + onSelect, +}: { + readonly member: SidebarProjectGroupMember; + readonly onSelect: ( + member: SidebarProjectGroupMember, + account: GitHubAccountRef | null, + ) => void; +}) { + const discovery = useEnvironmentQuery( + sourceControlEnvironment.discovery({ environmentId: member.environmentId, input: {} }), + ); + const accounts = useMemo( + () => + ( + discovery.data?.sourceControlProviders.find((provider) => provider.kind === "github") + ?.accounts ?? [] + ).filter((account) => account.authenticated), + [discovery.data], + ); + const accountByValue = useMemo( + () => new Map(accounts.map((account) => [githubAccountValue(account), account] as const)), + [accounts], + ); + const current = member.gitHubAccount; + const isLoading = discovery.isPending && discovery.data === null; + + const label = GitHub account; + + if (accounts.length === 0) { + return ( + + ); + } + + const currentValue = current ? githubAccountValue(current) : GITHUB_DEFAULT_ACCOUNT_VALUE; + + return ( + + ); +} + export default function SidebarV2() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); @@ -1346,6 +1469,32 @@ export default function SidebarV2() { [updateProject], ); + const updateProjectGitHubAccount = useCallback( + async (member: SidebarProjectGroupMember, account: GitHubAccountRef | null) => { + const current = member.gitHubAccount; + const unchanged = + account === null + ? current === null + : current !== null && current.host === account.host && current.login === account.login; + if (unchanged) return; + const result = await updateProject({ + environmentId: member.environmentId, + input: { projectId: member.id, gitHubAccount: account }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update GitHub account", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [updateProject], + ); + const updateProjectGroupingPreference = useCallback( (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { const overrideKey = deriveProjectGroupingOverrideKey(member); @@ -2689,6 +2838,10 @@ export default function SidebarV2() { + {projectActionsTarget.memberProjects.length > 1 ? (
diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca09128..36c1b3d8063f 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -46,6 +46,7 @@ function makeProject(overrides: Partial = {}): Project { createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", scripts: [], + gitHubAccount: null, ...overrides, }; } diff --git a/docs/integrations/source-control-providers.md b/docs/integrations/source-control-providers.md index c496d5516a68..d393d3fdd5d4 100644 --- a/docs/integrations/source-control-providers.md +++ b/docs/integrations/source-control-providers.md @@ -51,6 +51,23 @@ The **Source Control settings** page shows you exactly what's connected: Run a quick **Rescan** after setting up a new machine or changing credentials. +### Use a Different GitHub Account per Project + +If you juggle more than one GitHub identity — a work account and a personal one, say — you can attach a specific account to each project instead of relying on the machine-wide default. + +**Set it up once:** + +1. Sign in to each account with the GitHub CLI (you can be logged into several at the same time): + ```bash + gh auth login # repeat for each account + ``` + Confirm they're all listed with `gh auth status`. +2. In T3 Code, open a project's **GitHub account** selector and pick the account it should use. Choose **Use default account** to fall back to the machine-wide active account. + +Once attached, every GitHub operation T3 Code runs for that project — creating and listing pull requests, checking out a PR, viewing or creating repositories — acts as the chosen account. T3 Code never stores your token; it asks the GitHub CLI for a short-lived token for that account only when it needs one, and it never changes which account is globally active, so projects on different accounts don't interfere with each other. + +> **Note:** This currently scopes the `gh` CLI operations above. Plain `git` pushes/fetches over HTTPS still use whatever credentials your Git credential helper provides. Push via SSH or a matching credential helper if you need the push itself to run as a specific account. + ## Getting Started ### For GitHub (Recommended for most users) diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 11b497424604..6f43afd6dd9b 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -102,6 +102,7 @@ describe("add project shared logic", () => { updatedAt: "2026-01-01T00:00:00.000Z", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], }, { @@ -113,6 +114,7 @@ describe("add project shared logic", () => { updatedAt: "2026-01-01T00:00:00.000Z", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], }, ]; diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index e08fd9e552f2..cc0f37274d60 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -115,6 +115,7 @@ const SNAPSHOT: OrchestrationShellSnapshot = { workspaceRoot: "/repo", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", @@ -125,6 +126,7 @@ const SNAPSHOT: OrchestrationShellSnapshot = { workspaceRoot: "/other-repo", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index fdccc4c47dd8..518b4c26a6a8 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -18,6 +18,7 @@ const stubProject = { workspaceRoot: "/workspace/test", repositoryIdentity: null, defaultModelSelection: null, + gitHubAccount: null, scripts: [], createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b947bd63e4ca..bd89e1e4e0bd 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -6,6 +6,7 @@ import * as SchemaTransformation from "effect/SchemaTransformation"; import * as Struct from "effect/Struct"; import { ProviderOptionSelections } from "./model.ts"; import { RepositoryIdentity } from "./environment.ts"; +import { GitHubAccountRef } from "./sourceControl.ts"; import { ApprovalRequestId, CheckpointRef, @@ -214,6 +215,13 @@ export const OrchestrationProject = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), + /** + * GitHub account (from `gh auth status`) to run this project's GitHub + * operations as. Null means fall back to the machine-global active account. + */ + gitHubAccount: Schema.NullOr(GitHubAccountRef).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -391,6 +399,9 @@ export const OrchestrationProjectShell = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), + gitHubAccount: Schema.NullOr(GitHubAccountRef).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -520,6 +531,7 @@ export const ProjectCreateCommand = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + gitHubAccount: Schema.optional(Schema.NullOr(GitHubAccountRef)), createdAt: IsoDateTime, }); @@ -530,6 +542,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + gitHubAccount: Schema.optional(Schema.NullOr(GitHubAccountRef)), scripts: Schema.optional(Schema.Array(ProjectScript)), }); @@ -916,6 +929,7 @@ export const ProjectCreatedPayload = Schema.Struct({ workspaceRoot: TrimmedNonEmptyString, repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), + gitHubAccount: Schema.optional(Schema.NullOr(GitHubAccountRef)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -927,6 +941,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ workspaceRoot: Schema.optional(TrimmedNonEmptyString), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), + gitHubAccount: Schema.optional(Schema.NullOr(GitHubAccountRef)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161f..8b79c02229b7 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -121,6 +121,18 @@ export const SourceControlProviderAuth = Schema.Struct({ }); export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; +/** + * Reference to a specific GitHub account already authenticated in the `gh` + * CLI (one of the accounts surfaced by `gh auth status`). Projects attach one + * of these so their GitHub operations run as that account without touching the + * machine-global active account. + */ +export const GitHubAccountRef = Schema.Struct({ + host: TrimmedNonEmptyString, + login: TrimmedNonEmptyString, +}); +export type GitHubAccountRef = typeof GitHubAccountRef.Type; + const SourceControlDiscoverySharedFields = { label: TrimmedNonEmptyString, executable: Schema.optional(TrimmedNonEmptyString), @@ -137,10 +149,28 @@ export const VcsDiscoveryItem = Schema.Struct({ }); export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; +/** + * An account authenticated in a source-control CLI (currently only GitHub via + * `gh auth status`). Surfaced so a project can be attached to a specific + * account. `active` marks the machine-global default account. + */ +export const SourceControlAccountInfo = Schema.Struct({ + host: TrimmedNonEmptyString, + login: TrimmedNonEmptyString, + authenticated: Schema.Boolean, + active: Schema.Boolean, +}); +export type SourceControlAccountInfo = typeof SourceControlAccountInfo.Type; + export const SourceControlProviderDiscoveryItem = Schema.Struct({ kind: SourceControlProviderKind, ...SourceControlDiscoverySharedFields, auth: SourceControlProviderAuth, + /** + * All accounts the provider CLI is aware of. Lets a project pick which + * account to act as. Absent for providers without multi-account discovery. + */ + accounts: Schema.optional(Schema.Array(SourceControlAccountInfo)), }); export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type;