diff --git a/.env.example b/.env.example index a0807a0f..0a137671 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,24 @@ NODE_ENV=development PUBLIC_ORG= PRIVATE_ORG= +# GitHub Enterprise (GHE.com Data Residency / GHES) configuration. +# Leave unset for github.com. For GHE/GHES, set each custom URL explicitly. +# NEXT_PUBLIC_* variants must also be set at build time (Docker build args) so +# client bundles and UI links target the correct host. +# GITHUB_SERVER_URL= +# GITHUB_API_URL= +# GITHUB_GRAPHQL_URL= +# NEXT_PUBLIC_GITHUB_SERVER_URL= +# NEXT_PUBLIC_GITHUB_API_URL= +# NEXT_PUBLIC_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 instance configuration). If you leave it unset on a non-github.com +# deployment, the app logs a warning and still falls back to the github.com +# noreply domain for compatibility. +GITHUB_USER_EMAIL_DOMAIN= + # Used to skip branch protection creation if organization level branch protections are used instead SKIP_BRANCH_PROTECTION_CREATION= diff --git a/.gitignore b/.gitignore index 4560070e..e505b749 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ build !.env.example .DS_Store next-env.d.ts +tsconfig.tsbuildinfo diff --git a/Dockerfile b/Dockerfile index 9c0163d4..f6abafde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,14 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +ARG NEXT_PUBLIC_GITHUB_SERVER_URL +ARG NEXT_PUBLIC_GITHUB_API_URL +ARG NEXT_PUBLIC_GITHUB_GRAPHQL_URL + ENV NEXT_TELEMETRY_DISABLED=1 +ENV NEXT_PUBLIC_GITHUB_SERVER_URL=${NEXT_PUBLIC_GITHUB_SERVER_URL:-https://github.com} +ENV NEXT_PUBLIC_GITHUB_API_URL=${NEXT_PUBLIC_GITHUB_API_URL:-https://api.github.com} +ENV NEXT_PUBLIC_GITHUB_GRAPHQL_URL=${NEXT_PUBLIC_GITHUB_GRAPHQL_URL:-https://api.github.com/graphql} RUN npm run build RUN npm prune --omit=dev diff --git a/README.md b/README.md index 466cf216..db61a6fe 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,44 @@ 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, GraphQL API, and client bundle 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 + +# Same value as GITHUB_SERVER_URL, but inlined into client bundles at build time. +# Required for client-side hooks and UI links to point at the correct host. +NEXT_PUBLIC_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 + +# Same values as above, but inlined into client bundles at build time. +NEXT_PUBLIC_GITHUB_API_URL=https://api.acme.ghe.com +NEXT_PUBLIC_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. +- The `NEXT_PUBLIC_*` variables are inlined into the client bundle at build time. When building the Docker image, pass them as build args (e.g. `--build-arg NEXT_PUBLIC_GITHUB_SERVER_URL=https://acme.ghe.com`). The bundled `Dockerfile` already forwards them into the `npm run build` step. +- If you leave `GITHUB_USER_EMAIL_DOMAIN` unset on a non-github.com deployment, the app still falls back to `users.noreply.github.com` for compatibility, but it now logs a warning so you can correct the configuration. +- 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..b223c505 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -141,6 +141,19 @@ npm run build This will create an optimized production build of the app in the `out` directory. +### Building for GHE.com / GHES + +The `NEXT_PUBLIC_GITHUB_SERVER_URL`, `NEXT_PUBLIC_GITHUB_API_URL`, and `NEXT_PUBLIC_GITHUB_GRAPHQL_URL` env vars are inlined into the client bundle at build time. When targeting a GHE.com Data Residency tenant or a GHES instance, you must set them before running `npm run build` (or pass them as Docker build args). The bundled `Dockerfile` already forwards these build args into `npm run build`. For example: + +```sh +NEXT_PUBLIC_GITHUB_SERVER_URL=https://acme.ghe.com \ + NEXT_PUBLIC_GITHUB_API_URL=https://api.acme.ghe.com \ + NEXT_PUBLIC_GITHUB_GRAPHQL_URL=https://api.acme.ghe.com/graphql \ + npm run build +``` + +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 8b58dbc7..115bf7c1 100644 --- a/env.mjs +++ b/env.mjs @@ -21,6 +21,17 @@ 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. + // When unset, defaults target github.com / api.github.com so existing + // deployments are unaffected. Set each custom URL explicitly for your GHE + // deployment. + GITHUB_SERVER_URL: z.string().url().optional(), + GITHUB_API_URL: z.string().url().optional(), + GITHUB_GRAPHQL_URL: z.string().url().optional(), + // Optional override for the committer email domain used on sync commits. + // Defaults to `users.noreply.github.com` for github.com; for GHE/GHES set + // this explicitly (the value depends on instance configuration). + GITHUB_USER_EMAIL_DOMAIN: z.string().optional(), // Custom validation for a comma separated list of strings // ex: ajhenry,github,ahpook ALLOWED_HANDLES: z @@ -98,7 +109,14 @@ export const env = createEnv({ * * 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_. */ - client: {}, + client: { + // Mirrors of GitHub URL configuration that are also available in client + // bundles. Used by client-side hooks (Octokit) and UI link builders. These + // are inlined at build time, so they must be set during `npm run build`. + NEXT_PUBLIC_GITHUB_SERVER_URL: z.string().url().optional(), + NEXT_PUBLIC_GITHUB_API_URL: z.string().url().optional(), + NEXT_PUBLIC_GITHUB_GRAPHQL_URL: z.string().url().optional(), + }, /* * Due to how Next.js bundles environment variables on Edge and Client, * we need to manually destructure them to make sure all are included in bundle. @@ -117,6 +135,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, + GITHUB_API_URL: process.env.GITHUB_API_URL, + GITHUB_GRAPHQL_URL: process.env.GITHUB_GRAPHQL_URL, + GITHUB_USER_EMAIL_DOMAIN: process.env.GITHUB_USER_EMAIL_DOMAIN, + NEXT_PUBLIC_GITHUB_SERVER_URL: process.env.NEXT_PUBLIC_GITHUB_SERVER_URL, + NEXT_PUBLIC_GITHUB_API_URL: process.env.NEXT_PUBLIC_GITHUB_API_URL, + NEXT_PUBLIC_GITHUB_GRAPHQL_URL: process.env.NEXT_PUBLIC_GITHUB_GRAPHQL_URL, 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..0ec6a142 100644 --- a/scripts/webhook-relay.mjs +++ b/scripts/webhook-relay.mjs @@ -1,7 +1,7 @@ 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' @@ -14,6 +14,19 @@ if (!process.env.PUBLIC_ORG) { const url = `${process.env.NEXTAUTH_URL}/api/webhooks` +const apiBaseUrl = + process.env.GITHUB_API_URL ?? + process.env.NEXT_PUBLIC_GITHUB_API_URL ?? + 'https://api.github.com' + +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 = process.env.PRIVATE_KEY && !process.env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----') @@ -35,6 +48,7 @@ const setupForwarder = (organizationOwner) => { // value does not matter, but has to be set. secret: 'secret', }, + Octokit: RelayOctokit, }) const relay = new WebhookRelay({ diff --git a/src/app/[organizationId]/page.tsx b/src/app/[organizationId]/page.tsx index 9c2361c0..bd806ab0 100644 --- a/src/app/[organizationId]/page.tsx +++ b/src/app/[organizationId]/page.tsx @@ -24,6 +24,7 @@ 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 { getGitHubServerUrl } from 'utils/github-urls' const Organization = () => { const { organizationId } = useParams() @@ -203,7 +204,7 @@ const Organization = () => { Forked from{' '} + 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', @@ -107,10 +151,18 @@ export const nextAuthOptions: AuthOptions = { GitHub({ clientId: process.env.GITHUB_CLIENT_ID!, clientSecret: process.env.GITHUB_CLIENT_SECRET!, - issuer: 'https://github.com/login/oauth', + issuer: getOAuthIssuer(), authorization: { + url: getOAuthAuthorizationUrl(), params: { scope: 'repo, user, read:org' }, }, + token: getOAuthAccessTokenUrl(), + 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!, diff --git a/src/app/components/dialog/CreateMirrorDialog.tsx b/src/app/components/dialog/CreateMirrorDialog.tsx index 0f9d4fd2..467f7403 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 { getGitHubServerUrl } from 'utils/github-urls' import { useState } from 'react' @@ -91,7 +92,7 @@ export const CreateMirrorDialog = ({ This is a private mirror of{' '} @@ -135,7 +136,7 @@ export const CreateMirrorDialog = ({ > Forked from{' '} This is a private mirror of{' '} @@ -149,7 +150,7 @@ export const EditMirrorDialog = ({ > Forked from{' '} 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..bcfe2ca0 100644 --- a/src/app/components/header/ForkHeader.tsx +++ b/src/app/components/header/ForkHeader.tsx @@ -8,6 +8,7 @@ import { Text, } from '@primer/react' import { ForkData } from 'hooks/useFork' +import { getGitHubServerUrl } from 'utils/github-urls' interface ForkHeaderProps { forkData: ForkData @@ -49,7 +50,7 @@ export const ForkHeader = ({ forkData }: ForkHeaderProps) => { Forked from{' '} { const convertedKey = generatePKCS8Key(privateKey) + // Ensure auth requests target the configured (potentially GHE/GHES) API URL. + const request = octokitRequest.defaults({ baseUrl: getGitHubApiUrl() }) if (installationId) { const auth = createAppAuth({ appId: process.env.APP_ID!, privateKey: convertedKey, installationId: installationId, + request, }) const appAuthentication = await auth({ @@ -41,6 +46,7 @@ export const generateAppAccessToken = async (installationId?: string) => { privateKey, clientId: process.env.CLIENT_ID!, clientSecret: process.env.CLIENT_SECRET!, + request, }) const appAuthentication = await auth({ diff --git a/src/bot/rest.ts b/src/bot/rest.ts index a8224dba..9f36b081 100644 --- a/src/bot/rest.ts +++ b/src/bot/rest.ts @@ -1,8 +1,29 @@ import { config } from '@probot/octokit-plugin-config' import { Octokit as Core } from 'octokit' +import { getGitHubApiUrl, getGitHubGraphQlUrl } from '../utils/github-urls' -export const Octokit = Core.plugin(config).defaults({ +type GraphQlConfigurableOctokit = { + graphql: { + defaults: (options: { + url: string + }) => GraphQlConfigurableOctokit['graphql'] + } +} + +export const githubGraphQlEndpointPlugin = (octokit: unknown) => { + const graphQlCapableOctokit = octokit as GraphQlConfigurableOctokit + graphQlCapableOctokit.graphql = graphQlCapableOctokit.graphql.defaults({ + url: getGitHubGraphQlUrl(), + }) + return {} +} + +export const Octokit = Core.plugin( + config, + githubGraphQlEndpointPlugin, +).defaults({ userAgent: `octokit-rest.js/repo-sync-bot`, + baseUrl: getGitHubApiUrl(), }) export type Octokit = InstanceType diff --git a/src/pages/api/webhooks.ts b/src/pages/api/webhooks.ts index db5f3eb2..c2cc65ce 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -1,8 +1,14 @@ import app from 'bot' -import { createNodeMiddleware, createProbot } from 'probot' +import { githubGraphQlEndpointPlugin } from 'bot/rest' +import { createNodeMiddleware, createProbot, ProbotOctokit } from 'probot' +import { getGitHubApiUrl } from 'utils/github-urls' import { logger } from 'utils/logger' -export const probot = createProbot() +const GheProbotOctokit = ProbotOctokit.plugin( + githubGraphQlEndpointPlugin, +).defaults({ + baseUrl: getGitHubApiUrl(), +}) const probotLogger = logger.getSubLogger({ name: 'probot' }) @@ -15,6 +21,7 @@ export const config = { export default createNodeMiddleware(app, { probot: createProbot({ defaults: { + Octokit: GheProbotOctokit, log: { child: () => probotLogger, // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/server/git/controller.ts b/src/server/git/controller.ts index 59be808d..6ff1e1d4 100644 --- a/src/server/git/controller.ts +++ b/src/server/git/controller.ts @@ -2,6 +2,7 @@ import simpleGit, { SimpleGitOptions } from 'simple-git' import { generateAuthUrl } from '../../utils/auth' import { temporaryDirectory } from 'tempy' import { logger } from '../../utils/logger' +import { getCommitterEmailDomainWithWarning } from '../../utils/server/committer-email' import { SyncReposSchema } from './schema' const gitApiLogger = logger.getSubLogger({ name: 'git-api' }) @@ -58,7 +59,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]@${getCommitterEmailDomainWithWarning()}`, ], } diff --git a/src/server/repos/controller.ts b/src/server/repos/controller.ts index aea8bb04..8c04989c 100644 --- a/src/server/repos/controller.ts +++ b/src/server/repos/controller.ts @@ -11,6 +11,7 @@ import { } from '../../bot/octokit' import { Octokit } from '../../bot/rest' import { logger } from '../../utils/logger' +import { getCommitterEmailDomainWithWarning } from '../../utils/server/committer-email' import { CreateMirrorSchema, DeleteMirrorSchema, @@ -221,7 +222,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]@${getCommitterEmailDomainWithWarning()}`, ], } const git = simpleGit(tempDir, options) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 5f3d08bb..391a5ba7 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server' import { getConfig } from '../bot/config' import { personalOctokit } from '../bot/octokit' import { logger } from '../utils/logger' +import { getGitHubServerHost, getGitHubServerProtocol } from './github-urls' /** * Generates a git url with the access token in it @@ -17,8 +18,8 @@ export const generateAuthUrl = ( ) => { const USER = 'x-access-token' const PASS = accessToken - const REPO = `github.com/${owner}/${repo}` - return `https://${USER}:${PASS}@${REPO}` + const REPO = `${getGitHubServerHost()}/${owner}/${repo}` + return `${getGitHubServerProtocol()}//${USER}:${PASS}@${REPO}` } const middlewareLogger = logger.getSubLogger({ name: 'middleware' }) diff --git a/src/utils/github-urls.ts b/src/utils/github-urls.ts new file mode 100644 index 00000000..cf1ede2e --- /dev/null +++ b/src/utils/github-urls.ts @@ -0,0 +1,111 @@ +/** + * Helpers for resolving GitHub host/API/OAuth URLs. + * + * All values fall back to github.com defaults so existing deployments are + * unaffected. Configure each custom URL explicitly for GHE/GHES deployments. + * + * Note: these helpers may be imported from client bundles, so they may only + * read `NEXT_PUBLIC_*` environment variables. Non-public variables are read + * only via the dedicated server helpers below. + */ + +const DEFAULT_SERVER_URL = 'https://github.com' +const DEFAULT_API_URL = 'https://api.github.com' +const DEFAULT_GRAPHQL_URL = 'https://api.github.com/graphql' +const DEFAULT_EMAIL_DOMAIN = 'users.noreply.github.com' +export const isGithubDotComHost = (host: string) => + host === 'github.com' || host === 'www.github.com' + +const stripTrailingSlash = (value: string) => value.replace(/\/+$/, '') + +const safeUrl = (value: string | undefined | null): URL | null => { + if (!value) return null + try { + return new URL(value) + } catch { + return null + } +} + +/** + * Returns the base GitHub web URL (e.g. `https://github.com`). + * Safe to call from both server and client code. + */ +export const getGitHubServerUrl = (): string => { + const value = + process.env.NEXT_PUBLIC_GITHUB_SERVER_URL ?? process.env.GITHUB_SERVER_URL + return stripTrailingSlash( + value && value.length > 0 ? value : DEFAULT_SERVER_URL, + ) +} + +/** + * Returns the base GitHub REST API URL (e.g. `https://api.github.com`). + * Safe to call from both server and client code. + */ +export const getGitHubApiUrl = (): string => { + const explicit = + process.env.NEXT_PUBLIC_GITHUB_API_URL ?? process.env.GITHUB_API_URL + return stripTrailingSlash( + explicit && explicit.length > 0 ? explicit : DEFAULT_API_URL, + ) +} + +/** + * Returns the GraphQL endpoint URL (e.g. `https://api.github.com/graphql`). + * Safe to call from both server and client code. + */ +export const getGitHubGraphQlUrl = (): string => { + const explicit = + process.env.NEXT_PUBLIC_GITHUB_GRAPHQL_URL ?? process.env.GITHUB_GRAPHQL_URL + return stripTrailingSlash( + explicit && explicit.length > 0 ? explicit : DEFAULT_GRAPHQL_URL, + ) +} + +/** + * Returns the hostname portion of the GitHub server URL (e.g. `github.com`). + * Used to build authenticated git URLs. + */ +export const getGitHubServerHost = (): string => { + return safeUrl(getGitHubServerUrl())?.host ?? 'github.com' +} + +/** + * Returns the scheme portion of the GitHub server URL (e.g. `https:`). + * Used to build authenticated git URLs. + */ +export const getGitHubServerProtocol = (): string => { + return safeUrl(getGitHubServerUrl())?.protocol ?? 'https:' +} + +/** + * Returns the OAuth authorize URL. + */ +export const getOAuthAuthorizationUrl = (): string => + `${getGitHubServerUrl()}/login/oauth/authorize` + +/** + * Returns the OAuth access token URL. + */ +export const getOAuthAccessTokenUrl = (): string => + `${getGitHubServerUrl()}/login/oauth/access_token` + +/** + * Returns the OAuth issuer URL. + */ +export const getOAuthIssuer = (): string => + `${getGitHubServerUrl()}/login/oauth` + +/** + * Returns the committer email domain used for sync commits. + * + * Defaults to `users.noreply.github.com` to keep github.com behavior identical. + * For GHE/GHES, configure `GITHUB_USER_EMAIL_DOMAIN` explicitly (the exact + * domain depends on the instance/tenant configuration and cannot be safely + * derived). Server-only. + */ +export const getCommitterEmailDomain = (): string => { + const value = process.env.GITHUB_USER_EMAIL_DOMAIN + return value && value.length > 0 ? value : DEFAULT_EMAIL_DOMAIN +} diff --git a/src/utils/server/committer-email.ts b/src/utils/server/committer-email.ts new file mode 100644 index 00000000..d5120c0a --- /dev/null +++ b/src/utils/server/committer-email.ts @@ -0,0 +1,35 @@ +import { + getCommitterEmailDomain, + getGitHubServerUrl, + isGithubDotComHost, +} from '../github-urls' +import { logger } from '../logger' + +const githubUrlsLogger = logger.getSubLogger({ name: 'github-urls' }) + +let hasWarnedAboutDefaultCommitterEmailDomain = false + +const isGithubDotComServer = (serverUrl: string) => { + try { + const host = new URL(serverUrl).host.toLowerCase() + return isGithubDotComHost(host) + } catch { + return true + } +} + +export const getCommitterEmailDomainWithWarning = () => { + if ( + !hasWarnedAboutDefaultCommitterEmailDomain && + !process.env.GITHUB_USER_EMAIL_DOMAIN && + !isGithubDotComServer(getGitHubServerUrl()) + ) { + hasWarnedAboutDefaultCommitterEmailDomain = true + githubUrlsLogger.warn( + 'GITHUB_USER_EMAIL_DOMAIN is not set for a non-github.com GitHub server; defaulting to users.noreply.github.com.', + { serverUrl: getGitHubServerUrl() }, + ) + } + + return getCommitterEmailDomain() +} 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..9728898b --- /dev/null +++ b/test/bot/octokit.test.ts @@ -0,0 +1,148 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('Octokit GitHub Enterprise configuration', () => { + afterEach(() => { + delete process.env.GITHUB_SERVER_URL + delete process.env.GITHUB_API_URL + delete process.env.GITHUB_GRAPHQL_URL + delete process.env.NEXT_PUBLIC_GITHUB_SERVER_URL + delete process.env.NEXT_PUBLIC_GITHUB_API_URL + delete process.env.NEXT_PUBLIC_GITHUB_GRAPHQL_URL + delete process.env.APP_ID + delete process.env.CLIENT_ID + delete process.env.CLIENT_SECRET + delete process.env.PRIVATE_KEY + vi.resetModules() + vi.unstubAllEnvs() + vi.clearAllMocks() + vi.doUnmock('bot') + vi.doUnmock('probot') + vi.doUnmock('utils/logger') + }) + + it('configures REST and GraphQL endpoints for GHES', async () => { + process.env.GITHUB_SERVER_URL = 'https://ghes.example.com' + process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3' + process.env.GITHUB_GRAPHQL_URL = 'https://ghes.example.com/api/graphql' + process.env.NEXT_PUBLIC_GITHUB_SERVER_URL = 'https://ghes.example.com' + process.env.NEXT_PUBLIC_GITHUB_API_URL = 'https://ghes.example.com/api/v3' + process.env.NEXT_PUBLIC_GITHUB_GRAPHQL_URL = + 'https://ghes.example.com/api/graphql' + vi.resetModules() + + const { Octokit } = await import('../../src/bot/rest') + 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') + }) + + it('uses NEXT_PUBLIC GitHub URLs for the client-side personal octokit GraphQL endpoint', async () => { + process.env.NEXT_PUBLIC_GITHUB_SERVER_URL = 'https://acme.ghe.com' + process.env.NEXT_PUBLIC_GITHUB_API_URL = 'https://api.acme.ghe.com' + process.env.NEXT_PUBLIC_GITHUB_GRAPHQL_URL = + 'https://api.acme.ghe.com/graphql' + vi.resetModules() + + const { personalOctokit } = await import('../../src/bot/octokit') + const octokit = personalOctokit('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://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 () => { + process.env.GITHUB_SERVER_URL = 'https://ghes.example.com' + process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3' + process.env.NEXT_PUBLIC_GITHUB_SERVER_URL = 'https://ghes.example.com' + process.env.NEXT_PUBLIC_GITHUB_API_URL = 'https://ghes.example.com/api/v3' + process.env.APP_ID = '123' + process.env.CLIENT_ID = 'client-id' + process.env.CLIENT_SECRET = 'client-secret' + process.env.PRIVATE_KEY = 'private-key' + 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 () => { + process.env.GITHUB_SERVER_URL = 'https://ghes.example.com' + process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3' + process.env.GITHUB_GRAPHQL_URL = 'https://ghes.example.com/api/graphql' + process.env.NEXT_PUBLIC_GITHUB_SERVER_URL = 'https://ghes.example.com' + process.env.NEXT_PUBLIC_GITHUB_API_URL = 'https://ghes.example.com/api/v3' + process.env.NEXT_PUBLIC_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('utils/logger', () => ({ + logger: { + getSubLogger: vi.fn().mockReturnValue({}), + }, + })) + vi.doMock('probot', async () => { + const actual = await vi.importActual('probot') + return { + ...actual, + createNodeMiddleware, + createProbot, + } + }) + + await import('../../src/pages/api/webhooks') + + 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..a4b9d677 --- /dev/null +++ b/test/docs/docker-build-config.test.ts @@ -0,0 +1,38 @@ +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 client build args', () => { + it('forwards NEXT_PUBLIC GitHub build args during the Docker build', () => { + const dockerfile = readFileSync(join(repoRoot, 'Dockerfile'), 'utf8') + + expect(dockerfile).toContain('ARG NEXT_PUBLIC_GITHUB_SERVER_URL') + expect(dockerfile).toContain('ARG NEXT_PUBLIC_GITHUB_API_URL') + expect(dockerfile).toContain('ARG NEXT_PUBLIC_GITHUB_GRAPHQL_URL') + expect(dockerfile).toContain( + 'ENV NEXT_PUBLIC_GITHUB_SERVER_URL=$NEXT_PUBLIC_GITHUB_SERVER_URL', + ) + expect(dockerfile).toContain( + 'ENV NEXT_PUBLIC_GITHUB_API_URL=$NEXT_PUBLIC_GITHUB_API_URL', + ) + expect(dockerfile).toContain( + 'ENV NEXT_PUBLIC_GITHUB_GRAPHQL_URL=$NEXT_PUBLIC_GITHUB_GRAPHQL_URL', + ) + }) + + it('documents that the bundled Dockerfile already forwards the build args', () => { + const readme = readFileSync(join(repoRoot, 'README.md'), 'utf8') + const developing = readFileSync( + join(repoRoot, 'docs/developing.md'), + 'utf8', + ) + + expect(readme).toContain('The bundled `Dockerfile` already forwards them') + expect(readme).not.toContain('update the `Dockerfile`') + expect(developing).toContain( + 'The bundled `Dockerfile` already forwards these build args', + ) + }) +}) diff --git a/test/github-urls.test.ts b/test/github-urls.test.ts new file mode 100644 index 00000000..ac6fd04c --- /dev/null +++ b/test/github-urls.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + getCommitterEmailDomain, + getGitHubApiUrl, + getGitHubGraphQlUrl, + getGitHubServerHost, + getGitHubServerProtocol, + getGitHubServerUrl, + getOAuthAccessTokenUrl, + getOAuthAuthorizationUrl, + getOAuthIssuer, +} from '../src/utils/github-urls' + +const ENV_KEYS = [ + 'GITHUB_SERVER_URL', + 'GITHUB_API_URL', + 'GITHUB_GRAPHQL_URL', + 'NEXT_PUBLIC_GITHUB_SERVER_URL', + 'NEXT_PUBLIC_GITHUB_API_URL', + 'NEXT_PUBLIC_GITHUB_GRAPHQL_URL', + 'GITHUB_USER_EMAIL_DOMAIN', +] as const + +describe('github-urls helpers', () => { + const original: Record = {} + + beforeEach(() => { + for (const k of ENV_KEYS) { + original[k] = process.env[k] + delete process.env[k] + } + }) + + afterEach(() => { + for (const k of ENV_KEYS) { + if (original[k] === undefined) { + delete process.env[k] + } else { + process.env[k] = original[k] + } + } + }) + + describe('defaults (backward compatibility)', () => { + it('returns github.com defaults when no env is set', () => { + expect(getGitHubServerUrl()).toBe('https://github.com') + expect(getGitHubApiUrl()).toBe('https://api.github.com') + expect(getGitHubGraphQlUrl()).toBe('https://api.github.com/graphql') + expect(getGitHubServerHost()).toBe('github.com') + expect(getGitHubServerProtocol()).toBe('https:') + expect(getOAuthAuthorizationUrl()).toBe( + 'https://github.com/login/oauth/authorize', + ) + expect(getOAuthAccessTokenUrl()).toBe( + 'https://github.com/login/oauth/access_token', + ) + expect(getOAuthIssuer()).toBe('https://github.com/login/oauth') + expect(getCommitterEmailDomain()).toBe('users.noreply.github.com') + }) + }) + + describe('GHE.com Data Residency configuration', () => { + it('uses explicitly configured API and GraphQL URLs', () => { + process.env.GITHUB_SERVER_URL = 'https://acme.ghe.com' + process.env.GITHUB_API_URL = 'https://api.acme.ghe.com' + process.env.GITHUB_GRAPHQL_URL = 'https://api.acme.ghe.com/graphql' + expect(getGitHubApiUrl()).toBe('https://api.acme.ghe.com') + expect(getGitHubGraphQlUrl()).toBe('https://api.acme.ghe.com/graphql') + expect(getGitHubServerHost()).toBe('acme.ghe.com') + expect(getOAuthIssuer()).toBe('https://acme.ghe.com/login/oauth') + }) + + it('strips trailing slashes', () => { + process.env.GITHUB_SERVER_URL = 'https://acme.ghe.com/' + expect(getGitHubServerUrl()).toBe('https://acme.ghe.com') + }) + }) + + describe('GHES configuration', () => { + it('uses explicitly configured REST and GraphQL URLs', () => { + process.env.GITHUB_SERVER_URL = 'https://ghes.example.com' + process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3' + process.env.GITHUB_GRAPHQL_URL = 'https://ghes.example.com/api/graphql' + expect(getGitHubApiUrl()).toBe('https://ghes.example.com/api/v3') + expect(getGitHubGraphQlUrl()).toBe('https://ghes.example.com/api/graphql') + }) + }) + + describe('explicit overrides', () => { + it('respects explicit GITHUB_API_URL and GITHUB_GRAPHQL_URL', () => { + process.env.GITHUB_SERVER_URL = 'https://acme.ghe.com' + process.env.GITHUB_API_URL = 'https://custom.api.example/v3' + process.env.GITHUB_GRAPHQL_URL = 'https://custom.api.example/graphql' + expect(getGitHubApiUrl()).toBe('https://custom.api.example/v3') + expect(getGitHubGraphQlUrl()).toBe('https://custom.api.example/graphql') + }) + + it('does not derive the GraphQL endpoint from an explicit API override', () => { + process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3' + expect(getGitHubGraphQlUrl()).toBe('https://api.github.com/graphql') + }) + + it('prefers NEXT_PUBLIC_* over server-only env', () => { + process.env.GITHUB_SERVER_URL = 'https://server.example' + process.env.NEXT_PUBLIC_GITHUB_SERVER_URL = 'https://public.example' + expect(getGitHubServerUrl()).toBe('https://public.example') + }) + + it('preserves a custom protocol from the configured server URL', () => { + process.env.GITHUB_SERVER_URL = 'http://ghes.example.com:8080' + expect(getGitHubServerProtocol()).toBe('http:') + expect(getGitHubServerHost()).toBe('ghes.example.com:8080') + }) + + it('respects GITHUB_USER_EMAIL_DOMAIN override', () => { + process.env.GITHUB_USER_EMAIL_DOMAIN = 'users.noreply.acme.ghe.com' + expect(getCommitterEmailDomain()).toBe('users.noreply.acme.ghe.com') + }) + }) +}) diff --git a/test/utils/auth.test.ts b/test/utils/auth.test.ts new file mode 100644 index 00000000..92e5cc05 --- /dev/null +++ b/test/utils/auth.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { generateAuthUrl } from '../../src/utils/auth' + +describe('generateAuthUrl', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('uses the configured server scheme and host', () => { + vi.stubEnv('GITHUB_SERVER_URL', 'http://ghes.example.com:8080') + + 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', () => { + 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/test/utils/server/committer-email.test.ts b/test/utils/server/committer-email.test.ts new file mode 100644 index 00000000..78c1356c --- /dev/null +++ b/test/utils/server/committer-email.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('getCommitterEmailDomainWithWarning', () => { + afterEach(() => { + vi.resetModules() + vi.unstubAllEnvs() + vi.clearAllMocks() + }) + + it('warns once when a non-github.com server uses the default noreply domain', async () => { + vi.stubEnv('GITHUB_SERVER_URL', 'https://ghes.example.com') + + const warnSpy = vi.fn() + vi.doMock('../../../src/utils/logger', () => ({ + logger: { + getSubLogger: vi.fn().mockReturnValue({ warn: warnSpy }), + }, + })) + + const { getCommitterEmailDomainWithWarning } = await import( + '../../../src/utils/server/committer-email' + ) + + expect(getCommitterEmailDomainWithWarning()).toBe( + 'users.noreply.github.com', + ) + expect(getCommitterEmailDomainWithWarning()).toBe( + 'users.noreply.github.com', + ) + expect(warnSpy).toHaveBeenCalledTimes(1) + }) + + it('does not warn when the committer email domain is configured explicitly', async () => { + vi.stubEnv('GITHUB_SERVER_URL', 'https://ghes.example.com') + vi.stubEnv('GITHUB_USER_EMAIL_DOMAIN', 'users.noreply.ghes.example.com') + + const warnSpy = vi.fn() + vi.doMock('../../../src/utils/logger', () => ({ + logger: { + getSubLogger: vi.fn().mockReturnValue({ warn: warnSpy }), + }, + })) + + const { getCommitterEmailDomainWithWarning } = await import( + '../../../src/utils/server/committer-email' + ) + + expect(getCommitterEmailDomainWithWarning()).toBe( + 'users.noreply.ghes.example.com', + ) + expect(warnSpy).not.toHaveBeenCalled() + }) +})