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(
157157 ForgejoCli.layer,
158158 AzureDevOpsCli.layer,
159159 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
160 BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())),
160 BitbucketApi.layer,
161161 ),
162162 ),
163 // Default settings: no saved Bitbucket token, gh's own GitHub account choice.
164 Layer.provide(ServerSettings.layerTest()),
163165 Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))),
164166 Layer.provide(GitVcsDriver.layer),
165167 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
1414import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts";
1515import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
1616import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts";
17import * as ServerSettings from "../src/serverSettings.ts";
1718import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts";
1819import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts";
1920import * as VcsProcess from "../src/vcs/VcsProcess.ts";
......@@ -64,7 +65,7 @@ const measuredApi = Layer.effect(
6465 });
6566 }),
6667).pipe(
67 Layer.provide(GitHubCredentials.layer),
68 Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))),
6869 Layer.provide(GitHubGraphQlBudget.layer),
6970 Layer.provide(SourceControlRateLimit.layer),
7071 Layer.provide(FetchHttpClient.layer),
apps/server/src/git/GitManager.test.ts+1-1
......@@ -4553,7 +4553,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
45534553 Effect.flip,
45544554 Effect.map((error) => error.message),
45554555 );
4556 expect(errorMessage).toContain("GitHub CLI (`gh`) is required");
4556 expect(errorMessage).toContain("No GitHub credential on the server");
45574557 }),
45584558 );
45594559
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+13-53
......@@ -18,7 +18,6 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
1818import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts";
1919import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
2020import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts";
21import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
2221
2322const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
2423
......@@ -4041,45 +4040,6 @@ layer("GitHubPullRequestCli.layer", (it) => {
40414040 }),
40424041 );
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
40834043 it.effect("stops GraphQL reads at the protected reserve until reset", () =>
40844044 Effect.gen(function* () {
40854045 mockedExecute.mockReturnValue(
......@@ -4088,10 +4048,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
40884048 encodeJson({
40894049 data: {
40904050 repository: {
4091 pullRequest: {
4092 viewerCanUpdateBranch: true,
4093 baseRef: { compare: { behindBy: 4 } },
4094 },
4051 mergeCommitAllowed: true,
4052 squashMergeAllowed: false,
4053 rebaseMergeAllowed: true,
4054 viewerPermission: "READ",
4055 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
40954056 },
40964057 rateLimit: {
40974058 cost: 1,
......@@ -4110,13 +4071,12 @@ layer("GitHubPullRequestCli.layer", (it) => {
41104071 repository: "acme/web",
41114072 host: "github.com",
41124073 number: 7,
4113 headRef: "fork:feat/page",
41144074 } as const;
41154075
4116 yield* cli.getPullRequestBaseComparison(input);
4076 yield* cli.getViewerAccess(input);
41174077 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
41214081 assert.strictEqual(error._tag, "SourceControlRateLimitPausedError");
41224082 if (error._tag !== "SourceControlRateLimitPausedError") return;
......@@ -4136,10 +4096,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
41364096 encodeJson({
41374097 data: {
41384098 repository: {
4139 pullRequest: {
4140 viewerCanUpdateBranch: true,
4141 baseRef: { compare: { behindBy: 4 } },
4142 },
4099 mergeCommitAllowed: true,
4100 squashMergeAllowed: false,
4101 rebaseMergeAllowed: true,
4102 viewerPermission: "READ",
4103 pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true },
41434104 },
41444105 rateLimit: {
41454106 cost: 1,
......@@ -4171,12 +4132,11 @@ layer("GitHubPullRequestCli.layer", (it) => {
41714132 );
41724133 const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
41734134
4174 yield* cli.getPullRequestBaseComparison({
4135 yield* cli.getViewerAccess({
41754136 cwd: "/w",
41764137 repository: "acme/web",
41774138 host: "github.com",
41784139 number: 7,
4179 headRef: "fork:feat/page",
41804140 });
41814141 const access = yield* cli.getViewerAccess({
41824142 cwd: "/w",
apps/server/src/pullRequest/GitHubPullRequestCli.ts+2-34
......@@ -94,8 +94,6 @@ import {
9494 decodePullRequestStackMembershipsJson,
9595 pullRequestSearchGraphQlQuery,
9696 PULL_REQUEST_SEARCH_MAX_ROWS,
97 BASE_COMPARISON_GRAPHQL_QUERY,
98 decodeBaseComparisonJson,
9997 PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY,
10098 PULL_REQUEST_NODE_ID_GRAPHQL_QUERY,
10199 REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY,
......@@ -115,7 +113,6 @@ import {
115113 UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION,
116114 VIEWER_PERMISSIONS_GRAPHQL_QUERY,
117115 decodeViewerPermissionsJson,
118 type GitHubBaseComparison,
119116 type GitHubPullRequestActivity,
120117 type GitHubPullRequestActivityPage,
121118 type GitHubWorkflowRunPage,
......@@ -146,7 +143,7 @@ export class GitHubPullRequestReadError extends Schema.TaggedError<GitHubPullReq
146143 },
147144) {
148145 override get message(): string {
149 return `GitHub CLI returned an unreadable ${this.operation} response.`;
146 return `GitHub returned an unreadable ${this.operation} response.`;
150147 }
151148}
152149
......@@ -159,7 +156,7 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError<GitHub
159156 },
160157) {
161158 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.";
163160 }
164161}
165162
......@@ -588,22 +585,6 @@ export class GitHubPullRequestCli extends Context.Service<
588585 readonly number: number;
589586 }) => 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
607588 readonly getPullRequestActivity: (input: {
608589 readonly cwd: string;
609590 readonly repository: string;
......@@ -2170,19 +2151,6 @@ export const make = Effect.gen(function* () {
21702151 );
21712152 },
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
21862154 getPullRequestActivity: (input) =>
21872155 Effect.gen(function* () {
21882156 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";
77
88import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts";
99import * as GitHubApi from "../sourceControl/GitHubApi.ts";
10import * as GitHubCli from "../sourceControl/GitHubCli.ts";
1011import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
12import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts";
1113import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts";
1214import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts";
1315
......@@ -486,8 +488,6 @@ describe("gitHubViewerPermissions", () => {
486488 url: "https://github.com/acme/web/actions/runs/123",
487489 },
488490 ]),
489 getPullRequestBaseComparison: () =>
490 Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
491491 getViewerAccess: () =>
492492 Effect.succeed({
493493 canWrite: true,
......@@ -585,7 +585,6 @@ it.effect("does not classify same-repository gates as fork workflow approvals",
585585 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
586586 revalidateChecks: (_input, read) => read,
587587 getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }),
588 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
589588 listWorkflowRunsRequiringApproval: () =>
590589 Effect.die("same-repository pull requests must not probe fork workflow approvals"),
591590 getViewerAccess: () =>
......@@ -625,7 +624,6 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () =>
625624 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
626625 revalidateChecks: (_input, read) => read,
627626 getPullRequestDetail: () => Effect.succeed(openDetail),
628 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
629627 listWorkflowRunsRequiringApproval: () =>
630628 Effect.fail(
631629 new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({
......@@ -669,7 +667,6 @@ it.effect("propagates workflow discovery rate limits", () =>
669667 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
670668 revalidateChecks: (_input, read) => read,
671669 getPullRequestDetail: () => Effect.succeed(openDetail),
672 getPullRequestBaseComparison: () => Effect.succeed({ behindBy: 0, viewerCanUpdate: true }),
673670 listWorkflowRunsRequiringApproval: () =>
674671 Effect.fail(
675672 new GitHubApi.GitHubApiRateLimitError({
......@@ -691,6 +688,14 @@ it.effect("propagates workflow discovery rate limits", () =>
691688);
692689
693690describe("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
694699 it.effect("checks fresh access without reading branch details for unrelated operations", () => {
695700 let accessReads = 0;
696701 return Effect.gen(function* () {
......@@ -711,44 +716,27 @@ describe("getViewerPermissions", () => {
711716 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
712717 revalidateChecks: (_input, read) => read,
713718 getPullRequestDetail: () => Effect.die("Unexpected detail read"),
714 getPullRequestBaseComparison: () => Effect.die("Unexpected comparison read"),
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 }),
719 getViewerAccess: () => Effect.sync(() => (accessReads++, access)),
726720 }),
727721 ),
728722 );
729723 });
730724
731 const layerWithComparison = (
732 comparison: Effect.Effect<{
733 readonly behindBy: number | null;
734 readonly viewerCanUpdate: boolean;
735 }>,
725 const layerWithDetail = (
726 detail: Effect.Effect<GitHubPullRequestCore, GitHubPullRequestCli.GitHubPullRequestCliError>,
727 onDetail: (allowReserve: boolean) => void = () => {},
736728 ) =>
737729 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
738730 revalidateChecks: (_input, read) => read,
739 getPullRequestDetail: () => Effect.succeed(openDetail),
740 getPullRequestBaseComparison: () => comparison,
741 getViewerAccess: () =>
742 Effect.succeed({
743 canWrite: true,
744 canTriage: true,
745 canUpdate: true,
746 didAuthor: false,
747 mergeCapabilities: { merge: true, squash: true, rebase: true },
748 }),
731 getPullRequestDetail: () =>
732 GitHubCli.AllowGitHubReserve.pipe(
733 Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))),
734 Effect.flatMap(() => detail),
735 ),
736 getViewerAccess: () => Effect.die("Unexpected access read"),
749737 });
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", () =>
752740 Effect.gen(function* () {
753741 const provider = yield* make;
754742 const permissions = yield* provider.getViewerPermissions({
......@@ -758,16 +746,20 @@ describe("getViewerPermissions", () => {
758746 number: 7,
759747 });
760748
749 expect(permissions.actions).toContain("merge");
761750 expect(permissions.actions).toContain("update-branch");
762751 expect(permissions.updateMethods).toEqual(["merge", "rebase"]);
763752 }).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 ),
765758 ),
766759 );
767760
768761 it.effect("uses the GraphQL reserve for manual permission checks", () => {
769 let viewerAllowReserve: boolean | undefined;
770 let comparisonAllowReserve: boolean | undefined;
762 let allowed: boolean | undefined;
771763 return Effect.gen(function* () {
772764 const provider = yield* make;
773765 yield* provider.getViewerPermissions({
......@@ -776,36 +768,15 @@ describe("getViewerPermissions", () => {
776768 host: "github.com",
777769 number: 7,
778770 });
779
780 expect(viewerAllowReserve).toBe(true);
781 expect(comparisonAllowReserve).toBe(true);
771 expect(allowed).toBe(true);
782772 }).pipe(
783773 Effect.provide(
784 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
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 }),
774 layerWithDetail(Effect.succeed(openDetail), (allowReserve) => (allowed = allowReserve)),
804775 ),
805776 );
806777 });
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", () =>
809780 Effect.gen(function* () {
810781 const provider = yield* make;
811782 const permissions = yield* provider.getViewerPermissions({
......@@ -814,37 +785,42 @@ describe("getViewerPermissions", () => {
814785 host: "github.com",
815786 number: 7,
816787 });
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.
821788 expect(permissions.actions).toContain("merge");
789 expect(permissions.actions).not.toContain("update-branch");
822790 }).pipe(
823791 Effect.provide(
824792 Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({
825793 revalidateChecks: (_input, read) => read,
826 getPullRequestDetail: () => Effect.succeed(openDetail),
827 getPullRequestBaseComparison: () =>
794 getPullRequestDetail: () =>
828795 Effect.fail(
829796 new GitHubPullRequestCli.GitHubPullRequestReadError({
830797 command: "gh",
831798 cwd: "/w",
832 operation: "getPullRequestBaseComparison",
833 cause: new Error("unreadable"),
799 operation: "getPullRequestDetail",
800 cause: new Error("head changed"),
834801 }),
835802 ),
836 getViewerAccess: () =>
837 Effect.succeed({
838 canWrite: true,
839 canTriage: true,
840 canUpdate: true,
841 didAuthor: false,
842 mergeCapabilities: { merge: true, squash: true, rebase: true },
843 }),
803 getViewerAccess: () => Effect.succeed(access),
844804 }),
845805 ),
846806 ),
847807 );
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 );
848824});
849825
850826describe("getChangeRequest commits", () => {
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+29-31
......@@ -7,6 +7,7 @@ import type {
77 PullRequestViewerPermissions,
88} from "@t3tools/contracts";
99
10import * as GitHubCli from "../sourceControl/GitHubCli.ts";
1011import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
1112import {
1213 PullRequestProviderError,
......@@ -109,6 +110,7 @@ export function gitHubProviderFailure(
109110 case "GitHubCliMissingError":
110111 return { reason: "missing-tool" };
111112 case "GitHubNotSignedInError":
113 case "GitHubHostDisabledError":
112114 case "GitHubApiAuthenticationError":
113115 return { reason: "unauthenticated" };
114116 case "GitHubApiRateLimitError":
......@@ -513,38 +515,34 @@ export const make = Effect.gen(function* () {
513515 getReviewThreadComments: (input) =>
514516 cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))),
515517
516 getViewerPermissions: (input) =>
517 Effect.all(
518 [
519 cli.getViewerAccess({ ...input, allowReserve: true }),
520 // Whether this viewer may update the branch is only on the comparison, and the
521 // comparison only resolves through the head ref the detail carries. A failure here
522 // withholds that one action rather than the whole answer, the way the detail path
523 // leaves the banner unknown.
524 input.includeUpdateBranch === false
525 ? Effect.succeed(false)
526 : cli.getPullRequestDetail(input).pipe(
527 Effect.flatMap((pullRequest) =>
528 pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null
529 ? Effect.succeed(false)
530 : cli
531 .getPullRequestBaseComparison({
532 ...input,
533 headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`,
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 }),
518 getViewerPermissions: (input) => {
519 const lightAccess = cli
520 .getViewerAccess({ ...input, allowReserve: true })
521 .pipe(Effect.map((access) => gitHubViewerPermissions(access)));
522 if (input.includeUpdateBranch === false) {
523 return lightAccess.pipe(Effect.mapError(fail("getViewerPermissions")));
524 }
525 // The core detail already carries the viewer's access, the merge settings, and the base
526 // comparison, so one read usually answers what used to take three. When that heavier read
527 // fails, the light access read still answers, withholding only update-branch.
528 return cli.getPullRequestDetail(input).pipe(
529 Effect.provideService(GitHubCli.AllowGitHubReserve, true),
530 Effect.map((pullRequest) =>
531 gitHubViewerPermissions({
532 ...pullRequest.viewerAccess,
533 canUpdateBranch:
534 pullRequest.state === "open" && pullRequest.comparison?.viewerCanUpdate === true,
535 }),
546536 ),
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
549547 getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))),
550548
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts-43
......@@ -11,7 +11,6 @@ import {
1111 decodePullRequestStackMembershipsJson,
1212 buildReviewerRequest,
1313 buildSetFilesViewedGraphQlMutation,
14 decodeBaseComparisonJson,
1514 decodePullRequestActivityJson,
1615 decodePullRequestDetailJson,
1716 decodePullRequestFilesJson,
......@@ -1696,48 +1695,6 @@ describe("decodePullRequestFilesJson", () => {
16961695 });
16971696});
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
17411698describe("decodePullRequestFilesViewedJson", () => {
17421699 const page = (
17431700 nodes: ReadonlyArray<unknown>,
apps/server/src/pullRequest/gitHubPullRequestJson.ts-63
......@@ -2892,75 +2892,12 @@ function toCanTriage(viewerPermission: string | null | undefined): boolean {
28922892 * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses
28932893 * fails whole, taking the people down with the teams.
28942894 */
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
29452895export interface GitHubBaseComparison {
29462896 /** Null where the host could not compare, which the page reads as "unknown". */
29472897 readonly behindBy: number | null;
29482898 readonly viewerCanUpdate: boolean;
29492899}
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
29642901export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) {
29652902 repository(owner: $owner, name: $name) {
29662903 assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) {
apps/server/src/serverSettings.test.ts+48
......@@ -1588,6 +1588,54 @@ it.layer(NodeServices.layer)("server settings", (it) => {
15881588 }).pipe(Effect.provide(layerServerSettingsWithSecrets())),
15891589 );
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
15911639 it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () =>
15921640 Effect.gen(function* () {
15931641 const serverConfig = yield* ServerConfig.ServerConfig;
apps/server/src/serverSettings.ts+80-2
......@@ -161,6 +161,11 @@ const BITBUCKET_SECRET_NAMES = {
161161} as const;
162162const 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
164169const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : "");
165170
166171function redactProviderEnvironmentVariable(
......@@ -204,7 +209,13 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS
204209 accessToken: redactSecret(settings.bitbucket.accessToken),
205210 apiToken: redactSecret(settings.bitbucket.apiToken),
206211 };
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 };
208219}
209220
210221export function applyProviderInstanceMutation(
......@@ -707,7 +718,24 @@ const make = Effect.gen(function* () {
707718 bitbucket[field] = SECRET_REDACTED;
708719 moved = true;
709720 }
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;
711739 });
712740
713741 const loadSettingsFromDisk = Effect.gen(function* () {
......@@ -882,11 +910,27 @@ const make = Effect.gen(function* () {
882910 );
883911 bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : "";
884912 }
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 }
885928 return {
886929 ...settings,
887930 providerInstances: providerInstances as ServerSettings["providerInstances"],
888931 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
889932 bitbucket,
933 github: { ...settings.github, tokens },
890934 };
891935 });
892936
......@@ -1047,12 +1091,46 @@ const make = Effect.gen(function* () {
10471091 bitbucket[field] = SECRET_REDACTED;
10481092 }
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
10501127 return {
10511128 settings: {
10521129 ...next,
10531130 providerInstances: providerInstances as ServerSettings["providerInstances"],
10541131 usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
10551132 bitbucket,
1133 github: { ...next.github, tokens },
10561134 },
10571135 changes,
10581136 };
apps/server/src/sourceControl/GitHubApi.test.ts+2
......@@ -15,6 +15,7 @@ import * as GitHubCredentials from "./GitHubCredentials.ts";
1515import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts";
1616import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
1717import * as VcsProcess from "../vcs/VcsProcess.ts";
18import * as ServerSettings from "../serverSettings.ts";
1819
1920const NOW = Date.parse("2026-10-05T12:00:00.000Z");
2021
......@@ -416,6 +417,7 @@ describe("GitHubCredentials", () => {
416417 GitHubCredentials.layer.pipe(
417418 Layer.provide(Layer.mock(VcsProcess.VcsProcess)({ run })),
418419 Layer.provide(NodeServices.layer),
420 Layer.provide(ServerSettings.layerTest()),
419421 );
420422
421423 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
4444 gitHubCliFailureFields,
4545) {
4646 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`.";
4848 }
4949}
5050
......@@ -53,7 +53,10 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError<GitHubCliAu
5353 gitHubCliFailureFields,
5454) {
5555 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.";
5760 }
5861}
5962
......@@ -153,6 +156,7 @@ function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHu
153156 case "GitHubCliMissingError":
154157 return new GitHubCliUnavailableError(context);
155158 case "GitHubNotSignedInError":
159 case "GitHubHostDisabledError":
156160 case "GitHubApiAuthenticationError":
157161 return new GitHubCliAuthenticationError(context);
158162 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";
1313
1414import { HostProcessEnvironment, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess";
1515
16import * as ServerSettings from "../serverSettings.ts";
1617import * as VcsProcess from "../vcs/VcsProcess.ts";
1718
1819/** 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);
2021/** No credential is retried sooner, so a fresh `gh auth login` takes effect on the next read. */
2122const MISSING_TTL = Duration.seconds(10);
2223
23export const GitHubCredentialSource = Schema.Literals(["env", "gh"]);
24export const GitHubCredentialSource = Schema.Literals(["settings", "env", "gh"]);
2425export type GitHubCredentialSource = typeof GitHubCredentialSource.Type;
2526
2627export interface GitHubCredential {
......@@ -41,13 +42,25 @@ export class GitHubCliMissingError extends Schema.TaggedError<GitHubCliMissingEr
4142 }
4243}
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. */
4546export class GitHubNotSignedInError extends Schema.TaggedError<GitHubNotSignedInError>()(
4647 "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",
4760 { host: Schema.String },
4861) {
4962 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.`;
5164 }
5265}
5366
......@@ -65,8 +78,13 @@ export class GitHubCliFailedError extends Schema.TaggedError<GitHubCliFailedErro
6578export type GitHubCredentialUnavailableError =
6679 | GitHubCliMissingError
6780 | GitHubNotSignedInError
81 | GitHubHostDisabledError
6882 | GitHubCliFailedError;
6983
84export const isGitHubCredentialUnavailableError = Schema.is(
85 Schema.Union([GitHubCliMissingError, GitHubNotSignedInError, GitHubHostDisabledError]),
86);
87
7088/**
7189 * Where GitHub tokens come from. Callers ask per host and never see how the token was found,
7290 * so another source (an in-app OAuth login) slots in here without touching any of them.
......@@ -115,6 +133,7 @@ export function environmentToken(
115133/** @public Service construction is part of the canonical Effect module API. */
116134export const make = Effect.gen(function* () {
117135 const process = yield* VcsProcess.VcsProcess;
136 const serverSettings = yield* ServerSettings.ServerSettingsService;
118137 const crypto = yield* Crypto.Crypto;
119138 const environment = yield* HostProcessEnvironment;
120139 const workingDirectory = yield* HostProcessWorkingDirectory;
......@@ -127,12 +146,18 @@ export const make = Effect.gen(function* () {
127146 Effect.orDie,
128147 );
129148
130 const fromGh = (host: string) =>
149 const fromGh = (host: string, account: string | undefined) =>
131150 process
132151 .run({
133152 operation: "GitHubCredentials.get",
134153 command: "gh",
135 args: ["auth", "token", "--hostname", host],
154 args: [
155 "auth",
156 "token",
157 "--hostname",
158 host,
159 ...(account === undefined ? [] : ["--user", account]),
160 ],
136161 cwd: workingDirectory,
137162 // Never let gh print the token into a debug log.
138163 env: { GH_DEBUG: "", GH_PROMPT_DISABLED: "1" },
......@@ -146,19 +171,48 @@ export const make = Effect.gen(function* () {
146171 ? new GitHubCliMissingError({ host })
147172 : // gh exits non-zero with "no oauth token" when it has no login for the host.
148173 error._tag === "VcsProcessExitError"
149 ? new GitHubNotSignedInError({ host })
174 ? new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) })
150175 : new GitHubCliFailedError({ host, cause: error }),
151176 ),
152177 Effect.map((output) => output.stdout.trim()),
153178 Effect.filterOrFail(
154179 (token) => token !== "",
155 () => new GitHubNotSignedInError({ host }),
180 () => new GitHubNotSignedInError({ host, ...(account === undefined ? {} : { account }) }),
156181 ),
157182 );
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.
160205 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 ));
162216 return {
163217 host,
164218 token: Redacted.make(token),
......@@ -181,8 +235,31 @@ export const make = Effect.gen(function* () {
181235 });
182236
183237 return GitHubCredentials.of({
184 get: (host) => Cache.get(cache, normalizeHost(host)),
185 invalidate: (host) => Cache.invalidate(cache, normalizeHost(host)),
238 get: Effect.fn("GitHubCredentials.get")(function* (rawHost) {
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 },
186263 });
187264});
188265
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+75
......@@ -329,6 +329,7 @@ it("parses GitHub auth status accounts by host and active state", () => {
329329 authenticated: true,
330330 active: true,
331331 error: null,
332 environmentVariable: null,
332333 },
333334 {
334335 host: "github.com",
......@@ -336,6 +337,7 @@ it("parses GitHub auth status accounts by host and active state", () => {
336337 authenticated: false,
337338 active: false,
338339 error: null,
340 environmentVariable: null,
339341 },
340342 {
341343 host: "github.example.test",
......@@ -343,6 +345,7 @@ it("parses GitHub auth status accounts by host and active state", () => {
343345 authenticated: true,
344346 active: false,
345347 error: null,
348 environmentVariable: null,
346349 },
347350 ],
348351 );
......@@ -466,3 +469,75 @@ it.effect.each(["read", "decode"] as const)(
466469 else assert.propertyVal(error.cause, "_tag", "SchemaError");
467470 }),
468471);
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";
44import * as Option from "effect/Option";
55import * as Result from "effect/Result";
66import {
7 DEFAULT_SERVER_SETTINGS,
78 SourceControlProviderError,
89 type ChangeRequest,
10 type GitHubSettings,
911 type SourceControlProviderDiscoveryItem,
1012} from "@t3tools/contracts";
1113
1214import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
1315
16import * as ServerSettings from "../serverSettings.ts";
1417import * as GitHubApi from "./GitHubApi.ts";
1518import * 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";
1725import * as SourceControlProvider from "./SourceControlProvider.ts";
1826import {
1927 combinedAuthOutput,
......@@ -60,29 +68,78 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
6068 };
6169}
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) {
6492 const output = combinedAuthOutput(input);
6593 const authStatus = parseGitHubAuthStatus(input.stdout);
66 const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts);
67 const host = authenticatedAccount?.host;
94 const hosts = [...new Set(authStatus.accounts.map((entry) => entry.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) {
70 return providerAuth({
71 status: "authenticated",
72 account: authenticatedAccount.account,
73 host,
74 });
105 if (chosen) {
106 return {
107 ...providerAuth({
108 status: "authenticated",
109 account: chosen.account,
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 };
75129 }
76130
77131 const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0];
78132 if (authStatus.parsed) {
79 return providerAuth({
80 status: "unauthenticated",
81 host: failedAccount?.host,
82 detail:
83 failedAccount?.error ??
84 "Run `gh auth login` to authenticate GitHub CLI with an active account.",
85 });
133 return {
134 ...providerAuth({
135 status: "unauthenticated",
136 host: failedAccount?.host,
137 detail:
138 failedAccount?.error ??
139 "Run `gh auth login` to authenticate GitHub CLI with an active account.",
140 }),
141 ...accounts,
142 };
86143 }
87144
88145 // gh gained `auth status --json` in 2.81.0. Older versions reject the flag and exit
......@@ -98,14 +155,12 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) {
98155 if (input.exitCode !== 0) {
99156 return providerAuth({
100157 status: "unauthenticated",
101 host,
102158 detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.",
103159 });
104160 }
105161
106162 return providerAuth({
107163 status: "unknown",
108 host,
109164 detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.",
110165 });
111166}
......@@ -132,51 +187,75 @@ function environmentTokenVariable(environment: NodeJS.ProcessEnv): string | null
132187}
133188
134189/**
135 * GitHub is usable with a token from the environment or with `gh` to hand one over. An
136 * environment token is checked against the API, since `gh auth status` may not know it.
190 * GitHub is usable with a token saved in Settings, one from the environment, or `gh` to hand one
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.
137194 */
138195export const makeDiscovery = Effect.gen(function* () {
139196 const api = yield* GitHubApi.GitHubApi;
140197 const process = yield* VcsProcess.VcsProcess;
141198 const environment = yield* HostProcessEnvironment;
199 const serverSettings = yield* ServerSettings.ServerSettingsService;
200
142201 return {
143202 type: "managed-cli",
144203 kind: discovery.kind,
145204 label: discovery.label,
146205 installHint: discovery.installHint,
147206 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() !== "";
149218 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;
151222 const viewer = yield* api
152223 .rest({ host: "github.com", operation: "discovery", path: "user" })
153224 .pipe(Effect.result);
154225 const login = Result.isSuccess(viewer)
155226 ? Option.getOrUndefined(decodeViewer(viewer.success.body))?.login
156227 : 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 };
157230 return {
158231 ...cli,
159232 status: "available" as const,
160 auth:
161 login !== undefined
233 auth: {
234 ...(login !== undefined
162235 ? providerAuth({
163236 status: "authenticated",
164237 account: login,
165238 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.`,
167242 })
168243 : Result.isFailure(viewer) && viewer.failure._tag !== "GitHubApiAuthenticationError"
169244 ? // Only a refusal says the token is bad; a network error or a pause says nothing.
170245 providerAuth({
171246 status: "unknown",
172247 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}`,
174249 })
175250 : providerAuth({
176251 status: "unauthenticated",
177252 host: "github.com",
178 detail: `GitHub refused the token in ${variable}. Replace it, or unset it to use \`gh auth login\`.`,
179 }),
253 detail: savedToken
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 },
180259 } satisfies SourceControlProviderDiscoveryItem;
181260 }),
182261 refineUnknownRemote: () => Effect.succeed(null),
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+2
......@@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/process";
1111import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
1212import { VcsProcessSpawnError } from "@t3tools/contracts";
1313
14import * as ServerSettings from "../serverSettings.ts";
1415import * as ServerConfig from "../config.ts";
1516import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1617import * as VcsProcess from "../vcs/VcsProcess.ts";
......@@ -38,6 +39,7 @@ const layerSourceControlProviderRegistryTest = (input: {
3839 }).pipe(Layer.provide(NodeServices.layer)),
3940 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
4041 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
42 ServerSettings.ServerSettingsService.layerTest(),
4143 Layer.mock(GitHubCli.GitHubCli)({}),
4244 Layer.mock(GitHubApi.GitHubApi)({}),
4345 Layer.mock(GitLabCli.GitLabCli)({}),
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+2
......@@ -7,6 +7,7 @@ import * as Option from "effect/Option";
77import { ChildProcessSpawner } from "effect/process";
88import { VcsRepositoryDetectionError } from "@t3tools/contracts";
99
10import * as ServerSettings from "../serverSettings.ts";
1011import * as ServerConfig from "../config.ts";
1112import type * as VcsDriver from "../vcs/VcsDriver.ts";
1213import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
......@@ -96,6 +97,7 @@ function makeRegistry(input: {
9697 layerProcess,
9798 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
9899 Layer.mock(BitbucketApi.BitbucketApi)({}),
100 ServerSettings.ServerSettingsService.layerTest(),
99101 Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}),
100102 Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}),
101103 Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}),
apps/server/src/sourceControl/gitHubAuthStatus.ts+31
......@@ -1,3 +1,4 @@
1import type { GitHubSettings } from "@t3tools/contracts";
12import * as Option from "effect/Option";
23import * as Schema from "effect/Schema";
34
......@@ -7,6 +8,7 @@ const GitHubAuthStatusAccountSchema = Schema.Struct({
78 active: Schema.Boolean,
89 host: Schema.String,
910 login: Schema.String,
11 tokenSource: Schema.optional(Schema.String),
1012});
1113
1214const GitHubAuthStatusSchema = Schema.Struct({
......@@ -23,6 +25,8 @@ export interface GitHubAuthStatusAccount {
2325 readonly authenticated: boolean;
2426 readonly active: boolean;
2527 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;
2630}
2731
2832export interface GitHubAuthStatus {
......@@ -30,6 +34,8 @@ export interface GitHubAuthStatus {
3034 readonly accounts: ReadonlyArray<GitHubAuthStatusAccount>;
3135}
3236
37const ENVIRONMENT_TOKEN_SOURCE = /^(?:GH|GITHUB)_(?:ENTERPRISE_)?TOKEN$/u;
38
3339function nonEmptyString(value: string): string | null {
3440 const trimmed = value.trim();
3541 return trimmed.length > 0 ? trimmed : null;
......@@ -54,6 +60,9 @@ export function parseGitHubAuthStatus(text: string): GitHubAuthStatus {
5460 authenticated: account.state === "success",
5561 active: account.active,
5662 error: account.error?.trim() || null,
63 environmentVariable: ENVIRONMENT_TOKEN_SOURCE.test(account.tokenSource ?? "")
64 ? (account.tokenSource ?? null)
65 : null,
5766 },
5867 ];
5968 }),
......@@ -70,3 +79,25 @@ export function findAuthenticatedGitHubAccount(
7079 accounts.find((account) => account.authenticated)
7180 );
7281}
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";
55
66const REDACTED_TEXT_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
77
8function redactedPlaceholder(value: string): string {
8export function redactedPlaceholder(value: string): string {
99 let state = 0x811c9dc5;
1010 for (let index = 0; index < value.length; index += 1) {
1111 state ^= value.charCodeAt(index);
apps/web/src/components/settings/SourceControlSettings.tsx+32-1
......@@ -57,6 +57,8 @@ import {
5757 type Icon,
5858} from "../Icons";
5959import { BitbucketCredentialsSettings } from "./BitbucketCredentialsSettings";
60import { GitHubAccountSettings } from "./GitHubAccountSettings";
61import { GitHubTokenSettings } from "./GitHubTokenSettings";
6062import { RedactedSensitiveText } from "./RedactedSensitiveText";
6163import { SourceControlWritingSettingsSection } from "./SourceControlWritingSettings";
6264import {
......@@ -222,6 +224,9 @@ function itemSummary({
222224
223225 if (auth) {
224226 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);
225230 return (
226231 <>
227232 <span>Authenticated</span>
......@@ -231,6 +236,7 @@ function itemSummary({
231236 <RedactedAccount account={authAccount} />
232237 </>
233238 ) : null}
239 {authDetail ? <span>· {authDetail}</span> : null}
234240 </>
235241 );
236242 }
......@@ -241,6 +247,11 @@ function itemSummary({
241247 return <span>Available. {item.installHint}</span>;
242248 }
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
244255 if (auth.status === "unauthenticated") {
245256 return (
246257 <span>
......@@ -283,7 +294,8 @@ function DiscoveryItemRow({
283294 if (
284295 (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) ||
285296 (item.kind === "bitbucket" &&
286 searchTargetId === searchableSetting("bitbucket-credentials").id)
297 searchTargetId === searchableSetting("bitbucket-credentials").id) ||
298 (item.kind === "github" && searchTargetId === searchableSetting("github-accounts").id)
287299 ) {
288300 setIsExpanded(true);
289301 }
......@@ -603,6 +615,25 @@ export function SourceControlSettingsPanel() {
603615 onSaved={handleScan}
604616 />
605617 </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>
606637 ) : undefined}
607638 </DiscoveryItemRow>
608639 ))}
apps/web/src/components/settings/settingsSearch.ts+10
......@@ -751,6 +751,16 @@ export const SETTINGS_SEARCH_ITEMS = [
751751 environmentOnly: true,
752752 scope: "project-defaults",
753753 },
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 },
754764 {
755765 id: "bitbucket-credentials",
756766 title: "Bitbucket credentials",
docs/user/source-control.md+13-6
......@@ -11,11 +11,18 @@ and choose **Rescan**.
1111
1212### 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```bash
17gh auth login
18```
171. A token saved in **Settings → Source Control → GitHub**. It is kept in the server's secret
18 store, and works without the GitHub CLI.
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
2027### Forgejo and Gitea
2128
......@@ -136,7 +143,7 @@ environment clears its permission.
136143GitHub review details, linked PR status, and permitted review actions can then use another
137144connected environment signed in to the same GitHub account. Each needs a project on that host.
138145A 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.
140147Credentials stay on their machines. Previously verified credentials remain usable for routing
141148for ten minutes during a GitHub outage; new credentials must be verified first. An action with
142149an 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.
164171- **Not authenticated:** run the provider's login command on the server, then rescan. For Bitbucket,
165172 check the credentials saved in Settings → Source Control, or confirm the running server received
166173 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.
168175- **Push fails despite a connected account:** check the Git remote's credentials. SSH and HTTPS
169176 remotes can require separate setup from the hosting provider's API access.
170177- **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
12641264
12651265/**
12661266 * 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 why
1268 * these are whole sentences instead of a tool name to interpolate.
1267 * symptom. The reason names keep their `cli-` prefix for wire compatibility; for GitHub and
1268 * Bitbucket they mean "no credential" and "a refused credential", not a missing tool.
12691269 */
12701270const PROVIDER_REQUIREMENT: Partial<
12711271 Record<SourceControlProviderKind, { readonly missing: string; readonly unauthenticated: string }>
12721272> = {
12731273 github: {
12741274 missing:
1275 "GitHub CLI (`gh`) is required to browse change requests on this host. Install it from https://cli.github.com/ and reload.",
1276 unauthenticated: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
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:
1277 "GitHub has no working credential for this host. Run `gh auth login`, or check the account and hosts in Settings → Source Control.",
12771278 },
12781279 forgejo: {
12791280 missing:
packages/contracts/src/settings.test.ts+7
......@@ -987,6 +987,13 @@ describe("ServerSettingsPatch.providerInstances", () => {
987987});
988988
989989describe("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
990997 it("trims string settings while decoding patches", () => {
991998 const patch = decodeServerSettingsPatch({
992999 addProjectBaseDirectory: " ~/Development ",
packages/contracts/src/settings.ts+42
......@@ -1013,6 +1013,37 @@ export const BitbucketSettings = Schema.Struct({
10131013});
10141014export 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
10161047export const ObservabilitySettings = Schema.Struct({
10171048 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
10181049 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
......@@ -1423,6 +1454,7 @@ export const ServerSettings = Schema.Struct({
14231454 ),
14241455 observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
14251456 bitbucket: BitbucketSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
1457 github: GitHubSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
14261458 // Keyed by a user-chosen id so a source keeps its rows across edits. Entries
14271459 // this build cannot decode round-trip untouched, as provider instances do.
14281460 usageLimitSources: Schema.Record(UsageLimitSourceId, UsageLimitSourceConfig).pipe(
......@@ -1714,6 +1746,16 @@ export const ServerSettingsPatch = Schema.Struct({
17141746 apiToken: Schema.optionalKey(TrimmedString),
17151747 }),
17161748 ),
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 ),
17171759 providers: Schema.optionalKey(
17181760 Schema.Struct({
17191761 codex: Schema.optionalKey(CodexSettingsPatch),
packages/contracts/src/sourceControl.ts+14
......@@ -123,6 +123,20 @@ export const SourceControlProviderAuth = Schema.Struct({
123123 account: Schema.Option(TrimmedNonEmptyString),
124124 host: Schema.Option(TrimmedNonEmptyString),
125125 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 ),
126140});
127141export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type;
128142
packages/shared/src/serverSettings.test.ts+11
......@@ -41,6 +41,17 @@ describe("serverSettings helpers", () => {
4141 logsAfterDays: 30,
4242 });
4343 });
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 });
4455 it("replaces SSH host lists when saving, editing, and removing hosts", () => {
4556 const host = { id: "mini", label: "Mac mini", target: "mini" };
4657 const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] });
packages/shared/src/serverSettings.ts+4
......@@ -378,6 +378,10 @@ export function applyServerSettingsPatch(
378378 ],
379379 }
380380 : {}),
381 // Host replacement: deepMerge would keep a cleared account pin.
382 ...(patch.github?.hosts !== undefined
383 ? { github: { ...next.github, hosts: patch.github.hosts } }
384 : {}),
381385 ...(projectSettingsOverridesPatch !== undefined
382386 ? {
383387 projectSettingsOverrides: Object.fromEntries(