diff --git a/.env.example b/.env.example index d0af2094..c76b75cb 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,18 @@ NODE_ENV=development PUBLIC_ORG= PRIVATE_ORG= +# GitHub Enterprise (GHE.com Data Residency / GHES) configuration. +# Leave unset to use the github.com defaults shown below. For GHE/GHES, set +# each custom URL explicitly. +# GITHUB_SERVER_URL= +# GITHUB_API_URL= +# GITHUB_GRAPHQL_URL= + +# Committer email domain used on sync commits. Defaults to +# `users.noreply.github.com`. Set explicitly for GHE/GHES; the exact value +# depends on the instance configuration. +# GITHUB_USER_EMAIL_DOMAIN= + # Used to skip branch protection creation if organization level branch protections are used instead SKIP_BRANCH_PROTECTION_CREATION= @@ -40,7 +52,7 @@ DELETE_INTERNAL_MERGE_COMMITS_ON_SYNC= # Used to configure the timeout for syncing a mirror before the task gets backgrounded (default is 30 seconds) MIRROR_SYNC_TIMEOUT_MS= -# Used to configure the number of commits to push at a time when syncing a mirror (default is 100) +# Used to configure the number of commits to push at a time when syncing a mirror (default is 1000) MIRROR_PUSH_CHUNK_SIZE= # Used to disable mirror deletion through private mirrors. Hides the delete action in the UI and rejects direct API calls. diff --git a/README.md b/README.md index 466cf216..4e950c86 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,36 @@ PRIVATE_ORG=name-of-your-ghec-org # Where your private mirrors will be creat The authentication of the UI will still need to be a user's github.com user, but the app will be able to create forks and mirrors in the GHEC instance. +## Integrating the App into GHE.com (Data Residency) or GHES + +The app also supports GitHub Enterprise Cloud with Data Residency (`*.ghe.com`) and GitHub Enterprise Server. Configure the server, REST API, and GraphQL API URLs explicitly for your environment. + +Set the following environment variables in addition to the GHEC variables above: + +```sh +# Base URL of your GHE instance (no trailing slash). +# GHE.com Data Residency: https://.ghe.com +# GHES: https://ghes.example.com +GITHUB_SERVER_URL=https://acme.ghe.com + +# REST API and GraphQL URLs for the same GitHub host. +GITHUB_API_URL=https://api.acme.ghe.com +GITHUB_GRAPHQL_URL=https://api.acme.ghe.com/graphql + +# Committer email domain used on sync commits. Defaults to `users.noreply.github.com`. +# Set explicitly for GHE/GHES (value depends on instance configuration), e.g.: +# users.noreply.acme.ghe.com +# users.noreply.ghes.example.com +GITHUB_USER_EMAIL_DOMAIN=users.noreply.acme.ghe.com +``` + +Notes: + +- The OAuth App / GitHub App, organizations, members and forks must all live on the same GHE instance. +- GitHub configuration is read at runtime and safely passed from the server to client-side hooks and UI links. No duplicate `NEXT_PUBLIC_*` variables or Docker build arguments are required. +- If these variables are unset, the app uses `github.com`, `api.github.com`, and `users.noreply.github.com` defaults. +- The local webhook relay (`npm run webhook`) uses `github-app-webhook-relay-polling` against the GitHub App hook deliveries endpoint. It is best-effort on GHE; in production, use real webhook deliveries configured directly on your GitHub App. + ## Usage Once the app is installed, follow this document on [Using the Private Mirrors App](docs/using-the-app.md) to get the repository fork and mirrors set up for work. diff --git a/docs/developing.md b/docs/developing.md index d9e84d96..4304a9ae 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -141,6 +141,12 @@ npm run build This will create an optimized production build of the app in the `out` directory. +### Building for GHE.com / GHES + +GHE.com and GHES settings are runtime environment variables. The server passes the validated GitHub URLs to client-side hooks and UI links, so production builds and Docker images do not require separate `NEXT_PUBLIC_*` variables or build arguments. + +See the [GHE.com / GHES section in the README](../README.md#integrating-the-app-into-ghecom-data-residency-or-ghes) for the full list of environment variables. + ## Deployment To deploy the app, follow the instructions for your preferred hosting provider. The app can be deployed to any hosting provider that supports Next.js/Docker. diff --git a/env.mjs b/env.mjs index ac377e7d..39eb63be 100644 --- a/env.mjs +++ b/env.mjs @@ -1,6 +1,11 @@ import { createEnv } from '@t3-oss/env-nextjs' import { z } from 'zod' +const DEFAULT_GITHUB_SERVER_URL = 'https://github.com' +const DEFAULT_GITHUB_API_URL = 'https://api.github.com' +const DEFAULT_GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql' +const DEFAULT_GITHUB_USER_EMAIL_DOMAIN = 'users.noreply.github.com' + export const env = createEnv({ /* * Serverside Environment variables, not available on the client. @@ -21,6 +26,30 @@ export const env = createEnv({ NODE_ENV: z.string().optional().default('development'), PUBLIC_ORG: z.string().optional(), PRIVATE_ORG: z.string().optional(), + // GitHub Enterprise (GHE.com Data Residency / GHES) configuration. + GITHUB_SERVER_URL: z + .string() + .url() + .optional() + .default(DEFAULT_GITHUB_SERVER_URL) + .transform((value) => value.replace(/\/+$/, '')), + GITHUB_API_URL: z + .string() + .url() + .optional() + .default(DEFAULT_GITHUB_API_URL) + .transform((value) => value.replace(/\/+$/, '')), + GITHUB_GRAPHQL_URL: z + .string() + .url() + .optional() + .default(DEFAULT_GITHUB_GRAPHQL_URL) + .transform((value) => value.replace(/\/+$/, '')), + GITHUB_USER_EMAIL_DOMAIN: z + .string() + .min(1) + .optional() + .default(DEFAULT_GITHUB_USER_EMAIL_DOMAIN), // Custom validation for a comma separated list of strings // ex: ajhenry,github,ahpook ALLOWED_HANDLES: z @@ -122,6 +151,13 @@ export const env = createEnv({ NODE_ENV: process.env.NODE_ENV, PUBLIC_ORG: process.env.PUBLIC_ORG, PRIVATE_ORG: process.env.PRIVATE_ORG, + GITHUB_SERVER_URL: + process.env.GITHUB_SERVER_URL ?? DEFAULT_GITHUB_SERVER_URL, + GITHUB_API_URL: process.env.GITHUB_API_URL ?? DEFAULT_GITHUB_API_URL, + GITHUB_GRAPHQL_URL: + process.env.GITHUB_GRAPHQL_URL ?? DEFAULT_GITHUB_GRAPHQL_URL, + GITHUB_USER_EMAIL_DOMAIN: + process.env.GITHUB_USER_EMAIL_DOMAIN ?? DEFAULT_GITHUB_USER_EMAIL_DOMAIN, ALLOWED_HANDLES: process.env.ALLOWED_HANDLES, ALLOWED_ORGS: process.env.ALLOWED_ORGS, SKIP_BRANCH_PROTECTION_CREATION: diff --git a/scripts/webhook-relay.mjs b/scripts/webhook-relay.mjs index 0ac9b7da..496968b2 100644 --- a/scripts/webhook-relay.mjs +++ b/scripts/webhook-relay.mjs @@ -1,26 +1,34 @@ import { sign } from '@octokit/webhooks-methods' import WebhookRelay from 'github-app-webhook-relay-polling' import crypto from 'node:crypto' -import { App } from 'octokit' +import { App, Octokit } from 'octokit' import './proxy.mjs' +import { env } from '../env.mjs' -if (!process.env.PUBLIC_ORG) { +if (!env.PUBLIC_ORG) { console.error( 'Missing PUBLIC_ORG environment variable. This is required for the webhook relay to work locally.', ) process.exit(1) } -const url = `${process.env.NEXTAUTH_URL}/api/webhooks` +const url = `${env.NEXTAUTH_URL}/api/webhooks` +const apiBaseUrl = env.GITHUB_API_URL -const privateKey = - process.env.PRIVATE_KEY && - !process.env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----') - ? // Support optional base64 decoding of the private key to prevent issues with complicated environment variable passing scenarios - Buffer.from(process.env.PRIVATE_KEY, 'base64').toString('utf8') - : // Handle a bug with multiline envs in docker - See https://github.com/moby/moby/issues/46773 - (process.env.PRIVATE_KEY?.replace(/\\n/g, '\n') ?? '') +if (apiBaseUrl !== 'https://api.github.com') { + console.warn( + `[webhook-relay] Using API base URL: ${apiBaseUrl}. The polling webhook relay relies on the GitHub App hook deliveries endpoint and may not work against all GHE deployments.`, + ) +} + +const RelayOctokit = Octokit.defaults({ baseUrl: apiBaseUrl }) + +const privateKey = !env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----') + ? // Support optional base64 decoding of the private key to prevent issues with complicated environment variable passing scenarios + Buffer.from(env.PRIVATE_KEY, 'base64').toString('utf8') + : // Handle a bug with multiline envs in docker - See https://github.com/moby/moby/issues/46773 + env.PRIVATE_KEY.replace(/\\n/g, '\n') const privateKeyPkcs8 = crypto.createPrivateKey(privateKey).export({ type: 'pkcs8', @@ -29,12 +37,13 @@ const privateKeyPkcs8 = crypto.createPrivateKey(privateKey).export({ const setupForwarder = (organizationOwner) => { const app = new App({ - appId: process.env.APP_ID, + appId: env.APP_ID, privateKey: privateKeyPkcs8, webhooks: { // value does not matter, but has to be set. secret: 'secret', }, + Octokit: RelayOctokit, }) const relay = new WebhookRelay({ @@ -65,10 +74,7 @@ const setupForwarder = (organizationOwner) => { const headers = {} - headers['x-hub-signature-256'] = await sign( - process.env.WEBHOOK_SECRET, - parsedEvent, - ) + headers['x-hub-signature-256'] = await sign(env.WEBHOOK_SECRET, parsedEvent) headers['x-github-event'] = eventNameWithAction headers['x-github-delivery'] = event.id headers['content-type'] = 'application/json' @@ -91,12 +97,9 @@ const setupForwarder = (organizationOwner) => { relay.start() } -setupForwarder(process.env.PUBLIC_ORG) +setupForwarder(env.PUBLIC_ORG) -if ( - process.env.PRIVATE_ORG && - process.env.PUBLIC_ORG !== process.env.PRIVATE_ORG -) { +if (env.PRIVATE_ORG && env.PUBLIC_ORG !== env.PRIVATE_ORG) { console.log('Setting up private organization webhook relay') - setupForwarder(process.env.PRIVATE_ORG) + setupForwarder(env.PRIVATE_ORG) } diff --git a/src/app/[organizationId]/page.tsx b/src/app/[organizationId]/page.tsx index 58306d92..c0169603 100644 --- a/src/app/[organizationId]/page.tsx +++ b/src/app/[organizationId]/page.tsx @@ -24,9 +24,11 @@ import Fuse from 'fuse.js' import { OrgHeader } from 'app/components/header/OrgHeader' import { OrgBreadcrumbs } from 'app/components/breadcrumbs/OrgBreadcrumbs' import { ErrorFlash } from 'app/components/flash/ErrorFlash' +import { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider' const Organization = () => { const { organizationId } = useParams() + const { serverUrl } = useGitHubEnvironment() const { data, isLoading } = trpc.checkInstallation.useQuery({ orgId: organizationId as string, }) @@ -207,7 +209,7 @@ const Organization = () => { Forked from{' '} { export const verifySession = async (token: string | undefined) => { if (!token) return false - const octokit = personalOctokit(token) + const octokit = personalOctokit(token, githubEndpointConfig) try { await octokit.rest.users.getAuthenticated() return true @@ -57,8 +62,7 @@ export const refreshAccessToken = async ( grant_type: 'refresh_token', }) - const url = - 'https://github.com/login/oauth/access_token?' + params.toString() + const url = `${env.GITHUB_SERVER_URL}/login/oauth/access_token?${params.toString()}` const response = await fetch(url, { headers: { @@ -97,23 +101,70 @@ export const refreshAccessToken = async ( } } +const apiBaseUrl = env.GITHUB_API_URL + +export const createGitHubUserinfoRequest = + (apiBaseUrl: string) => + async ({ + client, + tokens, + }: { + client: { userinfo: (accessToken: string) => Promise } + tokens: { access_token?: string | null } + }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const profile = (await client.userinfo(tokens.access_token!)) as any + + if (!profile.email) { + try { + const res = await fetch(`${apiBaseUrl}/user/emails`, { + headers: { + Authorization: `token ${tokens.access_token}`, + 'User-Agent': 'private-mirrors-app', + }, + }) + + if (res.ok) { + const emails: Array<{ + email: string + primary: boolean + verified: boolean + }> = await res.json() + profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email + } + } catch (error) { + authLogger.warn('Failed to fetch user emails', { error }) + } + } + + return profile + } + export const nextAuthOptions: AuthOptions = { pages: { signIn: '/auth/login', error: '/auth/error', }, - debug: process.env.NODE_ENV === 'development', + debug: env.NODE_ENV === 'development', providers: [ GitHub({ - clientId: process.env.GITHUB_CLIENT_ID!, - clientSecret: process.env.GITHUB_CLIENT_SECRET!, - issuer: 'https://github.com/login/oauth', + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + issuer: `${env.GITHUB_SERVER_URL}/login/oauth`, authorization: { + url: `${env.GITHUB_SERVER_URL}/login/oauth/authorize`, params: { scope: 'repo, user, read:org' }, }, + token: `${env.GITHUB_SERVER_URL}/login/oauth/access_token`, + userinfo: { + url: `${apiBaseUrl}/user`, + // The built-in GitHub provider hardcodes `https://api.github.com/user/emails` + // for the email fallback. Override the request so we use the configured API host. + request: createGitHubUserinfoRequest(apiBaseUrl), + }, }), ], - secret: process.env.NEXTAUTH_SECRET!, + secret: env.NEXTAUTH_SECRET, logger: { error(code, metadata) { if (!(metadata instanceof Error) && metadata.provider) { @@ -143,12 +194,12 @@ export const nextAuthOptions: AuthOptions = { } // Get the allowed handles list - const allowedHandles = ( - process.env.ALLOWED_HANDLES?.split(',') ?? [] - ).filter((handle) => handle !== '') + const allowedHandles = env.ALLOWED_HANDLES.split(',').filter( + (handle) => handle !== '', + ) // Get the allowed orgs list - const allowedOrgs = (process.env.ALLOWED_ORGS?.split(',') ?? []).filter( + const allowedOrgs = env.ALLOWED_ORGS.split(',').filter( (org) => org !== '', ) @@ -181,7 +232,10 @@ export const nextAuthOptions: AuthOptions = { "Checking if any of user's orgs are in allowed orgs list", ) - const octokit = personalOctokit(params.account?.access_token as string) + const octokit = personalOctokit( + params.account?.access_token as string, + githubEndpointConfig, + ) // Get the user's organizations const orgs = await octokit @@ -258,8 +312,8 @@ export const nextAuthOptions: AuthOptions = { // Refresh the access token const refreshedToken = await refreshAccessToken( token, - process.env.GITHUB_CLIENT_ID!, - process.env.GITHUB_CLIENT_SECRET!, + env.GITHUB_CLIENT_ID, + env.GITHUB_CLIENT_SECRET, token.refreshToken, ) diff --git a/src/app/components/dialog/CreateMirrorDialog.tsx b/src/app/components/dialog/CreateMirrorDialog.tsx index 0f9d4fd2..a6f42f01 100644 --- a/src/app/components/dialog/CreateMirrorDialog.tsx +++ b/src/app/components/dialog/CreateMirrorDialog.tsx @@ -9,6 +9,7 @@ import { } from '@primer/react' import { Dialog } from '@primer/react/drafts' import { mirrorNameSchema } from 'server/repos/schema' +import { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider' import { useState } from 'react' @@ -31,6 +32,7 @@ export const CreateMirrorDialog = ({ closeDialog, createMirror, }: CreateMirrorDialogProps) => { + const { serverUrl } = useGitHubEnvironment() // set to default value of 'repository-name' for display purposes const [repoName, setRepoName] = useState(DEFAULT_REPO_NAME) @@ -91,7 +93,7 @@ export const CreateMirrorDialog = ({ This is a private mirror of{' '} @@ -135,7 +137,7 @@ export const CreateMirrorDialog = ({ > Forked from{' '} { + const { serverUrl } = useGitHubEnvironment() // set to the current mirror name for display purposes const [newMirrorName, setNewMirrorName] = useState(mirrorName) @@ -105,7 +107,7 @@ export const EditMirrorDialog = ({ This is a private mirror of{' '} @@ -149,7 +151,7 @@ export const EditMirrorDialog = ({ > Forked from{' '} { + const { serverUrl } = useGitHubEnvironment() return ( @@ -24,7 +26,7 @@ export const AppNotInstalledFlash = ({ This organization does not have the required App installed. Visit{' '} this page {' '} diff --git a/src/app/components/header/ForkHeader.tsx b/src/app/components/header/ForkHeader.tsx index 8f748aab..15b32bec 100644 --- a/src/app/components/header/ForkHeader.tsx +++ b/src/app/components/header/ForkHeader.tsx @@ -8,12 +8,14 @@ import { Text, } from '@primer/react' import { ForkData } from 'hooks/useFork' +import { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider' interface ForkHeaderProps { forkData: ForkData } export const ForkHeader = ({ forkData }: ForkHeaderProps) => { + const { serverUrl } = useGitHubEnvironment() return ( {forkData ? ( @@ -49,7 +51,7 @@ export const ForkHeader = ({ forkData }: ForkHeaderProps) => { Forked from{' '} ( + undefined, +) + +export const GitHubEnvironmentProvider = ({ + children, + value, +}: { + children: ReactNode + value: GitHubEnvironment +}) => { + return ( + + {children} + + ) +} + +export const useGitHubEnvironment = () => { + const value = useContext(GitHubEnvironmentContext) + if (!value) { + throw new Error( + 'useGitHubEnvironment must be used within GitHubEnvironmentProvider', + ) + } + return value +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1aa0b289..6fbb2169 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,8 @@ import { MainHeader } from './components/header/MainHeader' import { AuthProvider } from './context/AuthProvider' import { getServerSession } from 'next-auth' import { nextAuthOptions } from './api/auth/lib/nextauth-options' +import { env } from '../../env.mjs' +import { GitHubEnvironmentProvider } from './context/GitHubEnvironmentProvider' const RootLayout = async ({ children }: { children: React.ReactNode }) => { const session = await getServerSession(nextAuthOptions) @@ -15,31 +17,39 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => { - - - + + + - + + + + + {children} + - - {children} - - - - + + + diff --git a/src/bot/octokit.ts b/src/bot/octokit.ts index a8688752..670fb258 100644 --- a/src/bot/octokit.ts +++ b/src/bot/octokit.ts @@ -1,18 +1,21 @@ import { createAppAuth } from '@octokit/auth-app' +import { request as octokitRequest } from '@octokit/request' import { generatePKCS8Key } from 'utils/pem' import { logger } from '../utils/logger' import { Octokit } from './rest' +import { env } from '../../env.mjs' + +export { personalOctokit } from './rest' -const personalOctokitLogger = logger.getSubLogger({ name: 'personal-octokit' }) const appOctokitLogger = logger.getSubLogger({ name: 'app-octokit' }) const privateKey = - process.env.PRIVATE_KEY && - !process.env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----') + env.PRIVATE_KEY && + !env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----') ? // Support optional base64 decoding of the private key to prevent issues with complicated environment variable passing scenarios - Buffer.from(process.env.PRIVATE_KEY, 'base64').toString('utf8') + Buffer.from(env.PRIVATE_KEY, 'base64').toString('utf8') : // Handle a bug with multiline envs in docker - See https://github.com/moby/moby/issues/46773 - (process.env.PRIVATE_KEY?.replace(/\\n/g, '\n') ?? '') + (env.PRIVATE_KEY?.replace(/\\n/g, '\n') ?? '') /** * Generates an app access token for the app or an installation (if installationId is provided) @@ -21,12 +24,15 @@ const privateKey = */ export const generateAppAccessToken = async (installationId?: string) => { const convertedKey = generatePKCS8Key(privateKey) + // Ensure auth requests target the configured (potentially GHE/GHES) API URL. + const request = octokitRequest.defaults({ baseUrl: env.GITHUB_API_URL }) if (installationId) { const auth = createAppAuth({ - appId: process.env.APP_ID!, + appId: env.APP_ID, privateKey: convertedKey, installationId: installationId, + request, }) const appAuthentication = await auth({ @@ -37,10 +43,11 @@ export const generateAppAccessToken = async (installationId?: string) => { } const auth = createAppAuth({ - appId: process.env.APP_ID!, + appId: env.APP_ID, privateKey, - clientId: process.env.CLIENT_ID!, - clientSecret: process.env.CLIENT_SECRET!, + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + request, }) const appAuthentication = await auth({ @@ -60,12 +67,14 @@ export const appOctokit = () => { return new Octokit({ authStrategy: createAppAuth, auth: { - appId: process.env.APP_ID!, + appId: env.APP_ID, privateKey: convertedKey, - clientId: process.env.CLIENT_ID!, - clientSecret: process.env.CLIENT_SECRET!, + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, }, log: appOctokitLogger, + baseUrl: env.GITHUB_API_URL, + githubGraphQlUrl: env.GITHUB_GRAPHQL_URL, }) } @@ -80,23 +89,13 @@ export const installationOctokit = (installationId: string) => { return new Octokit({ authStrategy: createAppAuth, auth: { - appId: process.env.APP_ID!, + appId: env.APP_ID, privateKey: convertedKey, installationId: installationId, }, log: appOctokitLogger, - }) -} - -/** - * Creates a new octokit instance that is authenticated as the user - * @param token personal access token - * @returns Octokit authorized with the personal access token - */ -export const personalOctokit = (token: string) => { - return new Octokit({ - auth: token, - log: personalOctokitLogger, + baseUrl: env.GITHUB_API_URL, + githubGraphQlUrl: env.GITHUB_GRAPHQL_URL, }) } diff --git a/src/bot/rest.ts b/src/bot/rest.ts index a8224dba..c4d236fb 100644 --- a/src/bot/rest.ts +++ b/src/bot/rest.ts @@ -1,8 +1,57 @@ import { config } from '@probot/octokit-plugin-config' import { Octokit as Core } from 'octokit' +import { logger } from '../utils/logger' -export const Octokit = Core.plugin(config).defaults({ +type GraphQlConfigurableOctokit = { + graphql: { + defaults: (options: { + url: string + }) => GraphQlConfigurableOctokit['graphql'] + } +} + +type GitHubGraphQlEndpointOptions = { + [key: string]: unknown + githubGraphQlUrl?: string +} + +export const githubGraphQlEndpointPlugin = ( + octokit: unknown, + options: GitHubGraphQlEndpointOptions, +) => { + if (!options.githubGraphQlUrl) return {} + + const graphQlCapableOctokit = octokit as GraphQlConfigurableOctokit + graphQlCapableOctokit.graphql = graphQlCapableOctokit.graphql.defaults({ + url: options.githubGraphQlUrl, + }) + return {} +} + +export const Octokit = Core.plugin( + config, + githubGraphQlEndpointPlugin, +).defaults({ userAgent: `octokit-rest.js/repo-sync-bot`, }) export type Octokit = InstanceType + +export type GitHubEndpointConfig = { + apiUrl: string + graphQlUrl: string +} + +const personalOctokitLogger = logger.getSubLogger({ name: 'personal-octokit' }) + +export const personalOctokit = ( + token: string, + endpointConfig: GitHubEndpointConfig, +) => { + return new Octokit({ + auth: token, + log: personalOctokitLogger, + baseUrl: endpointConfig.apiUrl, + githubGraphQlUrl: endpointConfig.graphQlUrl, + }) +} diff --git a/src/hooks/useFork.tsx b/src/hooks/useFork.tsx index 45aee14e..b25ddebd 100644 --- a/src/hooks/useFork.tsx +++ b/src/hooks/useFork.tsx @@ -1,15 +1,23 @@ -import { personalOctokit } from 'bot/octokit' +import { GitHubEndpointConfig, personalOctokit } from 'bot/rest' +import { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider' import { useSession } from 'next-auth/react' import { useParams } from 'next/navigation' import { Octokit } from 'octokit' import { useEffect, useState } from 'react' -const getForkById = async (accessToken: string, repoId: string) => { +const getForkById = async ( + accessToken: string, + repoId: string, + endpointConfig: GitHubEndpointConfig, +) => { try { return ( - await personalOctokit(accessToken).request('GET /repositories/{id}', { - id: repoId, - }) + await personalOctokit(accessToken, endpointConfig).request( + 'GET /repositories/{id}', + { + id: repoId, + }, + ) ).data as Awaited>['data'] } catch (error) { console.error('Error fetching fork', { error }) @@ -20,6 +28,7 @@ const getForkById = async (accessToken: string, repoId: string) => { export const useForkData = () => { const session = useSession() const accessToken = session.data?.user.accessToken + const endpointConfig = useGitHubEnvironment() const { organizationId, forkId } = useParams() @@ -37,7 +46,7 @@ export const useForkData = () => { setIsLoading(true) setError(null) - getForkById(accessToken, forkId as string) + getForkById(accessToken, forkId as string, endpointConfig) .then((fork) => { setFork(fork) }) @@ -47,7 +56,7 @@ export const useForkData = () => { .finally(() => { setIsLoading(false) }) - }, [accessToken, organizationId, forkId]) + }, [accessToken, endpointConfig, organizationId, forkId]) return { data: fork, diff --git a/src/hooks/useForks.tsx b/src/hooks/useForks.tsx index a5b92a53..0c93aff6 100644 --- a/src/hooks/useForks.tsx +++ b/src/hooks/useForks.tsx @@ -1,5 +1,6 @@ import { getReposInOrgGQL } from 'bot/graphql' -import { personalOctokit } from 'bot/octokit' +import { GitHubEndpointConfig, personalOctokit } from 'bot/rest' +import { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider' import { useSession } from 'next-auth/react' import { useEffect, useState } from 'react' import { ForksObject } from 'types/forks' @@ -7,8 +8,12 @@ import { logger } from '../utils/logger' const forksLogger = logger.getSubLogger({ name: 'useForks' }) -const getForksInOrg = async (accessToken: string, login: string) => { - const res = (await personalOctokit(accessToken) +const getForksInOrg = async ( + accessToken: string, + login: string, + endpointConfig: GitHubEndpointConfig, +) => { + const res = (await personalOctokit(accessToken, endpointConfig) .graphql.paginate(getReposInOrgGQL, { login, isFork: true, @@ -64,6 +69,7 @@ const getForksInOrg = async (accessToken: string, login: string) => { export const useForksData = (login: string | undefined) => { const session = useSession() const accessToken = session.data?.user.accessToken + const endpointConfig = useGitHubEnvironment() const [forks, setForks] = useState @@ -79,7 +85,7 @@ export const useForksData = (login: string | undefined) => { setIsLoading(true) setError(null) - getForksInOrg(accessToken, login) + getForksInOrg(accessToken, login, endpointConfig) .then((forks) => { setForks(forks) }) @@ -89,7 +95,7 @@ export const useForksData = (login: string | undefined) => { .finally(() => { setIsLoading(false) }) - }, [login, accessToken]) + }, [login, accessToken, endpointConfig]) return { data: forks, diff --git a/src/hooks/useOrganization.tsx b/src/hooks/useOrganization.tsx index 5cabc08c..4f2d2bb0 100644 --- a/src/hooks/useOrganization.tsx +++ b/src/hooks/useOrganization.tsx @@ -1,4 +1,5 @@ -import { personalOctokit } from 'bot/octokit' +import { GitHubEndpointConfig, personalOctokit } from 'bot/rest' +import { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider' import { useSession } from 'next-auth/react' import { useParams, useRouter } from 'next/navigation' import { useEffect, useState } from 'react' @@ -6,10 +7,14 @@ import { useEffect, useState } from 'react' export const getOrganizationData = async ( accessToken: string, orgId: string, + endpointConfig: GitHubEndpointConfig, ) => { try { - return (await personalOctokit(accessToken).rest.orgs.get({ org: orgId })) - .data + return ( + await personalOctokit(accessToken, endpointConfig).rest.orgs.get({ + org: orgId, + }) + ).data } catch (error) { console.error('Error fetching organization', { error }) return null @@ -23,6 +28,7 @@ export const useOrgData = () => { const session = useSession() const accessToken = session.data?.user.accessToken + const endpointConfig = useGitHubEnvironment() const [orgData, setOrgData] = useState @@ -38,7 +44,7 @@ export const useOrgData = () => { setIsLoading(true) setError(null) - getOrganizationData(accessToken, organizationId as string) + getOrganizationData(accessToken, organizationId as string, endpointConfig) .then((orgData) => { if (!orgData) { router.push('/_error') @@ -52,7 +58,7 @@ export const useOrgData = () => { .finally(() => { setIsLoading(false) }) - }, [organizationId, accessToken, router]) + }, [organizationId, accessToken, endpointConfig, router]) return { data: orgData, diff --git a/src/hooks/useOrganizations.tsx b/src/hooks/useOrganizations.tsx index 0667880e..1f7fdfa1 100644 --- a/src/hooks/useOrganizations.tsx +++ b/src/hooks/useOrganizations.tsx @@ -1,15 +1,20 @@ -import { personalOctokit } from 'bot/octokit' +import { GitHubEndpointConfig, personalOctokit } from 'bot/rest' +import { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider' import { useSession } from 'next-auth/react' import { useEffect, useState } from 'react' -const getOrganizationsData = async (accessToken: string) => { - const octokit = personalOctokit(accessToken) +const getOrganizationsData = async ( + accessToken: string, + endpointConfig: GitHubEndpointConfig, +) => { + const octokit = personalOctokit(accessToken, endpointConfig) return await octokit.rest.orgs.listForAuthenticatedUser() } export const useOrgsData = () => { const session = useSession() const accessToken = session.data?.user.accessToken + const endpointConfig = useGitHubEnvironment() const [organizationData, setOrganizationData] = useState( null, @@ -25,7 +30,7 @@ export const useOrgsData = () => { setIsLoading(true) setError(null) - getOrganizationsData(accessToken) + getOrganizationsData(accessToken, endpointConfig) .then((orgs) => { setOrganizationData(orgs.data) }) @@ -35,7 +40,7 @@ export const useOrgsData = () => { .finally(() => { setIsLoading(false) }) - }, [accessToken]) + }, [accessToken, endpointConfig]) return { data: organizationData, diff --git a/src/pages/api/webhooks.ts b/src/pages/api/webhooks.ts index fd12245d..68248943 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -1,7 +1,14 @@ import app from 'bot' -import { createNodeMiddleware, createProbot } from 'probot' +import { githubGraphQlEndpointPlugin } from 'bot/rest' +import { createNodeMiddleware, createProbot, ProbotOctokit } from 'probot' +import { env } from '../../../env.mjs' -export const probot = createProbot() +const GheProbotOctokit = ProbotOctokit.plugin( + githubGraphQlEndpointPlugin, +).defaults({ + baseUrl: env.GITHUB_API_URL, + githubGraphQlUrl: env.GITHUB_GRAPHQL_URL, +}) export const config = { api: { @@ -12,6 +19,10 @@ export const config = { // Probot v14 requires a pino logger so custom logging has been removed // In the future it is worth considering replacing tslog with pino entirely export default await createNodeMiddleware(app, { - probot: createProbot(), + probot: createProbot({ + defaults: { + Octokit: GheProbotOctokit, + }, + }), webhooksPath: '/api/webhooks', }) diff --git a/src/server/git/controller.ts b/src/server/git/controller.ts index 33d61968..0d7f247f 100644 --- a/src/server/git/controller.ts +++ b/src/server/git/controller.ts @@ -4,6 +4,7 @@ import { temporaryDirectory } from 'tempy' import { logger } from '../../utils/logger' import { cleanupTempDir } from '../../utils/temp-dir' import { SyncReposSchema } from './schema' +import { env } from '../../../env.mjs' const gitApiLogger = logger.getSubLogger({ name: 'git-api' }) @@ -60,7 +61,7 @@ export const syncReposHandler = async ({ const options: Partial = { config: [ `user.name=pma[bot]`, - `user.email=${input.source.octokit.installationId}+pma[bot]@users.noreply.github.com`, + `user.email=${input.source.octokit.installationId}+pma[bot]@${env.GITHUB_USER_EMAIL_DOMAIN}`, ], } diff --git a/src/server/repos/controller.ts b/src/server/repos/controller.ts index e98f7b5f..46de6d2c 100644 --- a/src/server/repos/controller.ts +++ b/src/server/repos/controller.ts @@ -19,6 +19,7 @@ import { ListMirrorsSchema, } from './schema' import { TRPCError } from '@trpc/server' +import { env } from '../../../env.mjs' const reposApiLogger = logger.getSubLogger({ name: 'repos-api' }) @@ -233,7 +234,7 @@ export const createMirrorHandler = async ({ config: [ `user.name=pma[bot]`, // We want to use the private installation ID as the email so that we can push to the private repo - `user.email=${privateInstallationId}+pma[bot]@users.noreply.github.com`, + `user.email=${privateInstallationId}+pma[bot]@${env.GITHUB_USER_EMAIL_DOMAIN}`, ], } const git = simpleGit(tempDir, options) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 5f3d08bb..8a27a807 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -2,6 +2,12 @@ import { TRPCError } from '@trpc/server' import { getConfig } from '../bot/config' import { personalOctokit } from '../bot/octokit' import { logger } from '../utils/logger' +import { env } from '../../env.mjs' + +const githubEndpointConfig = { + apiUrl: env.GITHUB_API_URL, + graphQlUrl: env.GITHUB_GRAPHQL_URL, +} /** * Generates a git url with the access token in it @@ -17,8 +23,9 @@ export const generateAuthUrl = ( ) => { const USER = 'x-access-token' const PASS = accessToken - const REPO = `github.com/${owner}/${repo}` - return `https://${USER}:${PASS}@${REPO}` + const serverUrl = new URL(env.GITHUB_SERVER_URL) + const REPO = `${serverUrl.host}/${owner}/${repo}` + return `${serverUrl.protocol}//${USER}:${PASS}@${REPO}` } const middlewareLogger = logger.getSubLogger({ name: 'middleware' }) @@ -41,7 +48,7 @@ export const checkGitHubAppInstallationAuth = async ( throw new TRPCError({ code: 'UNAUTHORIZED' }) } - const octokit = personalOctokit(accessToken) + const octokit = personalOctokit(accessToken, githubEndpointConfig) const data = await octokit.rest.repos .get({ @@ -74,7 +81,7 @@ export const checkGitHubAuth = async ( throw new TRPCError({ code: 'UNAUTHORIZED' }) } - const octokit = personalOctokit(accessToken) + const octokit = personalOctokit(accessToken, githubEndpointConfig) try { // Check validity of token diff --git a/test/app/api/auth/nextauth-options.test.ts b/test/app/api/auth/nextauth-options.test.ts new file mode 100644 index 00000000..cf216dd0 --- /dev/null +++ b/test/app/api/auth/nextauth-options.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('nextAuthOptions GitHub Enterprise wiring', () => { + afterEach(() => { + delete process.env.GITHUB_CLIENT_ID + delete process.env.GITHUB_CLIENT_SECRET + delete process.env.NEXTAUTH_SECRET + vi.resetModules() + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('fetches user emails from the configured API host', async () => { + vi.resetModules() + process.env.GITHUB_CLIENT_ID = 'client-id' + process.env.GITHUB_CLIENT_SECRET = 'client-secret' + process.env.NEXTAUTH_SECRET = 'secret' + + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { email: 'primary@example.com', primary: true, verified: true }, + ], + }) + vi.stubGlobal('fetch', fetchSpy) + + const { createGitHubUserinfoRequest } = await import( + '../../../../src/app/api/auth/lib/nextauth-options' + ) + const request = createGitHubUserinfoRequest( + 'https://ghes.example.com/api/v3', + ) + + const profile = await request({ + client: { + userinfo: vi.fn().mockResolvedValue({ email: null }), + }, + tokens: { access_token: 'user-token' }, + }) + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://ghes.example.com/api/v3/user/emails', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'token user-token', + }), + }), + ) + expect(profile.email).toBe('primary@example.com') + }) +}) diff --git a/test/bot/octokit.test.ts b/test/bot/octokit.test.ts new file mode 100644 index 00000000..0113cc21 --- /dev/null +++ b/test/bot/octokit.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('Octokit GitHub Enterprise configuration', () => { + afterEach(() => { + vi.resetModules() + vi.unstubAllEnvs() + vi.clearAllMocks() + vi.doUnmock('bot') + vi.doUnmock('probot') + }) + + it('configures REST and GraphQL endpoints for GHES', async () => { + const { Octokit } = await import('../../src/bot/rest') + const octokit = new Octokit({ + auth: 'token', + baseUrl: 'https://ghes.example.com/api/v3', + githubGraphQlUrl: 'https://ghes.example.com/api/graphql', + }) + const graphqlEndpoint = ( + octokit.graphql.endpoint as unknown as (options: { query: string }) => { + url: string + } + )({ query: '{ viewer { login } }' }) + + expect(octokit.request.endpoint.DEFAULTS.baseUrl).toBe( + 'https://ghes.example.com/api/v3', + ) + expect(graphqlEndpoint.url).toBe('https://ghes.example.com/api/graphql') + }) + + it('configures client-side personal Octokit from exposed endpoints', async () => { + const { personalOctokit } = await import('../../src/bot/rest') + const octokit = personalOctokit('token', { + apiUrl: 'https://api.acme.ghe.com', + graphQlUrl: 'https://api.acme.ghe.com/graphql', + }) + const graphqlEndpoint = ( + octokit.graphql.endpoint as unknown as (options: { query: string }) => { + url: string + } + )({ query: '{ viewer { login } }' }) + + expect(octokit.request.endpoint.DEFAULTS.baseUrl).toBe( + 'https://api.acme.ghe.com', + ) + expect(graphqlEndpoint.url).toBe('https://api.acme.ghe.com/graphql') + }) + + it('uses the configured REST API base URL for app auth requests', async () => { + vi.stubEnv('GITHUB_API_URL', 'https://ghes.example.com/api/v3') + vi.resetModules() + + const defaultsSpy = vi.fn().mockReturnValue('request-client') + const authSpy = vi + .fn() + .mockReturnValue(vi.fn().mockResolvedValue({ token: 'generated-token' })) + + vi.doMock('@octokit/request', () => ({ + request: { + defaults: defaultsSpy, + }, + })) + vi.doMock('@octokit/auth-app', () => ({ + createAppAuth: authSpy, + })) + vi.doMock('utils/pem', () => ({ + generatePKCS8Key: vi.fn().mockReturnValue('converted-private-key'), + })) + + const { generateAppAccessToken } = await import('../../src/bot/octokit') + + await expect(generateAppAccessToken()).resolves.toBe('generated-token') + expect(defaultsSpy).toHaveBeenCalledWith({ + baseUrl: 'https://ghes.example.com/api/v3', + }) + }) + + it('configures webhook Probot Octokit endpoints for GHES', async () => { + vi.stubEnv('GITHUB_API_URL', 'https://ghes.example.com/api/v3') + vi.stubEnv('GITHUB_GRAPHQL_URL', 'https://ghes.example.com/api/graphql') + vi.resetModules() + + const createProbot = vi.fn((options) => options) + const createNodeMiddleware = vi.fn() + vi.doMock('bot', () => ({ + default: vi.fn(), + })) + vi.doMock('probot', async () => { + const actual = await vi.importActual('probot') + return { + ...actual, + createNodeMiddleware, + createProbot, + } + }) + + await import('../../src/pages/api/webhooks') + + expect(createProbot).toHaveBeenCalledTimes(1) + const Octokit = createProbot.mock.calls[0][0].defaults.Octokit + const octokit = new Octokit({ auth: 'token' }) + const graphqlEndpoint = ( + octokit.graphql.endpoint as unknown as (options: { query: string }) => { + url: string + } + )({ query: '{ viewer { login } }' }) + + expect(octokit.request.endpoint.DEFAULTS.baseUrl).toBe( + 'https://ghes.example.com/api/v3', + ) + expect(graphqlEndpoint.url).toBe('https://ghes.example.com/api/graphql') + }) +}) diff --git a/test/docs/docker-build-config.test.ts b/test/docs/docker-build-config.test.ts new file mode 100644 index 00000000..c02a3d83 --- /dev/null +++ b/test/docs/docker-build-config.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const repoRoot = join(import.meta.dirname, '..', '..') + +describe('Dockerfile and docs for GHE runtime configuration', () => { + it('does not duplicate GitHub configuration as client build args', () => { + const dockerfile = readFileSync(join(repoRoot, 'Dockerfile'), 'utf8') + + expect(dockerfile).not.toContain('NEXT_PUBLIC_GITHUB') + }) + + it('documents runtime configuration for client-side consumers', () => { + const readme = readFileSync(join(repoRoot, 'README.md'), 'utf8') + const developing = readFileSync( + join(repoRoot, 'docs/developing.md'), + 'utf8', + ) + + expect(readme).toContain('GitHub configuration is read at runtime') + expect(readme).not.toContain('NEXT_PUBLIC_GITHUB_SERVER_URL') + expect(developing).toContain( + 'production builds and Docker images do not require separate `NEXT_PUBLIC_*` variables', + ) + }) +}) diff --git a/test/env.test.ts b/test/env.test.ts new file mode 100644 index 00000000..d4340b2c --- /dev/null +++ b/test/env.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const GITHUB_ENV_KEYS = [ + 'GITHUB_SERVER_URL', + 'GITHUB_API_URL', + 'GITHUB_GRAPHQL_URL', + 'GITHUB_USER_EMAIL_DOMAIN', +] as const + +describe('GitHub environment configuration', () => { + afterEach(() => { + vi.resetModules() + vi.unstubAllEnvs() + }) + + it('provides github.com defaults', async () => { + for (const key of GITHUB_ENV_KEYS) { + delete process.env[key] + } + vi.resetModules() + + const { env } = await import('../env.mjs') + + expect(env.GITHUB_SERVER_URL).toBe('https://github.com') + expect(env.GITHUB_API_URL).toBe('https://api.github.com') + expect(env.GITHUB_GRAPHQL_URL).toBe('https://api.github.com/graphql') + expect(env.GITHUB_USER_EMAIL_DOMAIN).toBe('users.noreply.github.com') + }) + + it('provides GitHub defaults when validation is skipped during builds', async () => { + for (const key of GITHUB_ENV_KEYS) { + delete process.env[key] + } + vi.stubEnv('SKIP_ENV_VALIDATIONS', 'true') + vi.resetModules() + + const { env } = await import('../env.mjs') + + expect(env.GITHUB_SERVER_URL).toBe('https://github.com') + expect(env.GITHUB_API_URL).toBe('https://api.github.com') + expect(env.GITHUB_GRAPHQL_URL).toBe('https://api.github.com/graphql') + expect(env.GITHUB_USER_EMAIL_DOMAIN).toBe('users.noreply.github.com') + }) + + it('validates and normalizes explicit endpoints', async () => { + vi.stubEnv('GITHUB_SERVER_URL', 'https://ghes.example.com/') + vi.stubEnv('GITHUB_API_URL', 'https://ghes.example.com/api/v3/') + vi.stubEnv('GITHUB_GRAPHQL_URL', 'https://ghes.example.com/api/graphql/') + vi.stubEnv('GITHUB_USER_EMAIL_DOMAIN', 'users.noreply.ghes.example.com') + vi.resetModules() + + const { env } = await import('../env.mjs') + + expect(env.GITHUB_SERVER_URL).toBe('https://ghes.example.com') + expect(env.GITHUB_API_URL).toBe('https://ghes.example.com/api/v3') + expect(env.GITHUB_GRAPHQL_URL).toBe('https://ghes.example.com/api/graphql') + expect(env.GITHUB_USER_EMAIL_DOMAIN).toBe('users.noreply.ghes.example.com') + }) +}) diff --git a/test/setup-env.ts b/test/setup-env.ts new file mode 100644 index 00000000..04018e68 --- /dev/null +++ b/test/setup-env.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs' +import { beforeEach } from 'vitest' + +const requiredEnvironment = { + APP_ID: '12345', + GITHUB_CLIENT_ID: 'test-client-id', + GITHUB_CLIENT_SECRET: 'test-client-secret', + NEXTAUTH_SECRET: 'test-nextauth-secret', + NEXTAUTH_URL: 'http://localhost:3000', + WEBHOOK_SECRET: 'test-webhook-secret', + PRIVATE_KEY: readFileSync( + new URL('./fixtures/mock-cert.pem', import.meta.url), + 'utf8', + ), +} + +const applyRequiredEnvironment = () => { + for (const [key, value] of Object.entries(requiredEnvironment)) { + process.env[key] ??= value + } +} + +applyRequiredEnvironment() +beforeEach(applyRequiredEnvironment) diff --git a/test/utils/auth.test.ts b/test/utils/auth.test.ts new file mode 100644 index 00000000..295b118b --- /dev/null +++ b/test/utils/auth.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('generateAuthUrl', () => { + afterEach(() => { + vi.resetModules() + vi.unstubAllEnvs() + }) + + it('uses the configured server scheme and host', async () => { + vi.stubEnv('GITHUB_SERVER_URL', 'http://ghes.example.com:8080') + vi.resetModules() + const { generateAuthUrl } = await import('../../src/utils/auth') + + const authUrl = new URL(generateAuthUrl('token', 'owner', 'repo')) + + expect(authUrl.protocol).toBe('http:') + expect(authUrl.host).toBe('ghes.example.com:8080') + expect(authUrl.username).toBe('x-access-token') + expect(authUrl.password).toBe('token') + expect(authUrl.pathname).toBe('/owner/repo') + }) + + it('keeps the github.com default unchanged', async () => { + delete process.env.GITHUB_SERVER_URL + vi.resetModules() + const { generateAuthUrl } = await import('../../src/utils/auth') + + const authUrl = new URL(generateAuthUrl('token', 'owner', 'repo')) + + expect(authUrl.protocol).toBe('https:') + expect(authUrl.host).toBe('github.com') + expect(authUrl.pathname).toBe('/owner/repo') + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index ea160733..bfcad009 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'node', + setupFiles: ['test/setup-env.ts'], include: [ 'test/**/*.{test,spec}.{ts,tsx}', 'src/**/*.{test,spec}.{ts,tsx}',