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