Skip to content
This repository was archived by the owner on Oct 21, 2026. It is now read-only.

Commit 0209974

Browse files
committed
feat(server): select GitHub CLI accounts per project
- Run GitHub commands with the selected account’s token without switching `gh`’s active login - Add environment and project account settings, discovery, and guidance
1 parent f5ef0dd commit 0209974

17 files changed

Lines changed: 557 additions & 25 deletions

‎apps/server/src/pullRequest/PullRequestProviderRegistry.ts‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type { SourceControlProviderKind } from "@t3tools/contracts";
55

66
import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
77
import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
8-
import * as GitHubCli from "../sourceControl/GitHubCli.ts";
98
import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
109
import * as GitLabCli from "../sourceControl/GitLabCli.ts";
1110
import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
@@ -58,12 +57,7 @@ export const make = Effect.map(
5857
);
5958

6059
export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
61-
Layer.provide(
62-
GitHubPullRequestCli.layer.pipe(
63-
Layer.provide(GitHubCli.layer),
64-
Layer.provide(GitHubGraphQlBudget.layer),
65-
),
66-
),
60+
Layer.provide(GitHubPullRequestCli.layer.pipe(Layer.provide(GitHubGraphQlBudget.layer))),
6761
Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
6862
Layer.provide(ForgejoCli.layer),
6963
Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),

‎apps/server/src/server.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import * as CheckpointStore from "./checkpointing/CheckpointStore.ts";
6161
import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
6262
import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
6363
import * as GitHubCli from "./sourceControl/GitHubCli.ts";
64+
import * as GitHubCliAccountSelection from "./sourceControl/GitHubCliAccountSelection.ts";
6465
import * as GitLabCli from "./sourceControl/GitLabCli.ts";
6566
import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
6667
import * as TextGeneration from "./textGeneration/TextGeneration.ts";
@@ -274,6 +275,11 @@ const ProviderLayerLive = ProviderServiceLive.pipe(
274275

275276
const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive));
276277

278+
// Every server-side GitHub command resolves its checkout's selected `gh` login through this one instance.
279+
const GitHubCliLayerLive = GitHubCli.layer.pipe(
280+
Layer.provide(GitHubCliAccountSelection.layer.pipe(Layer.provide(ServerSettingsLayerLive))),
281+
);
282+
277283
const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe(
278284
Layer.provide(VcsProjectConfig.layer),
279285
);
@@ -283,7 +289,7 @@ const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.lay
283289
Layer.mergeAll(
284290
AzureDevOpsCli.layer,
285291
BitbucketApi.layer,
286-
GitHubCli.layer,
292+
GitHubCliLayerLive,
287293
GitLabCli.layer,
288294
ForgejoCli.layer,
289295
),
@@ -329,7 +335,7 @@ const RepositoryIdentityResolverLayerLive = Layer.effect(
329335
).pipe(Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(ProcessRunner.layer));
330336

331337
const PullRequestServiceLive = PullRequestService.layer.pipe(
332-
Layer.provide(PullRequestProviderRegistry.layer),
338+
Layer.provide(PullRequestProviderRegistry.layer.pipe(Layer.provide(GitHubCliLayerLive))),
333339
// Where the viewed-file marks live for a host that keeps none of its own.
334340
Layer.provide(PullRequestFilesViewed.layer),
335341
Layer.provide(PullRequestReadCache.layer),
@@ -493,7 +499,11 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
493499
// `GitHubCli` is the registry's own instance, exposed because the asset route fetches
494500
// GitHub-hosted pull request media with the repository's credential.
495501
Layer.provideMerge(
496-
Layer.mergeAll(SourceControlProviderRegistryLayerLive, PullRequestServiceLive, GitHubCli.layer),
502+
Layer.mergeAll(
503+
SourceControlProviderRegistryLayerLive,
504+
PullRequestServiceLive,
505+
GitHubCliLayerLive,
506+
),
497507
),
498508
Layer.provideMerge(GitLayerLive),
499509
Layer.provideMerge(VcsLayerLive),

‎apps/server/src/serverSettings.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
358358
"sourceControlWriterModelSelection",
359359
"textGenerationModelSelection",
360360
"pullRequestMergeMethod",
361+
"githubCliAccount",
361362
]);
362363

363364
// Preserve both enabled states because provider history cannot recover a new opt-in.

‎apps/server/src/sourceControl/GitHubCli.test.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,92 @@ describe("GitHubCli.layer", () => {
243243
}).pipe(Effect.provide(layer)),
244244
);
245245

