authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-06 13:14:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 13:14:14-07:00
log9ac8f33f1685d1d6e843b657829d0d7462a13813
treea5acafb7c0ccf8c649dcc6070e788785e2c553c8
parent649418f01fcb4ae5ebbc07739e0bfee8fae3aaec
signature Signed by PGP key B5690EEEBB952194

feat: choose the GitHub account per host, save a GitHub token, and fewer reads per PR action (#16322)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

34 files changed, 1322 insertions(+), 359 deletions(-)

apps/server/scripts/evaluate-thread-titles.ts+3-1
...@@ -157,9 +157,11 @@ await Effect.runPromise(...@@ -157,9 +157,11 @@ await Effect.runPromise(
157 ForgejoCli.layer,157 ForgejoCli.layer,
158 AzureDevOpsCli.layer,158 AzureDevOpsCli.layer,
159 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.159 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
160 BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())),160 BitbucketApi.layer,
161 ),161 ),
162 ),162 ),
163 // Default settings: no saved Bitbucket token, gh's own GitHub account choice.
164 Layer.provide(ServerSettings.layerTest()),
163 Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))),165 Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))),
164 Layer.provide(GitVcsDriver.layer),166 Layer.provide(GitVcsDriver.layer),
165 Layer.provide(VcsProcess.layer),167 Layer.provide(VcsProcess.layer),
apps/server/scripts/measure-pr-preview.ts+2-1
...@@ -14,6 +14,7 @@ import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.t...@@ -14,6 +14,7 @@ import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.t
14import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts";14import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts";
15import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";15import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
16import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";16import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";
17import * as ServerSettings from "../src/serverSettings.ts";
17import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts";18import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts";
18import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts";19import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts";
19import * as VcsProcess from "../src/vcs/VcsProcess.ts";20import * as VcsProcess from "../src/vcs/VcsProcess.ts";
...@@ -64,7 +65,7 @@ const measuredApi = Layer.effect(...@@ -64,7 +65,7 @@ const measuredApi = Layer.effect(
64 });65 });
65 }),66 }),
66).pipe(67).pipe(
67 Layer.provide(GitHubCredentials.layer),68 Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))),
68 Layer.provide(GitHubGraphQlBudget.layer),69 Layer.provide(GitHubGraphQlBudget.layer),
69 Layer.provide(SourceControlRateLimit.layer),70 Layer.provide(SourceControlRateLimit.layer),
70 Layer.provide(FetchHttpClient.layer),71 Layer.provide(FetchHttpClient.layer),
apps/server/src/git/GitManager.test.ts+1-1
...@@ -4553,7 +4553,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -4553,7 +4553,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
4553 Effect.flip,4553 Effect.flip,
4554 Effect.map((error) => error.message),4554 Effect.map((error) => error.message),
4555 );4555 );
4556 expect(errorMessage).toContain("GitHub CLI (`gh`) is required");4556 expect(errorMessage).toContain("No GitHub credential on the server");
4557 }),4557 }),
4558 );4558 );
45594559
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+13-53
...@@ -18,7 +18,6 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";...@@ -18,7 +18,6 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
18import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";18import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
19import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";19import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
20import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";20import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";
21import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
2221
23const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));22const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
2423
...@@ -4041,45 +4040,6 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4041,45 +4040,6 @@ layer("GitHubPullRequestCli.layer", (it) => {
4041 }),4040 }),
4042 );4041 );
40434042
4044 it.effect("sends the base comparison's head as a variable, not inside the document", () =>
4045 Effect.gen(function* () {
4046 mockedExecute.mockReturnValue(
4047 Effect.succeed(
4048 output(
4049 encodeJson({
4050 data: {
4051 repository: {
4052 pullRequest: {
4053 viewerCanUpdateBranch: true,
4054 baseRef: { compare: { behindBy: 4 } },
4055 },
4056 },
4057 },
4058 }),
4059 ),
4060 ),
4061 );
4062 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
4063
4064 const comparison = yield* cli.getPullRequestBaseComparison({
4065 cwd: "/w",
4066 repository: "acme/web",
4067 host: "github.com",
4068 number: 7,
4069 headRef: "fork:feat/page",
4070 });
4071
4072 expect(varsAt(0)).toEqual({
4073 owner: "acme",
4074 name: "web",
4075 number: 7,
4076 headRef: "fork:feat/page",
4077 });
4078 expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true });
4079 expect(queryAt(0)).toContain(BASE_COMPARISON_GRAPHQL_QUERY.slice(0, -2));
4080 }),
4081 );
4082
4083 it.effect("stops GraphQL reads at the protected reserve until reset", () =>4043 it.effect("stops GraphQL reads at the protected reserve until reset", () =>
4084 Effect.gen(function* () {4044 Effect.gen(function* () {
4085 mockedExecute.mockReturnValue(4045 mockedExecute.mockReturnValue(
...@@ -4088,10 +4048,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4088,10 +4048,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
4088 encodeJson({4048 encodeJson({
4089 data: {4049 data: {
4090 repository: {4050 repository: {
4091 pullRequest: {4051 mergeCommitAllowed: true,
4092 viewerCanUpdateBranch: true,4052 squashMergeAllowed: false,
4093 baseRef: { compare: { behindBy: 4 } },4053 rebaseMergeAllowed: true,
4094 },4054 viewerPermission: "READ",
4055 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
4095 },4056 },
4096 rateLimit: {4057 rateLimit: {
4097 cost: 1,4058 cost: 1,
...@@ -4110,13 +4071,12 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4110,13 +4071,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
4110 repository: "acme/web",4071 repository: "acme/web",
4111 host: "github.com",4072 host: "github.com",
4112 number: 7,4073 number: 7,
4113 headRef: "fork:feat/page",
4114 } as const;4074 } as const;
41154075
4116 yield* cli.getPullRequestBaseComparison(input);4076 yield* cli.getViewerAccess(input);
4117 expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }");4077 expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }");
41184078
4119 const error = yield* Effect.flip(cli.getPullRequestBaseComparison(input));4079 const error = yield* Effect.flip(cli.getViewerAccess(input));
41204080
4121 assert.strictEqual(error._tag, "SourceControlRateLimitPausedError");4081 assert.strictEqual(error._tag, "SourceControlRateLimitPausedError");
4122 if (error._tag !== "SourceControlRateLimitPausedError") return;4082 if (error._tag !== "SourceControlRateLimitPausedError") return;
...@@ -4136,10 +4096,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4136,10 +4096,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
4136 encodeJson({4096 encodeJson({
4137 data: {4097 data: {
4138 repository: {4098 repository: {
4139 pullRequest: {4099 mergeCommitAllowed: true,
4140 viewerCanUpdateBranch: true,4100 squashMergeAllowed: false,
4141 baseRef: { compare: { behindBy: 4 } },4101 rebaseMergeAllowed: true,
4142 },4102 viewerPermission: "READ",
4103 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
4143 },4104 },
4144 rateLimit: {4105 rateLimit: {
4145 cost: 1,4106 cost: 1,
...@@ -4171,12 +4132,11 @@ layer("GitHubPullRequestCli.layer", (it) => {...@@ -4171,12 +4132,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
4171 );4132 );
4172 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;4133 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
41734134
4174 yield* cli.getPullRequestBaseComparison({4135 yield* cli.getViewerAccess({
4175 cwd: "/w",4136 cwd: "/w",
4176 repository: "acme/web",4137 repository: "acme/web",
4177 host: "github.com",4138 host: "github.com",
4178 number: 7,4139 number: 7,
4179 headRef: "fork:feat/page",
4180 });4140 });
4181 const access = yield* cli.getViewerAccess({4141 const access = yield* cli.getViewerAccess({
4182 cwd: "/w",4142 cwd: "/w",
apps/server/src/pullRequest/GitHubPullRequestCli.ts+2-34
...@@ -94,8 +94,6 @@ import {...@@ -94,8 +94,6 @@ import {
94 decodePullRequestStackMembershipsJson,94 decodePullRequestStackMembershipsJson,
95 pullRequestSearchGraphQlQuery,95 pullRequestSearchGraphQlQuery,
96 PULL_REQUEST_SEARCH_MAX_ROWS,96 PULL_REQUEST_SEARCH_MAX_ROWS,
97 BASE_COMPARISON_GRAPHQL_QUERY,
98 decodeBaseComparisonJson,
99 PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,97 PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
100 PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,98 PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
101 REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,99 REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
...@@ -115,7 +113,6 @@ import {...@@ -115,7 +113,6 @@ import {
115 UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,113 UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,
116 VIEWER_PERMISSIONS_GRAPHQL_QUERY,114 VIEWER_PERMISSIONS_GRAPHQL_QUERY,
117 decodeViewerPermissionsJson,115 decodeViewerPermissionsJson,
118 type GitHubBaseComparison,
119 type GitHubPullRequestActivity,116 type GitHubPullRequestActivity,
120 type GitHubPullRequestActivityPage,117 type GitHubPullRequestActivityPage,
121 type GitHubWorkflowRunPage,118 type GitHubWorkflowRunPage,
...@@ -146,7 +143,7 @@ export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullReq...@@ -146,7 +143,7 @@ export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullReq
146 },143 },
147) {144) {
148 override get message(): string {145 override get message(): string {
149 return `GitHub CLI returned an unreadable ${this.operation} response.`;146 return `GitHub returned an unreadable ${this.operation} response.`;
150 }147 }
151}148}
152149
...@@ -159,7 +156,7 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHub...@@ -159,7 +156,7 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHub
159 },156 },
160) {157) {
161 override get message(): string {158 override get message(): string {
162 return "GitHub CLI returned no login for the authenticated account.";159 return "GitHub returned no login for the authenticated account.";
163 }160 }
164}161}
165162
...@@ -588,22 +585,6 @@ export class GitHubPullRequestCli extends Context.Service<...@@ -588,22 +585,6 @@ export class GitHubPullRequestCli extends Context.Service<
588 readonly number: number;585 readonly number: number;
589 }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestCliError>;586 }) => Effect.Effect<GitHubPullRequestStack | null, GitHubPullRequestCliError>;
590587
591 /**
592 * How far the branch trails its base, and whether this viewer may update it. Its own read
593 * because the comparison needs the head ref the detail answers with — a fork's branch is not
594 * addressable in the base repository by name alone.
595 */
596 readonly getPullRequestBaseComparison: (input: {
597 readonly cwd: string;
598 readonly repository: string;
599 readonly host: string;
600 readonly number: number;
601 /** Qualified `owner:branch`, which is the only form a fork's head resolves under. */
602 readonly headRef: string;
603 /** Manual action checks may use the quota held back from automatic reads. */
604 readonly allowReserve?: boolean | undefined;
605 }) => Effect.Effect<GitHubBaseComparison, GitHubPullRequestCliError>;
606
607 readonly getPullRequestActivity: (input: {588 readonly getPullRequestActivity: (input: {
608 readonly cwd: string;589 readonly cwd: string;
609 readonly repository: string;590 readonly repository: string;
...@@ -2170,19 +2151,6 @@ export const make = Effect.gen(function* () {...@@ -2170,19 +2151,6 @@ export const make = Effect.gen(function* () {
2170 );2151 );
2171 },2152 },
21722153
2173 getPullRequestBaseComparison: (input) => {
2174 const { owner, name } = parseRepositorySelector(input.repository);
2175 return graphqlRead({
2176 cwd: input.cwd,
2177 host: input.host,
2178 operation: "getPullRequestBaseComparison",
2179 ...(input.allowReserve === true ? { allowReserve: true } : {}),
2180 variables: { owner, name, number: input.number, headRef: input.headRef },
2181 query: BASE_COMPARISON_GRAPHQL_QUERY,
2182 decode: decodeBaseComparisonJson,
2183 });
2184 },
2185
2186 getPullRequestActivity: (input) =>2154 getPullRequestActivity: (input) =>
2187 Effect.gen(function* () {2155 Effect.gen(function* () {
2188 const { owner, name } = parseRepositorySelector(input.repository);2156 const { owner, name } = parseRepositorySelector(input.repository);
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+52-76
...@@ -7,7 +7,9 @@ import type { PullRequestReaction } from "@t3tools/contracts";...@@ -7,7 +7,9 @@ import type { PullRequestReaction } from "@t3tools/contracts";
77
8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";8import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
9import * as GitHubApi from "../sourceControl/GitHubApi.ts";9import * as GitHubApi from "../sourceControl/GitHubApi.ts";
10import * as GitHubCli from "../sourceControl/GitHubCli.ts";
10import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";11import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
12import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts";
11import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";13import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
12import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";14import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
1315
...@@ -486,8 +488,6 @@ describe("gitHubViewerPermissions", () => {...@@ -486,8 +488,6 @@ describe("gitHubViewerPermissions", () => {
486 url: "https://github.com/acme/web/actions/runs/123",488 url: "https://github.com/acme/web/actions/runs/123",
487 },489 },
488 ]),490 ]),
489 getPullRequestBaseComparison: () =>
490 Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
491 getViewerAccess: () =>491 getViewerAccess: () =>
492 Effect.succeed({492 Effect.succeed({
493 canWrite: true,493 canWrite: true,
...@@ -585,7 +585,6 @@ it.effect("does not classify same-repository gates as fork workflow approvals",...@@ -585,7 +585,6 @@ it.effect("does not classify same-repository gates as fork workflow approvals",
585 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({585 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
586 revalidateChecks: (_input, read) => read,586 revalidateChecks: (_input, read) => read,
587 getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }),587 getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }),
588 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
589 listWorkflowRunsRequiringApproval: () =>588 listWorkflowRunsRequiringApproval: () =>
590 Effect.die("same-repository pull requests must not probe fork workflow approvals"),589 Effect.die("same-repository pull requests must not probe fork workflow approvals"),
591 getViewerAccess: () =>590 getViewerAccess: () =>
...@@ -625,7 +624,6 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>...@@ -625,7 +624,6 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>
625 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({624 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
626 revalidateChecks: (_input, read) => read,625 revalidateChecks: (_input, read) => read,
627 getPullRequestDetail: () => Effect.succeed(openDetail),626 getPullRequestDetail: () => Effect.succeed(openDetail),
628 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
629 listWorkflowRunsRequiringApproval: () =>627 listWorkflowRunsRequiringApproval: () =>
630 Effect.fail(628 Effect.fail(
631 new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({629 new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({
...@@ -669,7 +667,6 @@ it.effect("propagates workflow discovery rate limits", () =>...@@ -669,7 +667,6 @@ it.effect("propagates workflow discovery rate limits", () =>
669 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({667 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
670 revalidateChecks: (_input, read) => read,668 revalidateChecks: (_input, read) => read,
671 getPullRequestDetail: () => Effect.succeed(openDetail),669 getPullRequestDetail: () => Effect.succeed(openDetail),
672 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
673 listWorkflowRunsRequiringApproval: () =>670 listWorkflowRunsRequiringApproval: () =>
674 Effect.fail(671 Effect.fail(
675 new GitHubApi.GitHubApiRateLimitError({672 new GitHubApi.GitHubApiRateLimitError({
...@@ -691,6 +688,14 @@ it.effect("propagates workflow discovery rate limits", () =>...@@ -691,6 +688,14 @@ it.effect("propagates workflow discovery rate limits", () =>
691);688);
692689
693describe("getViewerPermissions", () => {690describe("getViewerPermissions", () => {
691 const access = {
692 canWrite: true,
693 canTriage: true,
694 canUpdate: true,
695 didAuthor: false,
696 mergeCapabilities: { merge: true, squash: true, rebase: true },
697 };
698
694 it.effect("checks fresh access without reading branch details for unrelated operations", () => {699 it.effect("checks fresh access without reading branch details for unrelated operations", () => {
695 let accessReads = 0;700 let accessReads = 0;
696 return Effect.gen(function* () {701 return Effect.gen(function* () {
...@@ -711,44 +716,27 @@ describe("getViewerPermissions", () => {...@@ -711,44 +716,27 @@ describe("getViewerPermissions", () => {
711 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({716 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
712 revalidateChecks: (_input, read) => read,717 revalidateChecks: (_input, read) => read,
713 getPullRequestDetail: () => Effect.die("Unexpected detail read"),718 getPullRequestDetail: () => Effect.die("Unexpected detail read"),
714 getPullRequestBaseComparison: () => Effect.die("Unexpected comparison read"),719 getViewerAccess: () => Effect.sync(() => (accessReads++, access)),
715 getViewerAccess: () =>
716 Effect.sync(() => {
717 accessReads++;
718 return {
719 canWrite: true,
720 canTriage: true,
721 canUpdate: true,
722 didAuthor: false,
723 mergeCapabilities: { merge: true, squash: true, rebase: true },
724 };
725 }),
726 }),720 }),
727 ),721 ),
728 );722 );
729 });723 });
730724
731 const layerWithComparison = (725 const layerWithDetail = (
732 comparison: Effect.Effect<{726 detail: Effect.Effect<GitHubPullRequestCore, GitHubPullRequestCli.GitHubPullRequestCliError>,
733 readonly behindBy: number | null;727 onDetail: (allowReserve: boolean) => void = () => {},
734 readonly viewerCanUpdate: boolean;
735 }>,
736 ) =>728 ) =>
737 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({729 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
738 revalidateChecks: (_input, read) => read,730 revalidateChecks: (_input, read) => read,
739 getPullRequestDetail: () => Effect.succeed(openDetail),731 getPullRequestDetail: () =>
740 getPullRequestBaseComparison: () => comparison,732 GitHubCli.AllowGitHubReserve.pipe(
741 getViewerAccess: () =>733 Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))),
742 Effect.succeed({734 Effect.flatMap(() => detail),
743 canWrite: true,735 ),
744 canTriage: true,736 getViewerAccess: () => Effect.die("Unexpected access read"),
745 canUpdate: true,
746 didAuthor: false,
747 mergeCapabilities: { merge: true, squash: true, rebase: true },
748 }),
749 });737 });
750738
751 it.effect("offers update-branch when the comparison grants it", () =>739 it.effect("answers access and update-branch from the one detail read", () =>
752 Effect.gen(function* () {740 Effect.gen(function* () {
753 const provider = yield* make;741 const provider = yield* make;
754 const permissions = yield* provider.getViewerPermissions({742 const permissions = yield* provider.getViewerPermissions({
...@@ -758,16 +746,20 @@ describe("getViewerPermissions", () => {...@@ -758,16 +746,20 @@ describe("getViewerPermissions", () => {
758 number: 7,746 number: 7,
759 });747 });
760748
749 expect(permissions.actions).toContain("merge");
761 expect(permissions.actions).toContain("update-branch");750 expect(permissions.actions).toContain("update-branch");
762 expect(permissions.updateMethods).toEqual(["merge", "rebase"]);751 expect(permissions.updateMethods).toEqual(["merge", "rebase"]);
763 }).pipe(752 }).pipe(
764 Effect.provide(layerWithComparison(Effect.succeed({ behindBy: 3, viewerCanUpdate: true }))),753 Effect.provide(
754 layerWithDetail(
755 Effect.succeed({ ...openDetail, comparison: { behindBy: 3, viewerCanUpdate: true } }),
756 ),
757 ),
765 ),758 ),
766 );759 );
767760
768 it.effect("uses the GraphQL reserve for manual permission checks", () => {761 it.effect("uses the GraphQL reserve for manual permission checks", () => {
769 let viewerAllowReserve: boolean | undefined;762 let allowed: boolean | undefined;
770 let comparisonAllowReserve: boolean | undefined;
771 return Effect.gen(function* () {763 return Effect.gen(function* () {
772 const provider = yield* make;764 const provider = yield* make;
773 yield* provider.getViewerPermissions({765 yield* provider.getViewerPermissions({
...@@ -776,36 +768,15 @@ describe("getViewerPermissions", () => {...@@ -776,36 +768,15 @@ describe("getViewerPermissions", () => {
776 host: "github.com",768 host: "github.com",
777 number: 7,769 number: 7,
778 });770 });
779771 expect(allowed).toBe(true);
780 expect(viewerAllowReserve).toBe(true);
781 expect(comparisonAllowReserve).toBe(true);
782 }).pipe(772 }).pipe(
783 Effect.provide(773 Effect.provide(
784 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({774 layerWithDetail(Effect.succeed(openDetail), (allowReserve) => (allowed = allowReserve)),
785 revalidateChecks: (_input, read) => read,
786 getPullRequestDetail: () => Effect.succeed(openDetail),
787 getPullRequestBaseComparison: (input) =>
788 Effect.sync(() => {
789 comparisonAllowReserve = input.allowReserve;
790 return { behindBy: 3, viewerCanUpdate: true };
791 }),
792 getViewerAccess: (input) =>
793 Effect.sync(() => {
794 viewerAllowReserve = input.allowReserve;
795 return {
796 canWrite: true,
797 canTriage: true,
798 canUpdate: true,
799 didAuthor: false,
800 mergeCapabilities: { merge: true, squash: true, rebase: true },
801 };
802 }),
803 }),
804 ),775 ),
805 );776 );
806 });777 });
807778
808 it.effect("withholds update-branch when the comparison cannot be read", () =>779 it.effect("still answers from the light access read when the detail read fails", () =>
809 Effect.gen(function* () {780 Effect.gen(function* () {
810 const provider = yield* make;781 const provider = yield* make;
811 const permissions = yield* provider.getViewerPermissions({782 const permissions = yield* provider.getViewerPermissions({
...@@ -814,37 +785,42 @@ describe("getViewerPermissions", () => {...@@ -814,37 +785,42 @@ describe("getViewerPermissions", () => {
814 host: "github.com",785 host: "github.com",
815 number: 7,786 number: 7,
816 });787 });
817
818 expect(permissions.actions).not.toContain("update-branch");
819 expect(permissions.updateMethods).toBeUndefined();
820 // The rest of the answer survives a comparison nobody could make.
821 expect(permissions.actions).toContain("merge");788 expect(permissions.actions).toContain("merge");
789 expect(permissions.actions).not.toContain("update-branch");
822 }).pipe(790 }).pipe(
823 Effect.provide(791 Effect.provide(
824 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({792 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
825 revalidateChecks: (_input, read) => read,793 revalidateChecks: (_input, read) => read,
826 getPullRequestDetail: () => Effect.succeed(openDetail),794 getPullRequestDetail: () =>
827 getPullRequestBaseComparison: () =>
828 Effect.fail(795 Effect.fail(
829 new GitHubPullRequestCli.GitHubPullRequestReadError({796 new GitHubPullRequestCli.GitHubPullRequestReadError({
830 command: "gh",797 command: "gh",
831 cwd: "/w",798 cwd: "/w",
832 operation: "getPullRequestBaseComparison",799 operation: "getPullRequestDetail",
833 cause: new Error("unreadable"),800 cause: new Error("head changed"),
834 }),801 }),
835 ),802 ),
836 getViewerAccess: () =>803 getViewerAccess: () => Effect.succeed(access),
837 Effect.succeed({
838 canWrite: true,
839 canTriage: true,
840 canUpdate: true,
841 didAuthor: false,
842 mergeCapabilities: { merge: true, squash: true, rebase: true },
843 }),
844 }),804 }),
845 ),805 ),
846 ),806 ),
847 );807 );
808
809 it.effect("withholds update-branch where GitHub could not compare the branch", () =>
810 Effect.gen(function* () {
811 const provider = yield* make;
812 const permissions = yield* provider.getViewerPermissions({
813 cwd: "/w",
814 repository: "acme/web",
815 host: "github.com",
816 number: 7,
817 });
818
819 expect(permissions.actions).not.toContain("update-branch");
820 expect(permissions.updateMethods).toBeUndefined();
821 expect(permissions.actions).toContain("merge");
822 }).pipe(Effect.provide(layerWithDetail(Effect.succeed({ ...openDetail, comparison: null })))),
823 );
848});824});
849825
850describe("getChangeRequest commits", () => {826describe("getChangeRequest commits", () => {
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+29-31
...@@ -7,6 +7,7 @@ import type {...@@ -7,6 +7,7 @@ import type {
7 PullRequestViewerPermissions,7 PullRequestViewerPermissions,
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
99
10import * as GitHubCli from "../sourceControl/GitHubCli.ts";
10import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";11import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
11import {12import {
12 PullRequestProviderError,13 PullRequestProviderError,
...@@ -109,6 +110,7 @@ export function gitHubProviderFailure(...@@ -109,6 +110,7 @@ export function gitHubProviderFailure(
109 case "GitHubCliMissingError":110 case "GitHubCliMissingError":
110 return { reason: "missing-tool" };111 return { reason: "missing-tool" };
111 case "GitHubNotSignedInError":112 case "GitHubNotSignedInError":
113 case "GitHubHostDisabledError":
112 case "GitHubApiAuthenticationError":114 case "GitHubApiAuthenticationError":
113 return { reason: "unauthenticated" };115 return { reason: "unauthenticated" };
114 case "GitHubApiRateLimitError":116 case "GitHubApiRateLimitError":
...@@ -513,38 +515,34 @@ export const make = Effect.gen(function* () {...@@ -513,38 +515,34 @@ export const make = Effect.gen(function* () {
513 getReviewThreadComments: (input) =>515 getReviewThreadComments: (input) =>
514 cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))),516 cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))),
515517
516 getViewerPermissions: (input) =>518 getViewerPermissions: (input) => {
517 Effect.all(519 const lightAccess = cli
518 [520 .getViewerAccess({ ...input, allowReserve: true })
519 cli.getViewerAccess({ ...input, allowReserve: true }),521 .pipe(Effect.map((access) => gitHubViewerPermissions(access)));
520 // Whether this viewer may update the branch is only on the comparison, and the522 if (input.includeUpdateBranch === false) {
521 // comparison only resolves through the head ref the detail carries. A failure here523 return lightAccess.pipe(Effect.mapError(fail("getViewerPermissions")));
522 // withholds that one action rather than the whole answer, the way the detail path524 }
523 // leaves the banner unknown.525 // The core detail already carries the viewer's access, the merge settings, and the base
524 input.includeUpdateBranch === false526 // comparison, so one read usually answers what used to take three. When that heavier read
525 ? Effect.succeed(false)527 // fails, the light access read still answers, withholding only update-branch.
526 : cli.getPullRequestDetail(input).pipe(528 return cli.getPullRequestDetail(input).pipe(
527 Effect.flatMap((pullRequest) =>529 Effect.provideService(GitHubCli.AllowGitHubReserve, true),
528 pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null530 Effect.map((pullRequest) =>
529 ? Effect.succeed(false)531 gitHubViewerPermissions({
530 : cli532 ...pullRequest.viewerAccess,
531 .getPullRequestBaseComparison({533 canUpdateBranch:
532 ...input,534 pullRequest.state === "open" && pullRequest.comparison?.viewerCanUpdate === true,
533 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,535 }),
534 allowReserve: true,
535 })
536 .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)),
537 ),
538 Effect.orElseSucceed(() => false),
539 ),
540 ],
541 { concurrency: 2 },
542 ).pipe(
543 Effect.mapError(fail("getViewerPermissions")),
544 Effect.map(([access, canUpdateBranch]) =>
545 gitHubViewerPermissions({ ...access, canUpdateBranch }),
546 ),536 ),
547 ),537 Effect.catchIf(
538 (error) =>
539 error._tag !== "GitHubApiRateLimitError" &&
540 error._tag !== "SourceControlRateLimitPausedError",
541 () => lightAccess,
542 ),
543 Effect.mapError(fail("getViewerPermissions")),
544 );
545 },
548546
549 getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),547 getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),
550548
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts-43
...@@ -11,7 +11,6 @@ import {...@@ -11,7 +11,6 @@ import {
11 decodePullRequestStackMembershipsJson,11 decodePullRequestStackMembershipsJson,
12 buildReviewerRequest,12 buildReviewerRequest,
13 buildSetFilesViewedGraphQlMutation,13 buildSetFilesViewedGraphQlMutation,
14 decodeBaseComparisonJson,
15 decodePullRequestActivityJson,14 decodePullRequestActivityJson,
16 decodePullRequestDetailJson,15 decodePullRequestDetailJson,
17 decodePullRequestFilesJson,16 decodePullRequestFilesJson,
...@@ -1696,48 +1695,6 @@ describe("decodePullRequestFilesJson", () => {...@@ -1696,48 +1695,6 @@ describe("decodePullRequestFilesJson", () => {
1696 });1695 });
1697});1696});
16981697
1699describe("how far a branch trails its base", () => {
1700 const comparison = (pullRequest: unknown) =>
1701 JSON.stringify({ data: { repository: { pullRequest } } });
1702
1703 it("reads the commit count and whether this viewer may move the branch", () => {
1704 const decoded = expectSuccess(
1705 decodeBaseComparisonJson(
1706 comparison({ viewerCanUpdateBranch: true, baseRef: { compare: { behindBy: 12 } } }),
1707 ),
1708 );
1709 expect(decoded).toEqual({ behindBy: 12, viewerCanUpdate: true });
1710 });
1711
1712 it("reads a current branch as nothing to do", () => {
1713 expect(
1714 expectSuccess(
1715 decodeBaseComparisonJson(
1716 comparison({ viewerCanUpdateBranch: false, baseRef: { compare: { behindBy: 0 } } }),
1717 ),
1718 ),
1719 ).toEqual({ behindBy: 0, viewerCanUpdate: false });
1720 });
1721
1722 it("answers unknown where the head could not be compared", () => {
1723 // A pull request from a fork whose repository is gone, which GitHub answers with a null
1724 // comparison beside a perfectly good pull request.
1725 expect(
1726 expectSuccess(
1727 decodeBaseComparisonJson(comparison({ viewerCanUpdateBranch: true, baseRef: null })),
1728 ).behindBy,
1729 ).toBeNull();
1730 expect(expectSuccess(decodeBaseComparisonJson(comparison(null)))).toEqual({
1731 behindBy: null,
1732 viewerCanUpdate: false,
1733 });
1734 });
1735
1736 it("refuses a body that is not the answer to this question", () => {
1737 expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false);
1738 });
1739});
1740
1741describe("decodePullRequestFilesViewedJson", () => {1698describe("decodePullRequestFilesViewedJson", () => {
1742 const page = (1699 const page = (
1743 nodes: ReadonlyArray<unknown>,1700 nodes: ReadonlyArray<unknown>,
apps/server/src/pullRequest/gitHubPullRequestJson.ts-63
...@@ -2892,75 +2892,12 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean {...@@ -2892,75 +2892,12 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean {
2892 * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses2892 * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses
2893 * fails whole, taking the people down with the teams.2893 * fails whole, taking the people down with the teams.
2894 */2894 */
2895/**
2896 * Where the branch stands against its base, and whether this viewer may move it.
2897 *
2898 * `mergeStateStatus` is not the answer: GitHub only reports BEHIND where the repository requires
2899 * branches to be up to date before merging, so on every other repository a stale branch reads as
2900 * CLEAN or BLOCKED like any other. The comparison counts the commits instead, which is the same
2901 * number GitHub's own "out-of-date" banner shows.
2902 *
2903 * `headRef` is qualified `owner:branch` because a pull request from a fork has no branch of that
2904 * name in the base repository, and an unqualified name is simply not found there.
2905 */
2906export const BASE_COMPARISON_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) {
2907 repository(owner: $owner, name: $name) {
2908 pullRequest(number: $number) {
2909 viewerCanUpdateBranch
2910 baseRef {
2911 compare(headRef: $headRef) {
2912 behindBy
2913 }
2914 }
2915 }
2916 }
2917}`;
2918
2919const RawBaseComparisonSchema = Schema.Struct({
2920 data: Schema.Struct({
2921 repository: Schema.NullOr(
2922 Schema.Struct({
2923 pullRequest: Schema.NullOr(
2924 Schema.Struct({
2925 viewerCanUpdateBranch: Schema.optional(Schema.NullOr(Schema.Boolean)),
2926 /** Null where the head repository is gone, which is a comparison nobody can make. */
2927 baseRef: Schema.optional(
2928 Schema.NullOr(
2929 Schema.Struct({
2930 compare: Schema.optional(
2931 Schema.NullOr(Schema.Struct({ behindBy: Schema.Number })),
2932 ),
2933 }),
2934 ),
2935 ),
2936 }),
2937 ),
2938 }),
2939 ),
2940 }),
2941});
2942
2943const decodeBaseComparison = decodeJsonResult(RawBaseComparisonSchema);
2944
2945export interface GitHubBaseComparison {2895export interface GitHubBaseComparison {
2946 /** Null where the host could not compare, which the page reads as "unknown". */2896 /** Null where the host could not compare, which the page reads as "unknown". */
2947 readonly behindBy: number | null;2897 readonly behindBy: number | null;
2948 readonly viewerCanUpdate: boolean;2898 readonly viewerCanUpdate: boolean;
2949}2899}
29502900
2951export function decodeBaseComparisonJson(
2952 raw: string,
2953): Result.Result<GitHubBaseComparison, DecodeFailure> {
2954 const decoded = decodeBaseComparison(raw);
2955 if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
2956 const pullRequest = decoded.success.data.repository?.pullRequest;
2957 const behindBy = pullRequest?.baseRef?.compare?.behindBy;
2958 return Result.succeed({
2959 behindBy: typeof behindBy === "number" && behindBy >= 0 ? behindBy : null,
2960 viewerCanUpdate: pullRequest?.viewerCanUpdateBranch === true,
2961 });
2962}
2963
2964export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {2901export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
2965 repository(owner: $owner, name: $name) {2902 repository(owner: $owner, name: $name) {
2966 assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) {2903 assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) {
apps/server/src/serverSettings.test.ts+48
...@@ -1588,6 +1588,54 @@ it.layer(NodeServices.layer)("server settings", (it) => {...@@ -1588,6 +1588,54 @@ it.layer(NodeServices.layer)("server settings", (it) => {
1588 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),1588 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1589 );1589 );
15901590
1591 it.effect(
1592 "keeps GitHub tokens per host in the secret store and tells clients only that one is set",
1593 () =>
1594 Effect.gen(function* () {
1595 const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
1596 const secrets = yield* ServerSecretStore.ServerSecretStore;
1597 const serverConfig = yield* ServerConfig.ServerConfig;
1598 const fileSystem = yield* FileSystem.FileSystem;
1599
1600 const saved = yield* serverSettings.updateSettings({
1601 github: { tokens: { "GitHub.com": "ghp_dotcom", "ghe.acme.test": "ghp_ghe" } },
1602 });
1603 assert.deepEqual(saved.github.tokens, {
1604 "github.com": "ghp_dotcom",
1605 "ghe.acme.test": "ghp_ghe",
1606 });
1607 const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
1608 assert.notInclude(raw, "ghp_dotcom");
1609 assert.notInclude(raw, "ghp_ghe");
1610
1611 const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).github;
1612 assert.notInclude(forClient.tokens["github.com"]!, "ghp_dotcom");
1613 assert.isAbove(forClient.tokens["github.com"]!.length, 0);
1614
1615 // Echoing the redacted values back keeps them; host and account changes leave tokens alone.
1616 yield* serverSettings.updateSettings({ github: { tokens: forClient.tokens } });
1617 yield* serverSettings.updateSettings({
1618 github: { hosts: { "github.com": { enabled: true, account: "work" } } },
1619 });
1620 assert.deepEqual((yield* serverSettings.getSettings).github.tokens, {
1621 "github.com": "ghp_dotcom",
1622 "ghe.acme.test": "ghp_ghe",
1623 });
1624
1625 // An empty token removes that host's token and nothing else.
1626 const cleared = yield* serverSettings.updateSettings({
1627 github: { tokens: { "github.com": "" } },
1628 });
1629 assert.equal(cleared.github.tokens["github.com"] ?? "", "");
1630 assert.equal(cleared.github.tokens["ghe.acme.test"], "ghp_ghe");
1631 const remaining = yield* Effect.forEach(["github.com", "ghe.acme.test"], (host) =>
1632 secrets.get(`github-token-${Buffer.from(host, "utf8").toString("base64url")}`),
1633 );
1634 assert.isTrue(Option.isNone(remaining[0]!));
1635 assert.isTrue(Option.isSome(remaining[1]!));
1636 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
1637 );
1638
1591 it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () =>1639 it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () =>
1592 Effect.gen(function* () {1640 Effect.gen(function* () {
1593 const serverConfig = yield* ServerConfig.ServerConfig;1641 const serverConfig = yield* ServerConfig.ServerConfig;
apps/server/src/serverSettings.ts+80-2
...@@ -161,6 +161,11 @@ const BITBUCKET_SECRET_NAMES = {...@@ -161,6 +161,11 @@ const BITBUCKET_SECRET_NAMES = {
161} as const;161} as const;
162const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const;162const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const;
163163
164/** Hosts are case-insensitive; a patch can arrive before decoding lowercased its keys. */
165function gitHubTokenSecretName(host: string): string {
166 return `github-token-${Buffer.from(host.trim().toLowerCase(), "utf8").toString("base64url")}`;
167}
168
164const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : "");169const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : "");
165170
166function redactProviderEnvironmentVariable(171function redactProviderEnvironmentVariable(
...@@ -204,7 +209,13 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS...@@ -204,7 +209,13 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS
204 accessToken: redactSecret(settings.bitbucket.accessToken),209 accessToken: redactSecret(settings.bitbucket.accessToken),
205 apiToken: redactSecret(settings.bitbucket.apiToken),210 apiToken: redactSecret(settings.bitbucket.apiToken),
206 };211 };
207 return { ...settings, providerInstances, usageLimitSources, bitbucket };212 const github = {
213 ...settings.github,
214 tokens: Object.fromEntries(
215 Object.entries(settings.github.tokens).map(([host, token]) => [host, redactSecret(token)]),
216 ),
217 };
218 return { ...settings, providerInstances, usageLimitSources, bitbucket, github };
208}219}
209220
210export function applyProviderInstanceMutation(221export function applyProviderInstanceMutation(
...@@ -707,7 +718,24 @@ const make = Effect.gen(function* () {...@@ -707,7 +718,24 @@ const make = Effect.gen(function* () {
707 bitbucket[field] = SECRET_REDACTED;718 bitbucket[field] = SECRET_REDACTED;
708 moved = true;719 moved = true;
709 }720 }
710 return moved ? { ...settings, bitbucket } : settings;721 const tokens = { ...settings.github.tokens };
722 for (const [host, value] of Object.entries(tokens)) {
723 if (value.length === 0 || value === SECRET_REDACTED) continue;
724 const stored = yield* secretStore
725 .set(gitHubTokenSecretName(host), textEncoder.encode(value))
726 .pipe(
727 Effect.as(true),
728 Effect.catch(() =>
729 Effect.logWarning("failed to move a GitHub token into the secret store", {
730 host,
731 }).pipe(Effect.as(false)),
732 ),
733 );
734 if (!stored) continue;
735 tokens[host] = SECRET_REDACTED;
736 moved = true;
737 }
738 return moved ? { ...settings, bitbucket, github: { ...settings.github, tokens } } : settings;
711 });739 });
712740
713 const loadSettingsFromDisk = Effect.gen(function* () {741 const loadSettingsFromDisk = Effect.gen(function* () {
...@@ -882,11 +910,27 @@ const make = Effect.gen(function* () {...@@ -882,11 +910,27 @@ const make = Effect.gen(function* () {
882 );910 );
883 bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : "";911 bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : "";
884 }912 }
913 const tokens: Record<string, string> = {};
914 for (const [host, value] of Object.entries(settings.github.tokens)) {
915 if (value !== SECRET_REDACTED) {
916 tokens[host] = value;
917 continue;
918 }
919 const secret = yield* secretStore
920 .get(gitHubTokenSecretName(host))
921 .pipe(
922 Effect.mapError(
923 (cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),
924 ),
925 );
926 tokens[host] = Option.isSome(secret) ? textDecoder.decode(secret.value) : "";
927 }
885 return {928 return {
886 ...settings,929 ...settings,
887 providerInstances: providerInstances as ServerSettings["providerInstances"],930 providerInstances: providerInstances as ServerSettings["providerInstances"],
888 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],931 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
889 bitbucket,932 bitbucket,
933 github: { ...settings.github, tokens },
890 };934 };
891 });935 });
892936
...@@ -1047,12 +1091,46 @@ const make = Effect.gen(function* () {...@@ -1047,12 +1091,46 @@ const make = Effect.gen(function* () {
1047 bitbucket[field] = SECRET_REDACTED;1091 bitbucket[field] = SECRET_REDACTED;
1048 }1092 }
10491093
1094 const tokens: Record<string, string> = {};
1095 for (const [rawHost, raw] of Object.entries(next.github.tokens)) {
1096 const host = rawHost.trim().toLowerCase();
1097 let value = raw;
1098 if (value === SECRET_REDACTED) {
1099 // The marker keeps what is saved; a hand-edited plaintext token moves into the store.
1100 const inline = current.github.tokens[host];
1101 if (inline === undefined || inline === SECRET_REDACTED || inline.length === 0) {
1102 tokens[host] = SECRET_REDACTED;
1103 continue;
1104 }
1105 value = inline;
1106 }
1107 const secretName = gitHubTokenSecretName(host);
1108 if (value.length === 0) {
1109 changes.push({ kind: "remove", secretName, operation: "remove-secret" });
1110 continue;
1111 }
1112 changes.push({ kind: "write", secretName, value: textEncoder.encode(value) });
1113 tokens[host] = SECRET_REDACTED;
1114 }
1115 const nextHosts = new Set(
1116 Object.keys(next.github.tokens).map((host) => host.trim().toLowerCase()),
1117 );
1118 for (const host of Object.keys(current.github.tokens)) {
1119 if (nextHosts.has(host.trim().toLowerCase())) continue;
1120 changes.push({
1121 kind: "remove",
1122 secretName: gitHubTokenSecretName(host),
1123 operation: "remove-stale-secret",
1124 });
1125 }
1126
1050 return {1127 return {
1051 settings: {1128 settings: {
1052 ...next,1129 ...next,
1053 providerInstances: providerInstances as ServerSettings["providerInstances"],1130 providerInstances: providerInstances as ServerSettings["providerInstances"],
1054 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],1131 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
1055 bitbucket,1132 bitbucket,
1133 github: { ...next.github, tokens },
1056 },1134 },
1057 changes,1135 changes,
1058 };1136 };
apps/server/src/sourceControl/GitHubApi.test.ts+2
...@@ -15,6 +15,7 @@ import * as GitHubCredentials from "./GitHubCredentials.ts";...@@ -15,6 +15,7 @@ import * as GitHubCredentials from "./GitHubCredentials.ts";
15import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";15import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
16import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";16import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
17import * as VcsProcess from "../vcs/VcsProcess.ts";17import * as VcsProcess from "../vcs/VcsProcess.ts";
18import * as ServerSettings from "../serverSettings.ts";
1819
19const NOW = Date.parse("2026-10-05T12:00:00.000Z");20const NOW = Date.parse("2026-10-05T12:00:00.000Z");
2021
...@@ -416,6 +417,7 @@ describe("GitHubCredentials", () => {...@@ -416,6 +417,7 @@ describe("GitHubCredentials", () => {
416 GitHubCredentials.layer.pipe(417 GitHubCredentials.layer.pipe(
417 Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })),418 Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })),
418 Layer.provide(NodeServices.layer),419 Layer.provide(NodeServices.layer),
420 Layer.provide(ServerSettings.layerTest()),
419 );421 );
420422
421 it.effect("fails with GitHubCliMissingError when gh is not on PATH", () =>423 it.effect("fails with GitHubCliMissingError when gh is not on PATH", () =>
apps/server/src/sourceControl/GitHubCli.ts+6-2
...@@ -44,7 +44,7 @@ export class GitHubCliUnavailableError extends Schema.TaggedError<GitHubCliUnava...@@ -44,7 +44,7 @@ export class GitHubCliUnavailableError extends Schema.TaggedError<GitHubCliUnava
44 gitHubCliFailureFields,44 gitHubCliFailureFields,
45) {45) {
46 override get message(): string {46 override get message(): string {
47 return "GitHub CLI (`gh`) is required but not available on PATH. Install it and run `gh auth login`, or set GH_TOKEN.";47 return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.";
48 }48 }
49}49}
5050
...@@ -53,7 +53,10 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError<GitHubCliAu...@@ -53,7 +53,10 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError<GitHubCliAu
53 gitHubCliFailureFields,53 gitHubCliFailureFields,
54) {54) {
55 override get message(): string {55 override get message(): string {
56 return "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.";56 // A missing or turned-off credential already says what to do about it.
57 return GitHubCredentials.isGitHubCredentialUnavailableError(this.cause)
58 ? this.cause.message
59 : "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.";
57 }60 }
58}61}
5962
...@@ -153,6 +156,7 @@ function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHu...@@ -153,6 +156,7 @@ function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHu
153 case "GitHubCliMissingError":156 case "GitHubCliMissingError":
154 return new GitHubCliUnavailableError(context);157 return new GitHubCliUnavailableError(context);
155 case "GitHubNotSignedInError":158 case "GitHubNotSignedInError":
159 case "GitHubHostDisabledError":
156 case "GitHubApiAuthenticationError":160 case "GitHubApiAuthenticationError":
157 return new GitHubCliAuthenticationError(context);161 return new GitHubCliAuthenticationError(context);
158 case "GitHubCliFailedError":162 case "GitHubCliFailedError":
apps/server/src/sourceControl/GitHubCredentials.test.ts created+170
...@@ -0,0 +1,170 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import { ChildProcessSpawner } from "effect/process";
7
8import * as ServerSettings from "../serverSettings.ts";
9import * as VcsProcess from "../vcs/VcsProcess.ts";
10import * as GitHubCredentials from "./GitHubCredentials.ts";
11
12const TOKEN_VARIABLES = [
13 "GH_TOKEN",
14 "GITHUB_TOKEN",
15 "GH_ENTERPRISE_TOKEN",
16 "GITHUB_ENTERPRISE_TOKEN",
17] as const;
18
19function harness(
20 hosts: Record<string, { readonly account?: string; readonly enabled?: boolean }> = {},
21 signedOut: ReadonlyArray<string> = [],
22 tokens: Record<string, string> = {},
23) {
24 const calls: Array<ReadonlyArray<string>> = [];
25 const process = Layer.mock(VcsProcess.VcsProcess)({
26 run: (input) =>
27 Effect.sync(() => {
28 calls.push(input.args);
29 const user = input.args[input.args.indexOf("--user") + 1];
30 if (input.args.includes("--user") && user !== undefined && signedOut.includes(user)) {
31 return {
32 exitCode: ChildProcessSpawner.ExitCode(0),
33 stdout: "",
34 stderr: "",
35 stdoutTruncated: false,
36 stderrTruncated: false,
37 };
38 }
39 return {
40 exitCode: ChildProcessSpawner.ExitCode(0),
41 stdout: input.args.includes("--user") ? `token-for-${user}\n` : "active-token\n",
42 stderr: "",
43 stdoutTruncated: false,
44 stderrTruncated: false,
45 };
46 }),
47 });
48 const layer = GitHubCredentials.layer.pipe(
49 Layer.provideMerge(
50 ServerSettings.ServerSettingsService.layerTest({ github: { hosts, tokens } }),
51 ),
52 Layer.provide(process),
53 Layer.provide(NodeServices.layer),
54 );
55 return { layer, calls };
56}
57
58describe("GitHubCredentials", () => {
59 beforeEach(() => {
60 for (const name of TOKEN_VARIABLES) vi.stubEnv(name, "");
61 });
62 afterEach(() => {
63 vi.unstubAllEnvs();
64 });
65
66 it.effect("asks gh for the active login when Settings pin nothing", () => {
67 const { layer, calls } = harness();
68 return Effect.gen(function* () {
69 const credentials = yield* GitHubCredentials.GitHubCredentials;
70 const credential = yield* credentials.get("GitHub.com");
71 expect(Redacted.value(credential.token)).toBe("active-token");
72 expect(calls).toEqual([["auth", "token", "--hostname", "github.com"]]);
73 }).pipe(Effect.provide(layer));
74 });
75
76 it.effect("passes --user for an account pinned in Settings", () => {
77 const { layer, calls } = harness({ "github.com": { account: "work" } });
78 return Effect.gen(function* () {
79 const credentials = yield* GitHubCredentials.GitHubCredentials;
80 const credential = yield* credentials.get("github.com");
81 expect(Redacted.value(credential.token)).toBe("token-for-work");
82 expect(calls).toEqual([["auth", "token", "--hostname", "github.com", "--user", "work"]]);
83 }).pipe(Effect.provide(layer));
84 });
85
86 it.effect("fails a host turned off in Settings without asking gh", () => {
87 const { layer, calls } = harness({ "github.com": { enabled: false } });
88 return Effect.gen(function* () {
89 const credentials = yield* GitHubCredentials.GitHubCredentials;
90 const error = yield* Effect.flip(credentials.get("github.com"));
91 expect(error._tag).toBe("GitHubHostDisabledError");
92 expect(error.message).toBe(
93 "GitHub host github.com is turned off in Settings → Source Control.",
94 );
95 expect(calls).toEqual([]);
96 }).pipe(Effect.provide(layer));
97 });
98
99 it.effect("lets an environment token win over a pinned account, as gh does", () => {
100 vi.stubEnv("GH_TOKEN", "from-env");
101 const { layer, calls } = harness({ "github.com": { account: "work" } });
102 return Effect.gen(function* () {
103 const credentials = yield* GitHubCredentials.GitHubCredentials;
104 const credential = yield* credentials.get("github.com");
105 expect(Redacted.value(credential.token)).toBe("from-env");
106 expect(credential.source).toBe("env");
107 expect(calls).toEqual([]);
108 }).pipe(Effect.provide(layer));
109 });
110
111 it.effect("picks up a changed account on the next request without a restart", () => {
112 const { layer, calls } = harness();
113 return Effect.gen(function* () {
114 const credentials = yield* GitHubCredentials.GitHubCredentials;
115 const settings = yield* ServerSettings.ServerSettingsService;
116 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");
117
118 yield* settings.updateSettings({
119 github: { hosts: { "github.com": { account: "work", enabled: true } } },
120 });
121 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("token-for-work");
122
123 yield* settings.updateSettings({ github: { hosts: { "github.com": { enabled: false } } } });
124 expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe(
125 "GitHubHostDisabledError",
126 );
127
128 yield* settings.updateSettings({ github: { hosts: {} } });
129 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");
130 // The unpinned token stayed cached; only the newly pinned account cost a gh call.
131 expect(calls).toHaveLength(2);
132 }).pipe(Effect.provide(layer));
133 });
134
135 it.effect("falls back to the active login when the pinned one is no longer signed in", () => {
136 const { layer, calls } = harness({ "github.com": { account: "gone" } }, ["gone"]);
137 return Effect.gen(function* () {
138 const credentials = yield* GitHubCredentials.GitHubCredentials;
139 expect(Redacted.value((yield* credentials.get("github.com")).token)).toBe("active-token");
140 expect(calls).toEqual([
141 ["auth", "token", "--hostname", "github.com", "--user", "gone"],
142 ["auth", "token", "--hostname", "github.com"],
143 ]);
144 }).pipe(Effect.provide(layer));
145 });
146
147 it.effect("uses a token saved in Settings before GH_TOKEN and gh", () => {
148 vi.stubEnv("GH_TOKEN", "env-token");
149 const { layer, calls } = harness({}, [], { "github.com": "saved-token" });
150 return Effect.gen(function* () {
151 const credentials = yield* GitHubCredentials.GitHubCredentials;
152 const credential = yield* credentials.get("github.com");
153 expect(Redacted.value(credential.token)).toBe("saved-token");
154 expect(credential.source).toBe("settings");
155 expect(calls).toEqual([]);
156 }).pipe(Effect.provide(layer));
157 });
158
159 it.effect("keeps a host turned off even with a token saved in Settings", () => {
160 const { layer } = harness({ "github.com": { enabled: false } }, [], {
161 "github.com": "saved-token",
162 });
163 return Effect.gen(function* () {
164 const credentials = yield* GitHubCredentials.GitHubCredentials;
165 expect((yield* Effect.flip(credentials.get("github.com")))._tag).toBe(
166 "GitHubHostDisabledError",
167 );
168 }).pipe(Effect.provide(layer));
169 });
170});
apps/server/src/sourceControl/GitHubCredentials.ts+88-11
...@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema";...@@ -13,6 +13,7 @@ import * as Schema from "effect/Schema";
1313
14import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";14import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";
1515
16import * as ServerSettings from "../serverSettings.ts";
16import * as VcsProcess from "../vcs/VcsProcess.ts";17import * as VcsProcess from "../vcs/VcsProcess.ts";
1718
18/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */19/** How long a token is reused before `gh` is asked again, so a `gh auth switch` applies soon. */
...@@ -20,7 +21,7 @@ const TOKEN_TTL = Duration.minutes(5);...@@ -20,7 +21,7 @@ const TOKEN_TTL = Duration.minutes(5);
20/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */21/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */
21const MISSING_TTL = Duration.seconds(10);22const MISSING_TTL = Duration.seconds(10);
2223
23export const GitHubCredentialSource = Schema.Literals(["env", "gh"]);24export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]);
24export type GitHubCredentialSource = typeof GitHubCredentialSource.Type;25export type GitHubCredentialSource = typeof GitHubCredentialSource.Type;
2526
26export interface GitHubCredential {27export interface GitHubCredential {
...@@ -41,13 +42,25 @@ export class GitHubCliMissingError extends Schema.TaggedError<GitHubCliMissingEr...@@ -41,13 +42,25 @@ export class GitHubCliMissingError extends Schema.TaggedError<GitHubCliMissingEr
41 }42 }
42}43}
4344
44/** `gh` is installed but holds no login for the host. */45/** `gh` is installed but holds no login for the host, or not the account chosen in Settings. */
45export class GitHubNotSignedInError extends Schema.TaggedError<GitHubNotSignedInError>()(46export class GitHubNotSignedInError extends Schema.TaggedError<GitHubNotSignedInError>()(
46 "GitHubNotSignedInError",47 "GitHubNotSignedInError",
48 { host: Schema.String, account: Schema.optional(Schema.String) },
49) {
50 override get message(): string {
51 return this.account === undefined
52 ? `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.`
53 : `No GitHub credential for ${this.account} on ${this.host}: run \`gh auth login --hostname ${this.host}\` for that account or pick another in Settings → Source Control.`;
54 }
55}
56
57/** The user turned the host off in Settings. */
58export class GitHubHostDisabledError extends Schema.TaggedError<GitHubHostDisabledError>()(
59 "GitHubHostDisabledError",
47 { host: Schema.String },60 { host: Schema.String },
48) {61) {
49 override get message(): string {62 override get message(): string {
50 return `No GitHub credential for ${this.host}: run \`gh auth login --hostname ${this.host}\`.`;63 return `GitHub host ${this.host} is turned off in Settings → Source Control.`;
51 }64 }
52}65}
5366
...@@ -65,8 +78,13 @@ export class GitHubCliFailedError extends Schema.TaggedError<GitHubCliFailedErro...@@ -65,8 +78,13 @@ export class GitHubCliFailedError extends Schema.TaggedError<GitHubCliFailedErro
65export type GitHubCredentialUnavailableError =78export type GitHubCredentialUnavailableError =
66 | GitHubCliMissingError79 | GitHubCliMissingError
67 | GitHubNotSignedInError80 | GitHubNotSignedInError
81 | GitHubHostDisabledError
68 | GitHubCliFailedError;82 | GitHubCliFailedError;
6983
84export const isGitHubCredentialUnavailableError = Schema.is(
85 Schema.Union([GitHubCliMissingError, GitHubNotSignedInError, GitHubHostDisabledError]),
86);
87
70/**88/**
71 * Where GitHub tokens come from. Callers ask per host and never see how the token was found,89 * Where GitHub tokens come from. Callers ask per host and never see how the token was found,
72 * so another source (an in-app OAuth login) slots in here without touching any of them.90 * so another source (an in-app OAuth login) slots in here without touching any of them.
...@@ -115,6 +133,7 @@ export function environmentToken(...@@ -115,6 +133,7 @@ export function environmentToken(
115/** @public Service construction is part of the canonical Effect module API. */133/** @public Service construction is part of the canonical Effect module API. */
116export const make = Effect.gen(function* () {134export const make = Effect.gen(function* () {
117 const process = yield* VcsProcess.VcsProcess;135 const process = yield* VcsProcess.VcsProcess;
136 const serverSettings = yield* ServerSettings.ServerSettingsService;
118 const crypto = yield* Crypto.Crypto;137 const crypto = yield* Crypto.Crypto;
119 const environment = yield* HostProcessEnvironment;138 const environment = yield* HostProcessEnvironment;
120 const workingDirectory = yield* HostProcessWorkingDirectory;139 const workingDirectory = yield* HostProcessWorkingDirectory;
...@@ -127,12 +146,18 @@ export const make = Effect.gen(function* () {...@@ -127,12 +146,18 @@ export const make = Effect.gen(function* () {
127 Effect.orDie,146 Effect.orDie,
128 );147 );
129148
130 const fromGh = (host: string) =>149 const fromGh = (host: string, account: string | undefined) =>
131 process150 process
132 .run({151 .run({
133 operation: "GitHubCredentials.get",152 operation: "GitHubCredentials.get",
134 command: "gh",153 command: "gh",
135 args: ["auth", "token", "--hostname", host],154 args: [
155 "auth",
156 "token",
157 "--hostname",
158 host,
159 ...(account === undefined ? [] : ["--user", account]),
160 ],
136 cwd: workingDirectory,161 cwd: workingDirectory,
137 // Never let gh print the token into a debug log.162 // Never let gh print the token into a debug log.
138 env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" },163 env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" },
...@@ -146,19 +171,48 @@ export const make = Effect.gen(function* () {...@@ -146,19 +171,48 @@ export const make = Effect.gen(function* () {
146 ? new GitHubCliMissingError({ host })171 ? new GitHubCliMissingError({ host })
147 : // gh exits non-zero with "no oauth token" when it has no login for the host.172 : // gh exits non-zero with "no oauth token" when it has no login for the host.
148 error._tag === "VcsProcessExitError"173 error._tag === "VcsProcessExitError"
149 ? new GitHubNotSignedInError({ host })174 ? new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) })
150 : new GitHubCliFailedError({ host, cause: error }),175 : new GitHubCliFailedError({ host, cause: error }),
151 ),176 ),
152 Effect.map((output) => output.stdout.trim()),177 Effect.map((output) => output.stdout.trim()),
153 Effect.filterOrFail(178 Effect.filterOrFail(
154 (token) => token !== "",179 (token) => token !== "",
155 () => new GitHubNotSignedInError({ host }),180 () => new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }),
156 ),181 ),
157 );182 );
158183
159 const lookup = Effect.fn("GitHubCredentials.lookup")(function* (host: string) {184 /** The Settings choice for a host; unreadable settings fall back to gh's own choice. */
185 const hostChoice = (host: string) =>
186 serverSettings.getSettings.pipe(
187 Effect.map((settings) => settings.github.hosts[host]),
188 Effect.orElseSucceed(() => undefined),
189 );
190
191 /** A token saved in Settings for the host, read fresh so a saved or removed one applies at once. */
192 const savedToken = (host: string) =>
193 serverSettings.getSettings.pipe(
194 Effect.map((settings) => settings.github.tokens[host]?.trim() || null),
195 Effect.orElseSucceed(() => null),
196 );
197
198 /** Cache key: the host plus its pinned account, so a changed pin misses the cache. */
199 const cacheKey = (host: string, account: string | undefined) =>
200 account === undefined ? host : `${host}\u0000${account}`;
201
202 const lookup = Effect.fn("GitHubCredentials.lookup")(function* (key: string) {
203 const [host = key, choice] = key.split("\u0000");
204 // An environment token wins over a pinned account, exactly as it does in gh.
160 const fromEnv = environmentToken(host, environment);205 const fromEnv = environmentToken(host, environment);
161 const token = fromEnv ?? (yield* fromGh(host));206 // A pinned login gh no longer holds (logged out, expired) falls back to the active one,
207 // which is what discovery reports as the account in use.
208 const token =
209 fromEnv ??
210 (yield* fromGh(host, choice).pipe(
211 Effect.catchTags({
212 GitHubNotSignedInError: (error) =>
213 choice === undefined ? Effect.fail(error) : fromGh(host, undefined),
214 }),
215 ));
162 return {216 return {
163 host,217 host,
164 token: Redacted.make(token),218 token: Redacted.make(token),
...@@ -181,8 +235,31 @@ export const make = Effect.gen(function* () {...@@ -181,8 +235,31 @@ export const make = Effect.gen(function* () {
181 });235 });
182236
183 return GitHubCredentials.of({237 return GitHubCredentials.of({
184 get: (host) => Cache.get(cache, normalizeHost(host)),238 get: Effect.fn("GitHubCredentials.get")(function* (rawHost) {
185 invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)),239 const host = normalizeHost(rawHost);
240 const choice = yield* hostChoice(host);
241 if (choice?.enabled === false) {
242 return yield* new GitHubHostDisabledError({ host });
243 }
244 // A token saved in Settings is the most deliberate choice, so it comes before the
245 // environment and gh. It is read from the secret store each time, so it needs no cache.
246 const saved = yield* savedToken(host);
247 if (saved !== null) {
248 return {
249 host,
250 token: Redacted.make(saved),
251 source: "settings",
252 fingerprint: yield* fingerprintOf(host, saved),
253 } satisfies GitHubCredential;
254 }
255 return yield* Cache.get(cache, cacheKey(host, choice?.account));
256 }),
257 invalidate: (rawHost) => {
258 const host = normalizeHost(rawHost);
259 return hostChoice(host).pipe(
260 Effect.flatMap((choice) => Cache.invalidate(cache, cacheKey(host, choice?.account))),
261 );
262 },
186 });263 });
187});264});
188265
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+75
...@@ -329,6 +329,7 @@ it("parses GitHub auth status accounts by host and active state", () => {...@@ -329,6 +329,7 @@ it("parses GitHub auth status accounts by host and active state", () => {
329 authenticated: true,329 authenticated: true,
330 active: true,330 active: true,
331 error: null,331 error: null,
332 environmentVariable: null,
332 },333 },
333 {334 {
334 host: "github.com",335 host: "github.com",
...@@ -336,6 +337,7 @@ it("parses GitHub auth status accounts by host and active state", () => {...@@ -336,6 +337,7 @@ it("parses GitHub auth status accounts by host and active state", () => {
336 authenticated: false,337 authenticated: false,
337 active: false,338 active: false,
338 error: null,339 error: null,
340 environmentVariable: null,
339 },341 },
340 {342 {
341 host: "github.example.test",343 host: "github.example.test",
...@@ -343,6 +345,7 @@ it("parses GitHub auth status accounts by host and active state", () => {...@@ -343,6 +345,7 @@ it("parses GitHub auth status accounts by host and active state", () => {
343 authenticated: true,345 authenticated: true,
344 active: false,346 active: false,
345 error: null,347 error: null,
348 environmentVariable: null,
346 },349 },
347 ],350 ],
348 );351 );
...@@ -466,3 +469,75 @@ it.effect.each(["read", "decode"] as const)(...@@ -466,3 +469,75 @@ it.effect.each(["read", "decode"] as const)(
466 else assert.propertyVal(error.cause, "_tag", "SchemaError");469 else assert.propertyVal(error.cause, "_tag", "SchemaError");
467 }),470 }),
468);471);
472
473const multiAccountStatus = (extra: ReadonlyArray<Record<string, unknown>> = []) =>
474 processResult(
475 JSON.stringify({
476 hosts: {
477 "github.com": [
478 { state: "success", active: true, host: "github.com", login: "personal" },
479 { state: "success", active: false, host: "github.com", login: "work" },
480 ...extra,
481 ],
482 "ghe.acme.test": [
483 { state: "error", active: true, host: "ghe.acme.test", login: "jm", error: "expired" },
484 ],
485 },
486 }),
487 );
488
489it("reports every gh login and leads with the account Settings pin", () => {
490 const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), {
491 hosts: { "github.com": { account: "work", enabled: true } },
492 tokens: {},
493 });
494 assert.deepStrictEqual(auth.account, Option.some("work"));
495 assert.deepStrictEqual(auth.accounts, [
496 { host: "github.com", account: "personal", active: true, authenticated: true },
497 { host: "github.com", account: "work", active: false, authenticated: true },
498 { host: "ghe.acme.test", account: "jm", active: true, authenticated: false, error: "expired" },
499 ]);
500});
501
502it("falls back to gh's active login when the pinned account is gone", () => {
503 const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), {
504 hosts: { "github.com": { account: "former-job", enabled: true } },
505 tokens: {},
506 });
507 assert.deepStrictEqual(auth.account, Option.some("personal"));
508});
509
510it("reports unauthenticated when Settings turn off every signed-in host", () => {
511 const auth = GitHubSourceControlProvider.parseGitHubAuth(multiAccountStatus(), {
512 hosts: { "github.com": { enabled: false } },
513 tokens: {},
514 });
515 assert.strictEqual(auth.status, "unauthenticated");
516 assert.deepStrictEqual(
517 auth.detail,
518 Option.some("Every GitHub host gh is signed in to is turned off in Settings → Source Control."),
519 );
520});
521
522it("names the environment token that overrides the Settings choice", () => {
523 const auth = GitHubSourceControlProvider.parseGitHubAuth(
524 multiAccountStatus([
525 {
526 state: "success",
527 active: false,
528 host: "github.com",
529 login: "bot",
530 tokenSource: "GH_TOKEN",
531 },
532 ]),
533 { hosts: { "github.com": { account: "work", enabled: true } }, tokens: {} },
534 );
535 assert.deepStrictEqual(auth.account, Option.some("bot"));
536 assert.deepStrictEqual(
537 auth.detail,
538 Option.some(
539 "Using GH_TOKEN from the server environment; it overrides the account chosen in Settings.",
540 ),
541 );
542 assert.strictEqual(auth.accounts?.[2]?.environmentVariable, "GH_TOKEN");
543});
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+108-29
...@@ -4,16 +4,24 @@ import * as Effect from "effect/Effect";...@@ -4,16 +4,24 @@ import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import * as Result from "effect/Result";5import * as Result from "effect/Result";
6import {6import {
7 DEFAULT_SERVER_SETTINGS,
7 SourceControlProviderError,8 SourceControlProviderError,
8 type ChangeRequest,9 type ChangeRequest,
10 type GitHubSettings,
9 type SourceControlProviderDiscoveryItem,11 type SourceControlProviderDiscoveryItem,
10} from "@t3tools/contracts";12} from "@t3tools/contracts";
1113
12import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";14import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
1315
16import * as ServerSettings from "../serverSettings.ts";
14import * as GitHubApi from "./GitHubApi.ts";17import * as GitHubApi from "./GitHubApi.ts";
15import * as GitHubCli from "./GitHubCli.ts";18import * as GitHubCli from "./GitHubCli.ts";
16import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";19import {
20 effectiveGitHubAccount,
21 findAuthenticatedGitHubAccount,
22 parseGitHubAuthStatus,
23 type GitHubAuthStatusAccount,
24} from "./gitHubAuthStatus.ts";
17import * as SourceControlProvider from "./SourceControlProvider.ts";25import * as SourceControlProvider from "./SourceControlProvider.ts";
18import {26import {
19 combinedAuthOutput,27 combinedAuthOutput,
...@@ -60,29 +68,78 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq...@@ -60,29 +68,78 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
60 };68 };
61}69}
6270
63function parseGitHubAuth(input: SourceControlAuthProbeInput) {71function authAccounts(accounts: ReadonlyArray<GitHubAuthStatusAccount>) {
72 return accounts.map((entry) => ({
73 host: entry.host,
74 account: entry.account,
75 active: entry.active,
76 authenticated: entry.authenticated,
77 ...(entry.error === null ? {} : { error: entry.error }),
78 ...(entry.environmentVariable === null
79 ? {}
80 : { environmentVariable: entry.environmentVariable }),
81 }));
82}
83
84/**
85 * Reads `gh auth status --json hosts`. The headline account is the one GitHub requests will
86 * use: Settings can pin a login per host or turn a host off, and an environment token beats both.
87 */
88export function parseGitHubAuth(
89 input: SourceControlAuthProbeInput,
90 settings: GitHubSettings = DEFAULT_SERVER_SETTINGS.github,
91) {
64 const output = combinedAuthOutput(input);92 const output = combinedAuthOutput(input);
65 const authStatus = parseGitHubAuthStatus(input.stdout);93 const authStatus = parseGitHubAuthStatus(input.stdout);
66 const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts);94 const hosts = [...new Set(authStatus.accounts.map((entry) => entry.host))];
67 const host = authenticatedAccount?.host;95 const fallback = findAuthenticatedGitHubAccount(authStatus.accounts);
96 // Lead with the host gh would pick, unless Settings turned it off.
97 const orderedHosts = fallback
98 ? [fallback.host, ...hosts.filter((host) => host !== fallback.host)]
99 : hosts;
100 const chosen = orderedHosts
101 .map((host) => effectiveGitHubAccount(host, authStatus.accounts, settings))
102 .find((entry) => entry !== undefined);
103 const accounts = authStatus.parsed ? { accounts: authAccounts(authStatus.accounts) } : {};
68104
69 if (authenticatedAccount) {105 if (chosen) {
70 return providerAuth({106 return {
71 status: "authenticated",107 ...providerAuth({
72 account: authenticatedAccount.account,108 status: "authenticated",
73 host,109 account: chosen.account,
74 });110 host: chosen.host,
111 detail:
112 chosen.environmentVariable === null
113 ? undefined
114 : `Using ${chosen.environmentVariable} from the server environment; it overrides the account chosen in Settings.`,
115 }),
116 ...accounts,
117 };
118 }
119
120 if (fallback) {
121 return {
122 ...providerAuth({
123 status: "unauthenticated",
124 host: fallback.host,
125 detail: "Every GitHub host gh is signed in to is turned off in Settings → Source Control.",
126 }),
127 ...accounts,
128 };
75 }129 }
76130
77 const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0];131 const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0];
78 if (authStatus.parsed) {132 if (authStatus.parsed) {
79 return providerAuth({133 return {
80 status: "unauthenticated",134 ...providerAuth({
81 host: failedAccount?.host,135 status: "unauthenticated",
82 detail:136 host: failedAccount?.host,
83 failedAccount?.error ??137 detail:
84 "Run `gh auth login` to authenticate GitHub CLI with an active account.",138 failedAccount?.error ??
85 });139 "Run `gh auth login` to authenticate GitHub CLI with an active account.",
140 }),
141 ...accounts,
142 };
86 }143 }
87144
88 // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit145 // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit
...@@ -98,14 +155,12 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) {...@@ -98,14 +155,12 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) {
98 if (input.exitCode !== 0) {155 if (input.exitCode !== 0) {
99 return providerAuth({156 return providerAuth({
100 status: "unauthenticated",157 status: "unauthenticated",
101 host,
102 detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.",158 detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.",
103 });159 });
104 }160 }
105161
106 return providerAuth({162 return providerAuth({
107 status: "unknown",163 status: "unknown",
108 host,
109 detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.",164 detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.",
110 });165 });
111}166}
...@@ -132,51 +187,75 @@ function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null...@@ -132,51 +187,75 @@ function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null
132}187}
133188
134/**189/**
135 * GitHub is usable with a token from the environment or with `gh` to hand one over. An190 * GitHub is usable with a token saved in Settings, one from the environment, or `gh` to hand one
136 * environment token is checked against the API, since `gh auth status` may not know it.191 * over. Reads the
192 * GitHub settings on every probe, so a saved account choice shows on rescan. An environment
193 * token is checked against the API, since `gh auth status` may not know it.
137 */194 */
138export const makeDiscovery = Effect.gen(function* () {195export const makeDiscovery = Effect.gen(function* () {
139 const api = yield* GitHubApi.GitHubApi;196 const api = yield* GitHubApi.GitHubApi;
140 const process = yield* VcsProcess.VcsProcess;197 const process = yield* VcsProcess.VcsProcess;
141 const environment = yield* HostProcessEnvironment;198 const environment = yield* HostProcessEnvironment;
199 const serverSettings = yield* ServerSettings.ServerSettingsService;
200
142 return {201 return {
143 type: "managed-cli",202 type: "managed-cli",
144 kind: discovery.kind,203 kind: discovery.kind,
145 label: discovery.label,204 label: discovery.label,
146 installHint: discovery.installHint,205 installHint: discovery.installHint,
147 probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) {206 probe: Effect.fn("GitHubSourceControlProvider.discovery")(function* (cwd: string) {
148 const cli = yield* probeSourceControlProvider({ cwd, process, spec: discovery });207 const settings = yield* serverSettings.getSettings.pipe(
208 Effect.map((current) => current.github),
209 Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS.github),
210 );
211 const cli = yield* probeSourceControlProvider({
212 cwd,
213 process,
214 spec: { ...discovery, parseAuth: (input) => parseGitHubAuth(input, settings) },
215 });
216 // A token saved in Settings wins over the environment, which wins over gh.
217 const savedToken = (settings.tokens["github.com"] ?? "").trim() !== "";
149 const variable = environmentTokenVariable(environment);218 const variable = environmentTokenVariable(environment);
150 if (variable === null) return cli;219 const tokenSource = savedToken ? "the token saved in Settings" : variable;
220 // A host turned off in Settings stays off even with a token.
221 if (tokenSource === null || settings.hosts["github.com"]?.enabled === false) return cli;
151 const viewer = yield* api222 const viewer = yield* api
152 .rest({ host: "github.com", operation: "discovery", path: "user" })223 .rest({ host: "github.com", operation: "discovery", path: "user" })
153 .pipe(Effect.result);224 .pipe(Effect.result);
154 const login = Result.isSuccess(viewer)225 const login = Result.isSuccess(viewer)
155 ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login226 ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login
156 : undefined;227 : undefined;
228 // The per-host logins stay, so the account picker still lists every host gh knows.
229 const accounts = cli.auth.accounts === undefined ? {} : { accounts: cli.auth.accounts };
157 return {230 return {
158 ...cli,231 ...cli,
159 status: "available" as const,232 status: "available" as const,
160 auth:233 auth: {
161 login !== undefined234 ...(login !== undefined
162 ? providerAuth({235 ? providerAuth({
163 status: "authenticated",236 status: "authenticated",
164 account: login,237 account: login,
165 host: "github.com",238 host: "github.com",
166 detail: `Using the token in ${variable} from the server environment.`,239 detail: savedToken
240 ? "Using the token saved in Settings; it overrides GH_TOKEN and the gh login."
241 : `Using ${variable} from the server environment; it overrides the account chosen in Settings.`,
167 })242 })
168 : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError"243 : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError"
169 ? // Only a refusal says the token is bad; a network error or a pause says nothing.244 ? // Only a refusal says the token is bad; a network error or a pause says nothing.
170 providerAuth({245 providerAuth({
171 status: "unknown",246 status: "unknown",
172 host: "github.com",247 host: "github.com",
173 detail: `Could not check the token in ${variable}: ${viewer.failure.message}`,248 detail: `Could not check ${savedToken ? tokenSource : `the token in ${tokenSource}`}: ${viewer.failure.message}`,
174 })249 })
175 : providerAuth({250 : providerAuth({
176 status: "unauthenticated",251 status: "unauthenticated",
177 host: "github.com",252 host: "github.com",
178 detail: `GitHub refused the token in ${variable}. Replace it, or unset it to use \`gh auth login\`.`,253 detail: savedToken
179 }),254 ? "GitHub refused the token saved in Settings. Replace or remove it in Settings → Source Control."
255 : `GitHub refused the token in ${tokenSource}. Replace it, or unset it to use \`gh auth login\`.`,
256 })),
257 ...accounts,
258 },
180 } satisfies SourceControlProviderDiscoveryItem;259 } satisfies SourceControlProviderDiscoveryItem;
181 }),260 }),
182 refineUnknownRemote: () => Effect.succeed(null),261 refineUnknownRemote: () => Effect.succeed(null),
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+2
...@@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/process";...@@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/process";
11import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";11import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
12import { VcsProcessSpawnError } from "@t3tools/contracts";12import { VcsProcessSpawnError } from "@t3tools/contracts";
1313
14import * as ServerSettings from "../serverSettings.ts";
14import * as ServerConfig from "../config.ts";15import * as ServerConfig from "../config.ts";
15import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";16import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
16import * as VcsProcess from "../vcs/VcsProcess.ts";17import * as VcsProcess from "../vcs/VcsProcess.ts";
...@@ -38,6 +39,7 @@ const layerSourceControlProviderRegistryTest = (input: {...@@ -38,6 +39,7 @@ const layerSourceControlProviderRegistryTest = (input: {
38 }).pipe(Layer.provide(NodeServices.layer)),39 }).pipe(Layer.provide(NodeServices.layer)),
39 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),40 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
40 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),41 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
42 ServerSettings.ServerSettingsService.layerTest(),
41 Layer.mock(GitHubCli.GitHubCli)({}),43 Layer.mock(GitHubCli.GitHubCli)({}),
42 Layer.mock(GitHubApi.GitHubApi)({}),44 Layer.mock(GitHubApi.GitHubApi)({}),
43 Layer.mock(GitLabCli.GitLabCli)({}),45 Layer.mock(GitLabCli.GitLabCli)({}),
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+2
...@@ -7,6 +7,7 @@ import * as Option from "effect/Option";...@@ -7,6 +7,7 @@ import * as Option from "effect/Option";
7import { ChildProcessSpawner } from "effect/process";7import { ChildProcessSpawner } from "effect/process";
8import { VcsRepositoryDetectionError } from "@t3tools/contracts";8import { VcsRepositoryDetectionError } from "@t3tools/contracts";
99
10import * as ServerSettings from "../serverSettings.ts";
10import * as ServerConfig from "../config.ts";11import * as ServerConfig from "../config.ts";
11import type * as VcsDriver from "../vcs/VcsDriver.ts";12import type * as VcsDriver from "../vcs/VcsDriver.ts";
12import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";13import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
...@@ -96,6 +97,7 @@ function makeRegistry(input: {...@@ -96,6 +97,7 @@ function makeRegistry(input: {
96 layerProcess,97 layerProcess,
97 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),98 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
98 Layer.mock(BitbucketApi.BitbucketApi)({}),99 Layer.mock(BitbucketApi.BitbucketApi)({}),
100 ServerSettings.ServerSettingsService.layerTest(),
99 Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}),101 Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}),
100 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),102 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),
101 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),103 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
apps/server/src/sourceControl/gitHubAuthStatus.ts+31
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import type { GitHubSettings } from "@t3tools/contracts";
1import * as Option from "effect/Option";2import * as Option from "effect/Option";
2import * as Schema from "effect/Schema";3import * as Schema from "effect/Schema";
34
...@@ -7,6 +8,7 @@ const GitHubAuthStatusAccountSchema = Schema.Struct({...@@ -7,6 +8,7 @@ const GitHubAuthStatusAccountSchema = Schema.Struct({
7 active: Schema.Boolean,8 active: Schema.Boolean,
8 host: Schema.String,9 host: Schema.String,
9 login: Schema.String,10 login: Schema.String,
11 tokenSource: Schema.optional(Schema.String),
10});12});
1113
12const GitHubAuthStatusSchema = Schema.Struct({14const GitHubAuthStatusSchema = Schema.Struct({
...@@ -23,6 +25,8 @@ export interface GitHubAuthStatusAccount {...@@ -23,6 +25,8 @@ export interface GitHubAuthStatusAccount {
23 readonly authenticated: boolean;25 readonly authenticated: boolean;
24 readonly active: boolean;26 readonly active: boolean;
25 readonly error: string | null;27 readonly error: string | null;
28 /** The variable (GH_TOKEN and kin) the login came from; it overrides every stored login. */
29 readonly environmentVariable: string | null;
26}30}
2731
28export interface GitHubAuthStatus {32export interface GitHubAuthStatus {
...@@ -30,6 +34,8 @@ export interface GitHubAuthStatus {...@@ -30,6 +34,8 @@ export interface GitHubAuthStatus {
30 readonly accounts: ReadonlyArray<GitHubAuthStatusAccount>;34 readonly accounts: ReadonlyArray<GitHubAuthStatusAccount>;
31}35}
3236
37const ENVIRONMENT_TOKEN_SOURCE = /^(?:GH|GITHUB)_(?:ENTERPRISE_)?TOKEN$/u;
38
33function nonEmptyString(value: string): string | null {39function nonEmptyString(value: string): string | null {
34 const trimmed = value.trim();40 const trimmed = value.trim();
35 return trimmed.length > 0 ? trimmed : null;41 return trimmed.length > 0 ? trimmed : null;
...@@ -54,6 +60,9 @@ export function parseGitHubAuthStatus(text: string): GitHubAuthStatus {...@@ -54,6 +60,9 @@ export function parseGitHubAuthStatus(text: string): GitHubAuthStatus {
54 authenticated: account.state === "success",60 authenticated: account.state === "success",
55 active: account.active,61 active: account.active,
56 error: account.error?.trim() || null,62 error: account.error?.trim() || null,
63 environmentVariable: ENVIRONMENT_TOKEN_SOURCE.test(account.tokenSource ?? "")
64 ? (account.tokenSource ?? null)
65 : null,
57 },66 },
58 ];67 ];
59 }),68 }),
...@@ -70,3 +79,25 @@ export function findAuthenticatedGitHubAccount(...@@ -70,3 +79,25 @@ export function findAuthenticatedGitHubAccount(
70 accounts.find((account) => account.authenticated)79 accounts.find((account) => account.authenticated)
71 );80 );
72}81}
82
83/**
84 * The login GitHub requests for a host will use, honoring Settings: an environment token
85 * first (gh's own precedence), then the pinned account, then gh's active login.
86 * Returns undefined when the host is turned off or has no usable login.
87 */
88export function effectiveGitHubAccount(
89 host: string,
90 accounts: ReadonlyArray<GitHubAuthStatusAccount>,
91 settings: GitHubSettings,
92): GitHubAuthStatusAccount | undefined {
93 const choice = settings.hosts[host];
94 if (choice?.enabled === false) return undefined;
95 const usable = accounts.filter((account) => account.host === host && account.authenticated);
96 return (
97 usable.find((account) => account.environmentVariable !== null) ??
98 (choice?.account === undefined
99 ? undefined
100 : usable.find((account) => account.account === choice.account)) ??
101 findAuthenticatedGitHubAccount(usable)
102 );
103}
apps/web/src/components/settings/GitHubAccountSettings.logic.test.ts created+55
...@@ -0,0 +1,55 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { groupGitHubAccounts, nextGitHubHosts } from "./GitHubAccountSettings.logic";
4
5describe("groupGitHubAccounts", () => {
6 it("groups logins by host, keeping broken and environment logins out of the picker", () => {
7 expect(
8 groupGitHubAccounts([
9 { host: "github.com", account: "personal", active: true, authenticated: true },
10 { host: "github.com", account: "work", active: false, authenticated: true },
11 { host: "github.com", account: "old", active: false, authenticated: false, error: "bad" },
12 {
13 host: "github.com",
14 account: "bot",
15 active: false,
16 authenticated: true,
17 environmentVariable: "GH_TOKEN",
18 },
19 { host: "ghe.acme.test", account: "jm", active: false, authenticated: true },
20 ]),
21 ).toEqual([
22 {
23 host: "github.com",
24 activeAccount: "personal",
25 selectable: ["personal", "work"],
26 broken: [
27 { host: "github.com", account: "old", active: false, authenticated: false, error: "bad" },
28 ],
29 environmentVariable: "GH_TOKEN",
30 },
31 {
32 host: "ghe.acme.test",
33 activeAccount: "jm",
34 selectable: ["jm"],
35 broken: [],
36 environmentVariable: null,
37 },
38 ]);
39 });
40});
41
42describe("nextGitHubHosts", () => {
43 it("pins an account, keeps other hosts, and drops a host back on gh's defaults", () => {
44 const other = { "ghe.acme.test": { enabled: false } };
45 const pinned = nextGitHubHosts(other, "github.com", { account: "work" });
46 expect(pinned).toEqual({ ...other, "github.com": { enabled: true, account: "work" } });
47
48 const disabled = nextGitHubHosts(pinned, "github.com", { enabled: false });
49 expect(disabled["github.com"]).toEqual({ enabled: false, account: "work" });
50
51 expect(nextGitHubHosts(disabled, "github.com", { enabled: true, account: null })).toEqual(
52 other,
53 );
54 });
55});
apps/web/src/components/settings/GitHubAccountSettings.logic.ts created+52
...@@ -0,0 +1,52 @@
1import type { GitHubSettings, SourceControlProviderAuth } from "@t3tools/contracts";
2
3export type GitHubDiscoveredAccount = NonNullable<SourceControlProviderAuth["accounts"]>[number];
4
5export interface GitHubHostGroup {
6 readonly host: string;
7 /** gh's active stored login, the one used when Settings pin nothing. */
8 readonly activeAccount: string | null;
9 /** Stored logins that work and can be pinned with `gh auth token --user`. */
10 readonly selectable: ReadonlyArray<string>;
11 /** Logins gh holds but cannot use; shown with their error. */
12 readonly broken: ReadonlyArray<GitHubDiscoveredAccount>;
13 /** Set when GH_TOKEN or a sibling overrides every stored login for this host. */
14 readonly environmentVariable: string | null;
15}
16
17/** Groups gh's logins by host, in the order gh reported the hosts. */
18export function groupGitHubAccounts(
19 accounts: ReadonlyArray<GitHubDiscoveredAccount>,
20): ReadonlyArray<GitHubHostGroup> {
21 const hosts = [...new Set(accounts.map((entry) => entry.host))];
22 return hosts.map((host) => {
23 const entries = accounts.filter((entry) => entry.host === host);
24 const stored = entries.filter((entry) => entry.environmentVariable === undefined);
25 const selectable = stored.filter((entry) => entry.authenticated);
26 return {
27 host,
28 activeAccount: (selectable.find((entry) => entry.active) ?? selectable[0])?.account ?? null,
29 selectable: selectable.map((entry) => entry.account),
30 broken: stored.filter((entry) => !entry.authenticated),
31 environmentVariable:
32 entries.find((entry) => entry.environmentVariable)?.environmentVariable ?? null,
33 };
34 });
35}
36
37/**
38 * The full `hosts` map after one host changes. Hosts left on gh's defaults (enabled,
39 * no pinned account) are dropped so settings only hold real choices.
40 */
41export function nextGitHubHosts(
42 current: GitHubSettings["hosts"],
43 host: string,
44 change: { readonly enabled?: boolean; readonly account?: string | null },
45): GitHubSettings["hosts"] {
46 const previous = current[host];
47 const enabled = change.enabled ?? previous?.enabled ?? true;
48 const account = change.account === undefined ? previous?.account : (change.account ?? undefined);
49 const { [host]: _replaced, ...rest } = current;
50 if (enabled && account === undefined) return rest;
51 return { ...rest, [host]: { enabled, ...(account === undefined ? {} : { account }) } };
52}
apps/web/src/components/settings/GitHubAccountSettings.tsx created+224
...@@ -0,0 +1,224 @@
1import type { EnvironmentId, SourceControlProviderAuth } from "@t3tools/contracts";
2import { EyeIcon, EyeOffIcon } from "lucide-react";
3import { useState } from "react";
4
5import { useEnvironmentSettings } from "../../hooks/useSettings";
6import { serverEnvironment } from "../../state/server";
7import { useAtomCommand } from "../../state/use-atom-command";
8import { Button } from "../ui/button";
9import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
10import { Switch } from "../ui/switch";
11import { groupGitHubAccounts, nextGitHubHosts } from "./GitHubAccountSettings.logic";
12import { redactedPlaceholder } from "./RedactedSensitiveText";
13
14/** Sentinel select value for "follow gh's active login"; logins never contain spaces. */
15const ACTIVE_ACCOUNT = "active gh account";
16
17/**
18 * A login, blurred like RedactedSensitiveText until the panel reveals it. Plain text, not a
19 * button, so it can sit inside select options; one panel toggle reveals every login.
20 */
21function RedactedLogin(props: {
22 readonly account: string;
23 readonly revealed: boolean;
24 /** Distinguishes hidden logins from each other for a screen reader, e.g. "Account 2". */
25 readonly label?: string;
26}) {
27 return props.revealed ? (
28 <span className="min-w-0 truncate font-mono text-2xs">{props.account}</span>
29 ) : (
30 <span className="min-w-0 truncate font-mono text-2xs">
31 <span className="select-none blur-xs" aria-hidden>
32 {redactedPlaceholder(props.account)}
33 </span>
34 <span className="sr-only">{props.label ?? "Hidden account"}</span>
35 </span>
36 );
37}
38
39/**
40 * Per-host GitHub choices for one environment: turn a host off, or pin which of the
41 * logins `gh` holds is used instead of its active one. Changes save immediately.
42 */
43export function GitHubAccountSettings({
44 environmentId,
45 auth,
46 onSaved,
47}: {
48 readonly environmentId: EnvironmentId;
49 readonly auth: SourceControlProviderAuth;
50 readonly onSaved: () => void;
51}) {
52 const hosts = useEnvironmentSettings(environmentId, (settings) => settings.github.hosts);
53 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
54 label: "save GitHub account settings",
55 });
56 const [saving, setSaving] = useState(false);
57 const [revealed, setRevealed] = useState(false);
58 const groups = groupGitHubAccounts(auth.accounts ?? []);
59
60 const save = async (
61 host: string,
62 change: { readonly enabled?: boolean; readonly account?: string | null },
63 ) => {
64 setSaving(true);
65 try {
66 const result = await updateSettings({
67 environmentId,
68 input: { patch: { github: { hosts: nextGitHubHosts(hosts, host, change) } } },
69 });
70 if (result._tag === "Success") onSaved();
71 } finally {
72 setSaving(false);
73 }
74 };
75
76 if (groups.length === 0) {
77 return (
78 <p className="text-xs text-muted-foreground">
79 Sign in with <code className="rounded bg-muted px-1 py-px text-2xs">gh auth login</code> on
80 the server host, then rescan to choose accounts here.
81 </p>
82 );
83 }
84
85 return (
86 <div className="grid gap-4">
87 <div className="flex items-center justify-between gap-3">
88 <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
89 Choose which <code className="rounded bg-muted px-1 py-px text-2xs">gh</code> login each
90 GitHub host uses, or turn a host off.
91 </p>
92 <Button
93 size="icon-xs"
94 variant="ghost-muted"
95 onClick={() => setRevealed((current) => !current)}
96 aria-label={revealed ? "Hide GitHub accounts" : "Reveal GitHub accounts"}
97 aria-pressed={revealed}
98 >
99 {revealed ? <EyeOffIcon /> : <EyeIcon />}
100 </Button>
101 </div>
102 {groups.map((group) => {
103 const choice = hosts[group.host];
104 const enabled = choice?.enabled ?? true;
105 const stalePin =
106 choice?.account !== undefined && !group.selectable.includes(choice.account);
107 const pinned = choice?.account !== undefined && !stalePin ? choice.account : ACTIVE_ACCOUNT;
108 return (
109 <div key={group.host} className="grid gap-2">
110 <div className="flex items-center justify-between gap-3">
111 <div className="min-w-0 space-y-0.5">
112 <span className="text-xs font-medium text-foreground">{group.host}</span>
113 {group.selectable.length === 1 && group.selectable[0] !== undefined ? (
114 <p className="flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
115 <span>Signed in as</span>
116 <RedactedLogin revealed={revealed} account={group.selectable[0]} />
117 </p>
118 ) : null}
119 </div>
120 <Switch
121 checked={enabled}
122 disabled={saving}
123 aria-label={`Use GitHub on ${group.host}`}
124 onCheckedChange={(checked) => void save(group.host, { enabled: checked })}
125 />
126 </div>
127 {group.selectable.length > 1 ? (
128 <div className="flex items-center justify-between gap-3">
129 <span className="text-xs text-muted-foreground">Account</span>
130 <div className="w-64 max-w-full">
131 <Select
132 value={pinned}
133 disabled={saving || !enabled}
134 onValueChange={(value) => {
135 if (typeof value !== "string") return;
136 void save(group.host, {
137 account: value === ACTIVE_ACCOUNT ? null : value,
138 });
139 }}
140 >
141 <SelectTrigger size="sm" aria-label={`GitHub account for ${group.host}`}>
142 <SelectValue>
143 {(value: string) =>
144 value === ACTIVE_ACCOUNT ? (
145 <span className="flex min-w-0 items-center gap-1">
146 Active gh account
147 {group.activeAccount ? (
148 <>
149 (
150 <RedactedLogin
151 revealed={revealed}
152 account={group.activeAccount}
153 />
154 )
155 </>
156 ) : null}
157 </span>
158 ) : (
159 <RedactedLogin revealed={revealed} account={value} />
160 )
161 }
162 </SelectValue>
163 </SelectTrigger>
164 <SelectPopup align="end" alignItemWithTrigger={false}>
165 <SelectItem value={ACTIVE_ACCOUNT}>
166 <span className="flex min-w-0 items-center gap-1">
167 Active gh account
168 {group.activeAccount ? (
169 <>
170 (<RedactedLogin revealed={revealed} account={group.activeAccount} />)
171 </>
172 ) : null}
173 </span>
174 </SelectItem>
175 {group.selectable.map((account, index) => (
176 <SelectItem key={account} value={account}>
177 <RedactedLogin
178 revealed={revealed}
179 account={account}
180 label={`Account ${index + 1}`}
181 />
182 </SelectItem>
183 ))}
184 </SelectPopup>
185 </Select>
186 </div>
187 </div>
188 ) : null}
189 {stalePin ? (
190 <div className="flex items-center justify-between gap-3">
191 <p className="text-xs text-warning">
192 The chosen login is no longer signed in, so the active gh login is used.
193 </p>
194 <Button
195 size="xs"
196 variant="outline"
197 disabled={saving}
198 onClick={() => void save(group.host, { account: null })}
199 >
200 Use active login
201 </Button>
202 </div>
203 ) : null}
204 {group.broken.map((entry) => (
205 <p
206 key={entry.account}
207 className="flex min-w-0 flex-wrap items-center gap-1 text-xs text-muted-foreground/70"
208 >
209 <RedactedLogin revealed={revealed} account={entry.account} />
210 <span>can't be used: {entry.error ?? "gh reports this login as invalid."}</span>
211 </p>
212 ))}
213 {group.environmentVariable ? (
214 <p className="text-xs text-warning">
215 {group.environmentVariable} is set on the server, so it overrides the account chosen
216 here until it is unset.
217 </p>
218 ) : null}
219 </div>
220 );
221 })}
222 </div>
223 );
224}
apps/web/src/components/settings/GitHubTokenSettings.tsx created+138
...@@ -0,0 +1,138 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import { ExternalLinkIcon } from "lucide-react";
3import { useState } from "react";
4
5import { useEnvironmentSettings } from "../../hooks/useSettings";
6import { serverEnvironment } from "../../state/server";
7import { useAtomCommand } from "../../state/use-atom-command";
8import { Button, InlineButton } from "../ui/button";
9import { Input } from "../ui/input";
10import { Label } from "../ui/label";
11
12const DEFAULT_HOST = "github.com";
13
14/** Where to create a token for a host: GitHub's own page, or the same path on an Enterprise host. */
15function newTokenUrl(host: string): string {
16 return `https://${host}/settings/personal-access-tokens/new`;
17}
18
19/**
20 * A GitHub token for one host, kept in the server's secret store. It is used before
21 * `GH_TOKEN` and the `gh` login, so it also works on a server without `gh` installed.
22 * Write-only: the saved token is never shown, only whether one is set.
23 */
24export function GitHubTokenSettings({
25 environmentId,
26 onSaved,
27}: {
28 readonly environmentId: EnvironmentId;
29 readonly onSaved: () => void;
30}) {
31 const tokens = useEnvironmentSettings(environmentId, (settings) => settings.github.tokens);
32 const updateSettings = useAtomCommand(serverEnvironment.updateSettings, {
33 label: "save GitHub token",
34 });
35 const [host, setHost] = useState(DEFAULT_HOST);
36 const [draft, setDraft] = useState("");
37 const [saving, setSaving] = useState(false);
38 const normalizedHost = host.trim().toLowerCase();
39 const isSaved = (tokens[normalizedHost] ?? "").length > 0;
40 const savedHosts = Object.entries(tokens)
41 .filter(([, value]) => value.length > 0)
42 .map(([saved]) => saved);
43
44 const save = async (target: string, token: string) => {
45 setSaving(true);
46 try {
47 const result = await updateSettings({
48 environmentId,
49 input: { patch: { github: { tokens: { [target]: token } } } },
50 });
51 if (result._tag === "Success") {
52 setDraft("");
53 onSaved();
54 }
55 } finally {
56 setSaving(false);
57 }
58 };
59
60 return (
61 <form
62 className="grid gap-3"
63 onSubmit={(event) => {
64 event.preventDefault();
65 if (normalizedHost && draft.trim()) void save(normalizedHost, draft.trim());
66 }}
67 >
68 {/* Locked while saving: a successful save clears the draft, which would drop edits made mid-request. */}
69 <fieldset disabled={saving} className="contents">
70 <p className="max-w-2xl text-xs leading-relaxed text-muted-foreground">
71 A token saved here is used before{" "}
72 <code className="rounded bg-muted px-1 py-px text-2xs">GH_TOKEN</code> and the{" "}
73 <code className="rounded bg-muted px-1 py-px text-2xs">gh</code> login, so GitHub works
74 without the GitHub CLI. Give it read and write access to pull requests and contents.{" "}
75 <InlineButton
76 render={
77 <a
78 href={newTokenUrl(normalizedHost || DEFAULT_HOST)}
79 target="_blank"
80 rel="noreferrer noopener"
81 />
82 }
83 >
84 Create a token
85 <ExternalLinkIcon aria-hidden className="size-3" />
86 </InlineButton>
87 </p>
88 <div className="grid gap-3 sm:grid-cols-[12rem_1fr]">
89 <div className="grid gap-1.5">
90 <Label htmlFor={`github-token-host-${environmentId}`}>Host</Label>
91 <Input
92 id={`github-token-host-${environmentId}`}
93 autoComplete="off"
94 size="sm"
95 placeholder={DEFAULT_HOST}
96 value={host}
97 onChange={(event) => setHost(event.target.value)}
98 />
99 </div>
100 <div className="grid gap-1.5">
101 <Label htmlFor={`github-token-${environmentId}`}>Token</Label>
102 <Input
103 id={`github-token-${environmentId}`}
104 type="password"
105 autoComplete="off"
106 size="sm"
107 placeholder={isSaved ? "Stored secret, enter a new value to replace" : "Not set"}
108 value={draft}
109 onChange={(event) => setDraft(event.target.value)}
110 />
111 </div>
112 </div>
113 <div className="flex items-center justify-between gap-3">
114 <p className="text-xs text-muted-foreground">
115 {savedHosts.length === 0
116 ? "No token saved; the server uses GH_TOKEN or the gh login."
117 : `Saved for ${savedHosts.join(", ")}.`}
118 </p>
119 <div className="flex shrink-0 gap-2">
120 {isSaved ? (
121 <Button
122 size="xs"
123 variant="outline"
124 disabled={saving}
125 onClick={() => void save(normalizedHost, "")}
126 >
127 Remove
128 </Button>
129 ) : null}
130 <Button type="submit" size="xs" disabled={!normalizedHost || !draft.trim() || saving}>
131 Save
132 </Button>
133 </div>
134 </div>
135 </fieldset>
136 </form>
137 );
138}
apps/web/src/components/settings/RedactedSensitiveText.tsx+1-1
...@@ -5,7 +5,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";...@@ -5,7 +5,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
55
6const REDACTED_TEXT_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";6const REDACTED_TEXT_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
77
8function redactedPlaceholder(value: string): string {8export function redactedPlaceholder(value: string): string {
9 let state = 0x811c9dc5;9 let state = 0x811c9dc5;
10 for (let index = 0; index < value.length; index += 1) {10 for (let index = 0; index < value.length; index += 1) {
11 state ^= value.charCodeAt(index);11 state ^= value.charCodeAt(index);
apps/web/src/components/settings/SourceControlSettings.tsx+32-1
...@@ -57,6 +57,8 @@ import {...@@ -57,6 +57,8 @@ import {
57 type Icon,57 type Icon,
58} from "../Icons";58} from "../Icons";
59import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings";59import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings";
60import { GitHubAccountSettings } from "./GitHubAccountSettings";
61import { GitHubTokenSettings } from "./GitHubTokenSettings";
60import { RedactedSensitiveText } from "./RedactedSensitiveText";62import { RedactedSensitiveText } from "./RedactedSensitiveText";
61import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";63import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
62import {64import {
...@@ -222,6 +224,9 @@ function itemSummary({...@@ -222,6 +224,9 @@ function itemSummary({
222224
223 if (auth) {225 if (auth) {
224 if (auth.status === "authenticated") {226 if (auth.status === "authenticated") {
227 // The server names the account its requests use, Settings choice included, and
228 // says when an environment token overrides it.
229 const authDetail = optionLabel(auth.detail);
225 return (230 return (
226 <>231 <>
227 <span>Authenticated</span>232 <span>Authenticated</span>
...@@ -231,6 +236,7 @@ function itemSummary({...@@ -231,6 +236,7 @@ function itemSummary({
231 <RedactedAccount account={authAccount} />236 <RedactedAccount account={authAccount} />
232 </>237 </>
233 ) : null}238 ) : null}
239 {authDetail ? <span>· {authDetail}</span> : null}
234 </>240 </>
235 );241 );
236 }242 }
...@@ -241,6 +247,11 @@ function itemSummary({...@@ -241,6 +247,11 @@ function itemSummary({
241 return <span>Available. {item.installHint}</span>;247 return <span>Available. {item.installHint}</span>;
242 }248 }
243249
250 // Signed in, but every login is turned off here: the fix is the switch below, not the CLI.
251 if (auth.status === "unauthenticated" && auth.accounts?.some((entry) => entry.authenticated)) {
252 return <span>{optionLabel(auth.detail) ?? `Every ${item.label} host is turned off.`}</span>;
253 }
254
244 if (auth.status === "unauthenticated") {255 if (auth.status === "unauthenticated") {
245 return (256 return (
246 <span>257 <span>
...@@ -283,7 +294,8 @@ function DiscoveryItemRow({...@@ -283,7 +294,8 @@ function DiscoveryItemRow({
283 if (294 if (
284 (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) ||295 (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) ||
285 (item.kind === "bitbucket" &&296 (item.kind === "bitbucket" &&
286 searchTargetId === searchableSetting("bitbucket-credentials").id)297 searchTargetId === searchableSetting("bitbucket-credentials").id) ||
298 (item.kind === "github" && searchTargetId === searchableSetting("github-accounts").id)
287 ) {299 ) {
288 setIsExpanded(true);300 setIsExpanded(true);
289 }301 }
...@@ -603,6 +615,25 @@ export function SourceControlSettingsPanel() {...@@ -603,6 +615,25 @@ export function SourceControlSettingsPanel() {
603 onSaved={handleScan}615 onSaved={handleScan}
604 />616 />
605 </SettingsSearchTarget>617 </SettingsSearchTarget>
618 ) : item.kind === "github" ? (
619 <SettingsSearchTarget id={searchableSetting("github-accounts").id}>
620 <div className="grid gap-6">
621 {/* Shown even without gh: a saved token is how GitHub works without the CLI. */}
622 <GitHubTokenSettings
623 key={`token-${environmentId}`}
624 environmentId={environmentId}
625 onSaved={handleScan}
626 />
627 {item.status === "available" ? (
628 <GitHubAccountSettings
629 key={environmentId}
630 environmentId={environmentId}
631 auth={item.auth}
632 onSaved={handleScan}
633 />
634 ) : null}
635 </div>
636 </SettingsSearchTarget>
606 ) : undefined}637 ) : undefined}
607 </DiscoveryItemRow>638 </DiscoveryItemRow>
608 ))}639 ))}
apps/web/src/components/settings/settingsSearch.ts+10
...@@ -751,6 +751,16 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -751,6 +751,16 @@ export const SETTINGS_SEARCH_ITEMS = [
751 environmentOnly: true,751 environmentOnly: true,
752 scope: "project-defaults",752 scope: "project-defaults",
753 },753 },
754 {
755 id: "github-accounts",
756 title: "GitHub accounts and token",
757 to: "/settings/source-control",
758 searchTerms: [
759 "github gh account login user host enterprise ghes switch multiple accounts disable sign in token personal access token pat api key credential",
760 ],
761 environmentOnly: true,
762 scope: "environment-defaults",
763 },
754 {764 {
755 id: "bitbucket-credentials",765 id: "bitbucket-credentials",
756 title: "Bitbucket credentials",766 title: "Bitbucket credentials",
docs/user/source-control.md+13-6
...@@ -11,11 +11,18 @@ and choose **Rescan**....@@ -11,11 +11,18 @@ and choose **Rescan**.
1111
12### GitHub12### GitHub
1313
14Install [GitHub CLI](https://cli.github.com/) 2.81.0 or newer, then sign in:14T3 Code talks to GitHub's API directly and only needs a token. Any of these works, in this
15order of precedence:
1516
16```bash171. A token saved in **Settings → Source Control → GitHub**. It is kept in the server's secret
17gh auth login18 store, and works without the GitHub CLI.
18```192. `GH_TOKEN` (`GH_ENTERPRISE_TOKEN` with `GH_HOST` for GitHub Enterprise Server) in the
20 server's environment.
213. [GitHub CLI](https://cli.github.com/) 2.81.0 or newer, signed in with `gh auth login`.
22
23If `gh` is signed in to several accounts or hosts, expand **GitHub** in the same place to pick
24the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence
25over that choice; a host turned off stays off either way.
1926
20### Forgejo and Gitea27### Forgejo and Gitea
2128
...@@ -136,7 +143,7 @@ environment clears its permission....@@ -136,7 +143,7 @@ environment clears its permission.
136GitHub review details, linked PR status, and permitted review actions can then use another143GitHub review details, linked PR status, and permitted review actions can then use another
137connected environment signed in to the same GitHub account. Each needs a project on that host.144connected environment signed in to the same GitHub account. Each needs a project on that host.
138A connected local environment is preferred for actions and can answer slow or failed reads.145A connected local environment is preferred for actions and can answer slow or failed reads.
139Browsers and mobile clients need a paired environment to use its GitHub CLI credentials.146Browsers and mobile clients need a paired environment to use its GitHub credentials.
140Credentials stay on their machines. Previously verified credentials remain usable for routing147Credentials stay on their machines. Previously verified credentials remain usable for routing
141for ten minutes during a GitHub outage; new credentials must be verified first. An action with148for ten minutes during a GitHub outage; new credentials must be verified first. An action with
142an uncertain result is never automatically retried elsewhere. Listings, diffs, and checkout or149an uncertain result is never automatically retried elsewhere. Listings, diffs, and checkout or
...@@ -164,7 +171,7 @@ does not show its diff, so marks are made and read on web and desktop....@@ -164,7 +171,7 @@ does not show its diff, so marks are made and read on web and desktop.
164- **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket,171- **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket,
165 check the credentials saved in Settings → Source Control, or confirm the running server received172 check the credentials saved in Settings → Source Control, or confirm the running server received
166 the environment variables.173 the environment variables.
167- **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0.174- **GitHub sign-in cannot be verified:** update GitHub CLI to at least 2.81.0, or save a token in Settings → Source Control.
168- **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS175- **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS
169 remotes can require separate setup from the hosting provider's API access.176 remotes can require separate setup from the hosting provider's API access.
170- **A review cannot load:** open it on the host website while resolving connectivity, permissions,177- **A review cannot load:** open it on the host website while resolving connectivity, permissions,
packages/contracts/src/pullRequest.ts+5-4
...@@ -1264,16 +1264,17 @@ export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.T...@@ -1264,16 +1264,17 @@ export type PullRequestUnavailableReason = typeof PullRequestUnavailableReason.T
12641264
1265/**1265/**
1266 * What each host needs before it can be read, so a failure names the fix rather than the1266 * What each host needs before it can be read, so a failure names the fix rather than the
1267 * symptom. Bitbucket is credentials on the server rather than a signed-in CLI, which is why1267 * symptom. The reason names keep their `cli-` prefix for wire compatibility; for GitHub and
1268 * these are whole sentences instead of a tool name to interpolate.1268 * Bitbucket they mean "no credential" and "a refused credential", not a missing tool.
1269 */1269 */
1270const PROVIDER_REQUIREMENT: Partial<1270const PROVIDER_REQUIREMENT: Partial<
1271 Record<SourceControlProviderKind, { readonly missing: string; readonly unauthenticated: string }>1271 Record<SourceControlProviderKind, { readonly missing: string; readonly unauthenticated: string }>
1272> = {1272> = {
1273 github: {1273 github: {
1274 missing:1274 missing:
1275 "GitHub CLI (`gh`) is required to browse change requests on this host. Install it from https://cli.github.com/ and reload.",1275 "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI (https://cli.github.com/) and run `gh auth login`.",
1276 unauthenticated: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",1276 unauthenticated:
1277 "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.",
1277 },1278 },
1278 forgejo: {1279 forgejo: {
1279 missing:1280 missing:
packages/contracts/src/settings.test.ts+7
...@@ -987,6 +987,13 @@ describe("ServerSettingsPatch.providerInstances", () => {...@@ -987,6 +987,13 @@ describe("ServerSettingsPatch.providerInstances", () => {
987});987});
988988
989describe("ServerSettingsPatch string normalization", () => {989describe("ServerSettingsPatch string normalization", () => {
990 it("lowercases GitHub hosts and defaults them to enabled", () => {
991 const patch = decodeServerSettingsPatch({
992 github: { hosts: { " GitHub.com ": { account: " work " } } },
993 });
994 expect(patch.github?.hosts).toEqual({ "github.com": { account: "work", enabled: true } });
995 });
996
990 it("trims string settings while decoding patches", () => {997 it("trims string settings while decoding patches", () => {
991 const patch = decodeServerSettingsPatch({998 const patch = decodeServerSettingsPatch({
992 addProjectBaseDirectory: " ~/Development ",999 addProjectBaseDirectory: " ~/Development ",
packages/contracts/src/settings.ts+42
...@@ -1013,6 +1013,37 @@ export const BitbucketSettings = Schema.Struct({...@@ -1013,6 +1013,37 @@ export const BitbucketSettings = Schema.Struct({
1013});1013});
1014export type BitbucketSettings = typeof BitbucketSettings.Type;1014export type BitbucketSettings = typeof BitbucketSettings.Type;
10151015
1016/**
1017 * Per-host choices for the GitHub CLI's logins. `account` pins one of the logins
1018 * `gh` holds for the host instead of its active one; a disabled host gets no
1019 * credential at all. A token saved here wins over `GH_TOKEN` and friends, which win over `gh`.
1020 */
1021/** A GitHub host name, lowercased on decode so `GitHub.com` and `github.com` are one entry. */
1022export const GitHubHost = TrimmedNonEmptyString.pipe(
1023 Schema.decodeTo(Schema.String, SchemaTransformation.toLowerCase()),
1024);
1025
1026export const GitHubHostSettings = Schema.Struct({
1027 account: Schema.optionalKey(TrimmedNonEmptyString),
1028 enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
1029});
1030export type GitHubHostSettings = typeof GitHubHostSettings.Type;
1031
1032export const GitHubSettings = Schema.Struct({
1033 /** Keyed by lowercased host, for example `github.com`. */
1034 hosts: Schema.Record(GitHubHost, GitHubHostSettings).pipe(
1035 Schema.withDecodingDefault(Effect.succeed({})),
1036 ),
1037 /**
1038 * A token per host, used before `GH_TOKEN` and `gh`. The server keeps each one in its secret
1039 * store; settings and clients only ever see a redaction marker for a saved token.
1040 */
1041 tokens: Schema.Record(GitHubHost, TrimmedString).pipe(
1042 Schema.withDecodingDefault(Effect.succeed({})),
1043 ),
1044});
1045export type GitHubSettings = typeof GitHubSettings.Type;
1046
1016export const ObservabilitySettings = Schema.Struct({1047export const ObservabilitySettings = Schema.Struct({
1017 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),1048 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
1018 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),1049 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
...@@ -1423,6 +1454,7 @@ export const ServerSettings = Schema.Struct({...@@ -1423,6 +1454,7 @@ export const ServerSettings = Schema.Struct({
1423 ),1454 ),
1424 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),1455 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1425 bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),1456 bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1457 github: GitHubSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1426 // Keyed by a user-chosen id so a source keeps its rows across edits. Entries1458 // Keyed by a user-chosen id so a source keeps its rows across edits. Entries
1427 // this build cannot decode round-trip untouched, as provider instances do.1459 // this build cannot decode round-trip untouched, as provider instances do.
1428 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(1460 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
...@@ -1714,6 +1746,16 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1714,6 +1746,16 @@ export const ServerSettingsPatch = Schema.Struct({
1714 apiToken: Schema.optionalKey(TrimmedString),1746 apiToken: Schema.optionalKey(TrimmedString),
1715 }),1747 }),
1716 ),1748 ),
1749 /**
1750 * `hosts` replaces the whole map, so an omitted host or account clears it. `tokens` merges per
1751 * host: an empty token removes that host's token, the redaction marker keeps it.
1752 */
1753 github: Schema.optionalKey(
1754 Schema.Struct({
1755 hosts: Schema.optionalKey(Schema.Record(GitHubHost, GitHubHostSettings)),
1756 tokens: Schema.optionalKey(Schema.Record(GitHubHost, TrimmedString)),
1757 }),
1758 ),
1717 providers: Schema.optionalKey(1759 providers: Schema.optionalKey(
1718 Schema.Struct({1760 Schema.Struct({
1719 codex: Schema.optionalKey(CodexSettingsPatch),1761 codex: Schema.optionalKey(CodexSettingsPatch),
packages/contracts/src/sourceControl.ts+14
...@@ -123,6 +123,20 @@ export const SourceControlProviderAuth = Schema.Struct({...@@ -123,6 +123,20 @@ export const SourceControlProviderAuth = Schema.Struct({
123 account: Schema.Option(TrimmedNonEmptyString),123 account: Schema.Option(TrimmedNonEmptyString),
124 host: Schema.Option(TrimmedNonEmptyString),124 host: Schema.Option(TrimmedNonEmptyString),
125 detail: Schema.Option(TrimmedNonEmptyString),125 detail: Schema.Option(TrimmedNonEmptyString),
126 /** Every login the provider CLI holds, across hosts. Only GitHub reports these today. */
127 accounts: Schema.optionalKey(
128 Schema.Array(
129 Schema.Struct({
130 host: TrimmedNonEmptyString,
131 account: TrimmedNonEmptyString,
132 active: Schema.Boolean,
133 authenticated: Schema.Boolean,
134 error: Schema.optionalKey(TrimmedNonEmptyString),
135 /** Set when the login comes from a token variable such as `GH_TOKEN`, which wins over Settings. */
136 environmentVariable: Schema.optionalKey(TrimmedNonEmptyString),
137 }),
138 ),
139 ),
126});140});
127export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type;141export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type;
128142
packages/shared/src/serverSettings.test.ts+11
...@@ -41,6 +41,17 @@ describe("serverSettings helpers", () => {...@@ -41,6 +41,17 @@ describe("serverSettings helpers", () => {
41 logsAfterDays: 30,41 logsAfterDays: 30,
42 });42 });
43 });43 });
44 it("replaces GitHub host choices so a cleared account pin does not survive", () => {
45 const pinned = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
46 github: { hosts: { "github.com": { account: "work", enabled: true } } },
47 });
48 expect(pinned.github.hosts).toEqual({ "github.com": { account: "work", enabled: true } });
49 expect(
50 applyServerSettingsPatch(pinned, {
51 github: { hosts: { "github.com": { enabled: false } } },
52 }).github.hosts,
53 ).toEqual({ "github.com": { enabled: false } });
54 });
44 it("replaces SSH host lists when saving, editing, and removing hosts", () => {55 it("replaces SSH host lists when saving, editing, and removing hosts", () => {
45 const host = { id: "mini", label: "Mac mini", target: "mini" };56 const host = { id: "mini", label: "Mac mini", target: "mini" };
46 const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] });57 const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] });
packages/shared/src/serverSettings.ts+4
...@@ -378,6 +378,10 @@ export function applyServerSettingsPatch(...@@ -378,6 +378,10 @@ export function applyServerSettingsPatch(
378 ],378 ],
379 }379 }
380 : {}),380 : {}),
381 // Host replacement: deepMerge would keep a cleared account pin.
382 ...(patch.github?.hosts !== undefined
383 ? { github: { ...next.github, hosts: patch.github.hosts } }
384 : {}),
381 ...(projectSettingsOverridesPatch !== undefined385 ...(projectSettingsOverridesPatch !== undefined
382 ? {386 ? {
383 projectSettingsOverrides: Object.fromEntries(387 projectSettingsOverrides: Object.fromEntries(