| 1 | import { Octokit } from "@octokit/rest"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Redacted from "effect/Redacted"; |
| 6 | import { AuthError } from "../Auth/AuthProvider.ts"; |
| 7 | import { |
| 8 | deferUntilFirstUse, |
| 9 | orDieCredentialsUnavailable, |
| 10 | resolveProviderConfig, |
| 11 | } from "../Auth/Resolve.ts"; |
| 12 | import { |
| 13 | GITHUB_AUTH_PROVIDER_NAME, |
| 14 | type GitHubAuthConfig, |
| 15 | type GitHubResolvedCredentials, |
| 16 | readEnvCredentials, |
| 17 | } from "./AuthProvider.ts"; |
| 18 | import { normalizeGitHubBaseUrl } from "./BaseUrl.ts"; |
| 19 | |
| 20 | export interface GitHubCredentialsService { |
| 21 | readonly token: Redacted.Redacted<string>; |
| 22 | /** |
| 23 | * REST API base URL for GitHub Enterprise (e.g. |
| 24 | * `https://github.example.com/api/v3` for GitHub Enterprise Server, or |
| 25 | * `https://api.acme.ghe.com` for GitHub Enterprise Cloud with data |
| 26 | * residency). `undefined` targets github.com. |
| 27 | */ |
| 28 | readonly baseUrl?: string; |
| 29 | /** |
| 30 | * Construct an Octokit for these credentials. Pass `override` to target a |
| 31 | * different host than the credentials' own `baseUrl` — `override.baseUrl` |
| 32 | * is used verbatim, including `undefined` for github.com (which is why the |
| 33 | * override is an object rather than an optional string: it distinguishes |
| 34 | * "no override" from "override to the github.com default"). |
| 35 | */ |
| 36 | readonly octokit: (override?: { baseUrl: string | undefined }) => Octokit; |
| 37 | } |
| 38 | |
| 39 | export class GitHubCredentials extends Context.Service< |
| 40 | GitHubCredentials, |
| 41 | Effect.Effect<GitHubCredentialsService> |
| 42 | >()("GitHub::Credentials") {} |
| 43 | |
| 44 | const make = ( |
| 45 | token: Redacted.Redacted<string>, |
| 46 | baseUrl?: string, |
| 47 | ): GitHubCredentialsService => ({ |
| 48 | token, |
| 49 | baseUrl, |
| 50 | octokit: (override) => { |
| 51 | const url = override !== undefined ? override.baseUrl : baseUrl; |
| 52 | return new Octokit({ |
| 53 | auth: Redacted.value(token), |
| 54 | ...(url !== undefined ? { baseUrl: url } : {}), |
| 55 | }); |
| 56 | }, |
| 57 | }); |
| 58 | |
| 59 | /** |
| 60 | * Build a `GitHubCredentials` layer from a literal token. Useful for |
| 61 | * tests or when callers already have a PAT in hand. |
| 62 | * |
| 63 | * Pass `baseUrl` to target a GitHub Enterprise instance. It accepts a |
| 64 | * hostname or URL and is normalized into the REST API base URL — a GitHub |
| 65 | * Enterprise Server host gets `/api/v3` appended, a `*.ghe.com` |
| 66 | * data-residency host gets the `api.` prefix. |
| 67 | */ |
| 68 | export const fromToken = ( |
| 69 | token: string | Redacted.Redacted<string>, |
| 70 | options?: { readonly baseUrl?: string }, |
| 71 | ) => |
| 72 | Layer.succeed( |
| 73 | GitHubCredentials, |
| 74 | Effect.gen(function* () { |
| 75 | const baseUrl = |
| 76 | options?.baseUrl !== undefined |
| 77 | ? yield* normalizeGitHubBaseUrl(options.baseUrl) |
| 78 | : undefined; |
| 79 | return make( |
| 80 | typeof token === "string" ? Redacted.make(token) : token, |
| 81 | baseUrl, |
| 82 | ); |
| 83 | }).pipe(Effect.orDie), |
| 84 | ); |
| 85 | |
| 86 | /** |
| 87 | * Build a `GitHubCredentials` layer that reads the token from |
| 88 | * `GITHUB_ACCESS_TOKEN` or `GITHUB_TOKEN` at layer build time. |
| 89 | * |
| 90 | * GitHub Enterprise is resolved from the environment too: `GITHUB_BASE_URL`, |
| 91 | * `GITHUB_API_URL` (set by GitHub Actions runners), or `GH_HOST` select the |
| 92 | * host, and on an enterprise host `GH_ENTERPRISE_TOKEN` / |
| 93 | * `GITHUB_ENTERPRISE_TOKEN` are checked before the standard token variables. |
| 94 | */ |
| 95 | export const fromEnv = () => |
| 96 | Layer.succeed( |
| 97 | GitHubCredentials, |
| 98 | readEnvCredentials().pipe( |
| 99 | Effect.map((creds) => make(creds.token, creds.baseUrl)), |
| 100 | Effect.orDie, |
| 101 | ), |
| 102 | ); |
| 103 | |
| 104 | /** |
| 105 | * Build a `GitHubCredentials` layer that resolves a token via the |
| 106 | * Alchemy AuthProvider for the configured profile (defaults to |
| 107 | * the current Alchemy profile). |
| 108 | * |
| 109 | * Pass `baseUrl` to hard-code the GitHub host — it takes precedence over |
| 110 | * whatever host the auth provider resolved from the profile config or |
| 111 | * environment. `GitHub.providers({ baseUrl })` threads its option here. |
| 112 | */ |
| 113 | export const fromAuthProvider = (options?: { readonly baseUrl?: string }) => |
| 114 | Layer.effect( |
| 115 | GitHubCredentials, |
| 116 | Effect.gen(function* () { |
| 117 | const fixedBaseUrl = |
| 118 | options?.baseUrl !== undefined |
| 119 | ? { baseUrl: yield* normalizeGitHubBaseUrl(options.baseUrl) } |
| 120 | : undefined; |
| 121 | // Defer profile lookup and credential resolution until first use, so |
| 122 | // building the provider layers never requires a configured profile. |
| 123 | const resolve = yield* resolveProviderConfig< |
| 124 | GitHubAuthConfig, |
| 125 | GitHubResolvedCredentials |
| 126 | >(GITHUB_AUTH_PROVIDER_NAME).pipe( |
| 127 | Effect.flatMap(({ profileName, resolve }) => |
| 128 | resolve.pipe( |
| 129 | Effect.map((creds) => |
| 130 | make( |
| 131 | creds.token, |
| 132 | fixedBaseUrl !== undefined |
| 133 | ? fixedBaseUrl.baseUrl |
| 134 | : creds.baseUrl, |
| 135 | ), |
| 136 | ), |
| 137 | Effect.mapError( |
| 138 | (e) => |
| 139 | new AuthError({ |
| 140 | message: `Failed to resolve GitHub credentials from ${profileName === undefined ? "the CI environment" : `profile '${profileName}'`}: ${(e as { message?: string }).message ?? String(e)}`, |
| 141 | }), |
| 142 | ), |
| 143 | ), |
| 144 | ), |
| 145 | deferUntilFirstUse, |
| 146 | ); |
| 147 | return yield* resolve.pipe( |
| 148 | orDieCredentialsUnavailable(GITHUB_AUTH_PROVIDER_NAME), |
| 149 | Effect.cached, |
| 150 | ); |
| 151 | }).pipe(Effect.orDie), |
| 152 | ); |