1import { Octokit } from "@octokit/rest";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import { AuthError } from "../Auth/AuthProvider.ts";
7import {
8 deferUntilFirstUse,
9 orDieCredentialsUnavailable,
10 resolveProviderConfig,
11} from "../Auth/Resolve.ts";
12import {
13 GITHUB_AUTH_PROVIDER_NAME,
14 type GitHubAuthConfig,
15 type GitHubResolvedCredentials,
16 readEnvCredentials,
17} from "./AuthProvider.ts";
18import { normalizeGitHubBaseUrl } from "./BaseUrl.ts";
19
20export 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
39export class GitHubCredentials extends Context.Service<
40 GitHubCredentials,
41 Effect.Effect<GitHubCredentialsService>
42>()("GitHub::Credentials") {}
43
44const 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 */
68export 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 */
95export 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 */
113export 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 );