246+
it.effect("runs commands as the checkout's selected account without switching gh", () =>
247+
Effect.gen(function* () {
248+
const commands: Array<{ args: ReadonlyArray<string>; env: NodeJS.ProcessEnv | undefined }> =
249+
[];
250+
const gh = yield* GitHubCli.make.pipe(
251+
Effect.provideService(GitHubCli.GitHubCliAccountSelection, {
252+
forCwd: (cwd) =>
253+
Effect.succeed(
254+
cwd === "/work"
255+
? { host: "github.com", login: "work-login" }
256+
: cwd === "/enterprise"
257+
? { host: "github.example.test", login: "enterprise-login" }
258+
: null,
259+
),
260+
}),
261+
Effect.provideService(VcsProcess.VcsProcess, {
262+
run: (input) =>
263+
Effect.sync(() => {
264+
commands.push({ args: input.args, env: input.env });
265+
return input.args[0] === "auth"
266+
? processOutput(`token-for-${input.args[5]}\n`)
267+
: processOutput("");
268+
}),
269+
}),
270+
);
271+
yield* gh.execute({ cwd: "/work", args: ["pr", "merge", "1"] });
272+
yield* gh.execute({ cwd: "/work", args: ["pr", "close", "2"] });
273+
yield* gh.execute({ cwd: "/enterprise", args: ["pr", "merge", "3"] });
274+
yield* gh.execute({ cwd: "/personal", args: ["pr", "merge", "4"] });
275+
276+
expect(commands.map((command) => command.args.join(" "))).toEqual([
277+
"auth token --hostname github.com --user work-login",
278+
"pr merge 1",
279+
"pr close 2",
280+
"auth token --hostname github.example.test --user enterprise-login",
281+
"pr merge 3",
282+
"pr merge 4",
283+
]);
284+
// The lookup ignores an ambient env token, which gh would print instead.
285+
expect(commands[0]?.env).toMatchObject({ GH_TOKEN: "", GITHUB_TOKEN: "" });
286+
expect(commands[1]?.env).toEqual({
287+
GH_TOKEN: "token-for-work-login",
288+
GITHUB_TOKEN: "token-for-work-login",
289+
});
290+
expect(commands[4]?.env).toEqual({
291+
GH_ENTERPRISE_TOKEN: "token-for-enterprise-login",
292+
GITHUB_ENTERPRISE_TOKEN: "token-for-enterprise-login",
293+
});
294+
expect(commands[5]?.env).toBeUndefined();
295+
}).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
296+
);
297+
298+
it.effect("fails instead of falling back when the selected account is signed out", () =>
299+
Effect.gen(function* () {
300+
const commands: string[] = [];
301+
const gh = yield* GitHubCli.make.pipe(
302+
Effect.provideService(GitHubCli.GitHubCliAccountSelection, {
303+
forCwd: () => Effect.succeed({ host: "github.com", login: "gone" }),
304+
}),
305+
Effect.provideService(VcsProcess.VcsProcess, {
306+
run: (input) => {
307+
commands.push(input.args.join(" "));
308+
return input.args[0] === "auth"
309+
? Effect.fail(
310+
new VcsProcessExitError({
311+
operation: "GitHubCli.accountToken",
312+
command: "gh",
313+
cwd: "/work",
314+
exitCode: 1,
315+
failureKind: "authentication",
316+
detail: "no oauth token found for github.com account gone",
317+
}),
318+
)
319+
: Effect.succeed(processOutput(""));
320+
},
321+
}),
322+
);
323+
const failure = yield* gh
324+
.execute({ cwd: "/work", args: ["pr", "merge", "1"] })
325+
.pipe(Effect.flip);
326+
expect(failure._tag).toBe("GitHubCliAccountUnavailableError");
327+
expect(failure.detail).toContain("gone on github.com");
328+
expect(commands).toEqual(["auth token --hostname github.com --user gone"]);
329+
}).pipe(Effect.provide(Layer.merge(GitHubGraphQlBudget.layer, SourceControlRateLimit.layer))),
330+
);
331+
246332
it("does not classify a missing cwd as an unavailable gh executable", () => {
247333
const context = { command: "gh", cwd: "/repo" } as const;
248334
const missingCwd = new VcsProcessSpawnError({

‎apps/server/src/sourceControl/GitHubCli.ts‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema";
1313

1414
import {
1515
TrimmedNonEmptyString,
16+
type GitHubCliAccount,
1617
type SourceControlRepositoryVisibility,
1718
type VcsError,
1819
} from "@t3tools/contracts";
@@ -35,6 +36,24 @@ export const PinnedGitHubCredential = Context.Reference<{
3536
readonly credentialFingerprint: string;
3637
} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null });
3738

39+
/**
40+
* The `gh` login a command's checkout is set to use, or null for the CLI's
41+
* active login. Read once when the service is built; the server provides it
42+
* from settings (see `GitHubCliAccountSelection.ts`).
43+
*/
44+
export class GitHubCliAccountSelection extends Context.Reference<{
45+
readonly forCwd: (cwd: string) => Effect.Effect<GitHubCliAccount | null>;
46+
}>("t3/sourceControl/GitHubCliAccountSelection", {
47+
defaultValue: () => ({ forCwd: () => Effect.succeed(null) }),
48+
}) {}
49+
50+
/** gh reads github.com and GHE.com tenancies from GH_TOKEN, every other host from GH_ENTERPRISE_TOKEN. */
51+
function tokenEnv(host: string, token: string): NodeJS.ProcessEnv {
52+
return host === "github.com" || host.endsWith(".ghe.com")
53+
? { GH_TOKEN: token, GITHUB_TOKEN: token }
54+
: { GH_ENTERPRISE_TOKEN: token, GITHUB_ENTERPRISE_TOKEN: token };
55+
}
56+
3857
export const AllowGitHubReserve = Context.Reference<boolean>(
3958
"t3/sourceControl/AllowGitHubReserve",
4059
{ defaultValue: () => false },
@@ -117,6 +136,19 @@ export class GitHubCliRateLimitError extends Schema.TaggedError<GitHubCliRateLim
117136
}
118137
}
119138

139+
export class GitHubCliAccountUnavailableError extends Schema.TaggedError<GitHubCliAccountUnavailableError>()(
140+
"GitHubCliAccountUnavailableError",
141+
{ ...gitHubCliFailureFields, host: Schema.String, login: Schema.String },
142+
) {
143+
get detail(): string {
144+
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.`;
145+
}
146+
147+
override get message(): string {
148+
return `GitHub CLI failed in execute: ${this.detail}`;
149+
}
150+
}
151+
120152
export class GitHubPullRequestNotFoundError extends Schema.TaggedError<GitHubPullRequestNotFoundError>()(
121153
"GitHubPullRequestNotFoundError",
122154
gitHubCliFailureFields,
@@ -204,6 +236,7 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedError<GitHubReposi
204236
export const GitHubCliError = Schema.Union([
205237
GitHubCliUnavailableError,
206238
GitHubCliAuthenticationError,
239+
GitHubCliAccountUnavailableError,
207240
GitHubCliRateLimitError,
208241
GitHubPullRequestNotFoundError,
209242
GitHubCliCommandError,
@@ -399,6 +432,34 @@ export const make = Effect.gen(function* () {
399432
const process = yield* VcsProcess.VcsProcess;
400433
const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget;
401434
const limits = yield* SourceControlRateLimit.SourceControlRateLimit;
435+
const accountSelection = yield* GitHubCliAccountSelection;
436+
437+
// `gh auth token --user` reads one login's token from the CLI's store without
438+
// switching the active login. Blank env tokens so an ambient GH_TOKEN cannot answer for it.
439+
const accountTokens = yield* Cache.makeWith(
440+
(key: string) => {
441+
const [host = "", login = ""] = key.split("\0");
442+
return process
443+
.run({
444+
operation: "GitHubCli.accountToken",
445+
command: "gh",
446+
args: ["auth", "token", "--hostname", host, "--user", login],
447+
cwd: globalThis.process.cwd(),
448+
timeoutMs: DEFAULT_TIMEOUT_MS,
449+
env: { ...tokenEnv(host, ""), GH_DEBUG: "" },
450+
})
451+
.pipe(
452+
Effect.map((result) => result.stdout.trim()),
453+
Effect.flatMap((token) =>
454+
token ? Effect.succeed(Redacted.make(token)) : Effect.fail(null),
455+
),
456+
);
457+
},
458+
{
459+
capacity: 16,
460+
timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(1) : Duration.zero),
461+
},
462+
);
402463

403464
const executeRaw: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.executeRaw")(
404465
function* (input) {
@@ -411,9 +472,33 @@ export const make = Effect.gen(function* () {
411472
});
412473
}
413474
const token = credential === null ? undefined : Redacted.value(credential.token);
475+
// A pinned credential already names its account; otherwise use the checkout's selection.
476+
// A selected login that cannot be read fails the command rather than running as another.
477+
const account = credential === null ? yield* accountSelection.forCwd(input.cwd) : null;
478+
const accountToken =
479+
account === null
480+
? null
481+
: yield* Cache.get(accountTokens, `${account.host.toLowerCase()}\0${account.login}`).pipe(
482+
// Only the token is kept. Never attach credential lookup output to an error.
483+
Effect.mapError(
484+
() =>
485+
new GitHubCliAccountUnavailableError({
486+
command: "gh",
487+
cwd: input.cwd,
488+
host: account.host,
489+
login: account.login,
490+
cause: new Error("`gh auth token --user` did not return a token."),
491+
}),
492+
),
493+
);
414494
const env =
415495
credential === null
416-
? input.env
496+
? account === null || accountToken === null
497+
? input.env
498+
: {
499+
...tokenEnv(account.host.toLowerCase(), Redacted.value(accountToken)),
500+
...input.env,
501+
}
417502
: {
418503
...input.env,
419504
GH_HOST: credential.host,

0 commit comments

Comments
 (0)