From 00e5917b3e7e8b5896050a3d683f07353170bf24 Mon Sep 17 00:00:00 2001 From: mbianchidev Date: Thu, 28 May 2026 17:44:45 +0200 Subject: [PATCH 01/12] feat: add GHE.com Data Residency / GHES support (#479) Make all GitHub host references configurable via environment variables so the app can run against GHE.com Data Residency tenants (*.ghe.com) and GitHub Enterprise Server, in addition to github.com. - New helpers in src/utils/github-urls.ts derive server/API/OAuth URLs from GITHUB_SERVER_URL (with smart derivation: github.com -> api.github.com, *.ghe.com -> api..ghe.com, anything else -> /api/v3). GITHUB_API_URL overrides derivation. - Octokit (bot/rest.ts) and Probot (pages/api/webhooks.ts) are configured with the derived baseUrl. createAppAuth requests also use the configured base URL via @octokit/request defaults. - NextAuth GitHub provider routes authorization/token/userinfo through the configured GHE host and uses a custom userinfo.request to fetch /user/emails from the configured API host (next-auth v4 hardcodes api.github.com otherwise). OAuth refresh URL uses env. - generateAuthUrl builds git remotes from GITHUB_SERVER_URL host. - Committer email domain is configurable via GITHUB_USER_EMAIL_DOMAIN (default users.noreply.github.com preserves current behavior). - UI components use getGitHubServerUrl() for fork/org links; client bundles read NEXT_PUBLIC_GITHUB_SERVER_URL / NEXT_PUBLIC_GITHUB_API_URL inlined at build time. - webhook-relay.mjs script wires baseUrl into octokit.App and warns when not targeting github.com (polling endpoint is best-effort on GHE). - .env.example and docs (README.md, docs/developing.md) document the GHE.com / GHES configuration and Docker build-arg requirement. - Added tests for URL derivation covering github.com defaults, GHE.com Data Residency, GHES, and explicit overrides. Defaults are unchanged, so existing github.com / GHEC deployments continue to work without any new configuration. Closes #479 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .env.example | 17 +++ .gitignore | 1 + README.md | 37 ++++++ docs/developing.md | 12 ++ env.mjs | 24 +++- scripts/webhook-relay.mjs | 36 +++++- src/app/[organizationId]/page.tsx | 3 +- src/app/api/auth/lib/nextauth-options.ts | 50 ++++++- .../components/dialog/CreateMirrorDialog.tsx | 5 +- .../components/dialog/EditMirrorDialog.tsx | 5 +- .../components/flash/AppNotInstalledFlash.tsx | 3 +- src/app/components/header/ForkHeader.tsx | 3 +- src/bot/octokit.ts | 6 + src/bot/rest.ts | 2 + src/pages/api/webhooks.ts | 12 +- src/server/git/controller.ts | 3 +- src/server/repos/controller.ts | 3 +- src/utils/auth.ts | 3 +- src/utils/github-urls.ts | 116 +++++++++++++++++ test/github-urls.test.ts | 122 ++++++++++++++++++ 20 files changed, 446 insertions(+), 17 deletions(-) create mode 100644 src/utils/github-urls.ts create mode 100644 test/github-urls.test.ts diff --git a/.env.example b/.env.example index a0807a0f..c864c18b 100644 --- a/.env.example +++ b/.env.example @@ -28,6 +28,23 @@ NODE_ENV=development PUBLIC_ORG= PRIVATE_ORG= +# GitHub Enterprise (GHE.com Data Residency / GHES) configuration. +# Leave unset for github.com. For GHE.com Data Residency, set GITHUB_SERVER_URL +# to your tenant URL (e.g. https://acme.ghe.com). For GHES, set it to your +# server URL (e.g. https://ghes.example.com). GITHUB_API_URL is derived +# automatically but can be overridden if needed. +# 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= +NEXT_PUBLIC_GITHUB_SERVER_URL= +NEXT_PUBLIC_GITHUB_API_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). +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/README.md b/README.md index 466cf216..e02aee45 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,43 @@ 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. Authentication, OAuth, REST/GraphQL API calls, git remotes and UI links are all driven by the GitHub host you configure. + +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 + +# Optional. Auto-derived from GITHUB_SERVER_URL: +# github.com -> https://api.github.com +# .ghe.com -> https://api..ghe.com +# -> https:///api/v3 +# Override only if the auto-derivation does not match your instance. +GITHUB_API_URL= +NEXT_PUBLIC_GITHUB_API_URL= + +# 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`) and update the `Dockerfile` to forward them into the `npm run build` step. +- 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..f7fd0773 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -141,6 +141,18 @@ 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` and `NEXT_PUBLIC_GITHUB_API_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). For example: + +```sh +NEXT_PUBLIC_GITHUB_SERVER_URL=https://acme.ghe.com \ + NEXT_PUBLIC_GITHUB_API_URL=https://api.acme.ghe.com \ + 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..54443910 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 GITHUB_SERVER_URL to your GHE base URL + // (e.g. https://acme.ghe.com or https://ghes.example.com); GITHUB_API_URL + // is derived automatically but can be overridden. + GITHUB_SERVER_URL: z.string().url().optional(), + GITHUB_API_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,13 @@ export const env = createEnv({ * * 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_. */ - client: {}, + client: { + // Mirrors of GITHUB_SERVER_URL / GITHUB_API_URL 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(), + }, /* * 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 +134,11 @@ 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_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, 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..6d7d2284 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,39 @@ if (!process.env.PUBLIC_ORG) { const url = `${process.env.NEXTAUTH_URL}/api/webhooks` +const deriveApiUrl = (serverUrl) => { + try { + const u = new URL(serverUrl) + const host = u.host.toLowerCase() + if (host === 'github.com' || host === 'www.github.com') { + return 'https://api.github.com' + } + if (host === 'ghe.com' || host.endsWith('.ghe.com')) { + return `${u.protocol}//api.${host}` + } + return `${u.protocol}//${u.host}/api/v3` + } catch { + return 'https://api.github.com' + } +} + +const apiBaseUrl = + process.env.GITHUB_API_URL ?? + process.env.NEXT_PUBLIC_GITHUB_API_URL ?? + deriveApiUrl( + process.env.GITHUB_SERVER_URL ?? + process.env.NEXT_PUBLIC_GITHUB_SERVER_URL ?? + 'https://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 +68,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{' '} = 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 + }, + }, }), ], 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..109297e0 100644 --- a/src/bot/rest.ts +++ b/src/bot/rest.ts @@ -1,8 +1,10 @@ import { config } from '@probot/octokit-plugin-config' import { Octokit as Core } from 'octokit' +import { getGitHubApiUrl } from '../utils/github-urls' export const Octokit = Core.plugin(config).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..098f2a59 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -1,8 +1,15 @@ import app from 'bot' -import { createNodeMiddleware, createProbot } from 'probot' +import { createNodeMiddleware, createProbot, ProbotOctokit } from 'probot' +import { getGitHubApiUrl } from 'utils/github-urls' import { logger } from 'utils/logger' -export const probot = createProbot() +const baseUrl = getGitHubApiUrl() + +// Configure Probot's Octokit with the GHE/GHES/github.com API base URL so +// every `context.octokit.*` call hits the correct host. +const GheProbotOctokit = ProbotOctokit.defaults({ baseUrl }) + +export const probot = createProbot({ defaults: { Octokit: GheProbotOctokit } }) const probotLogger = logger.getSubLogger({ name: 'probot' }) @@ -15,6 +22,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..e5225599 100644 --- a/src/server/git/controller.ts +++ b/src/server/git/controller.ts @@ -1,5 +1,6 @@ import simpleGit, { SimpleGitOptions } from 'simple-git' import { generateAuthUrl } from '../../utils/auth' +import { getCommitterEmailDomain } from '../../utils/github-urls' import { temporaryDirectory } from 'tempy' import { logger } from '../../utils/logger' import { SyncReposSchema } from './schema' @@ -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]@${getCommitterEmailDomain()}`, ], } diff --git a/src/server/repos/controller.ts b/src/server/repos/controller.ts index aea8bb04..845a374c 100644 --- a/src/server/repos/controller.ts +++ b/src/server/repos/controller.ts @@ -2,6 +2,7 @@ import simpleGit, { SimpleGitOptions } from 'simple-git' import { generateAuthUrl } from 'utils/auth' +import { getCommitterEmailDomain } from 'utils/github-urls' import { temporaryDirectory } from 'tempy' import { getConfig } from '../../bot/config' import { @@ -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]@${getCommitterEmailDomain()}`, ], } const git = simpleGit(tempDir, options) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 5f3d08bb..1491157e 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 } from './github-urls' /** * Generates a git url with the access token in it @@ -17,7 +18,7 @@ export const generateAuthUrl = ( ) => { const USER = 'x-access-token' const PASS = accessToken - const REPO = `github.com/${owner}/${repo}` + const REPO = `${getGitHubServerHost()}/${owner}/${repo}` return `https://${USER}:${PASS}@${REPO}` } diff --git a/src/utils/github-urls.ts b/src/utils/github-urls.ts new file mode 100644 index 00000000..4c0823e1 --- /dev/null +++ b/src/utils/github-urls.ts @@ -0,0 +1,116 @@ +/** + * Helpers for resolving GitHub host/API/OAuth URLs. + * + * Supports github.com (default), GitHub Enterprise Cloud with Data Residency + * (`*.ghe.com`) and GitHub Enterprise Server. + * + * All values fall back to github.com defaults so existing deployments are + * unaffected. + * + * 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_EMAIL_DOMAIN = 'users.noreply.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 + } +} + +/** + * Derives the GitHub REST/GraphQL API URL from a server URL. + * + * - `https://github.com` => `https://api.github.com` + * - `https://.ghe.com` => `https://api..ghe.com` + * - anything else (GHES) => `/api/v3` + */ +export const deriveApiUrlFromServerUrl = (serverUrl: string): string => { + const url = safeUrl(serverUrl) + if (!url) return DEFAULT_API_URL + + const host = url.host.toLowerCase() + + if (host === 'github.com' || host === 'www.github.com') { + return DEFAULT_API_URL + } + + if (host === 'ghe.com' || host.endsWith('.ghe.com')) { + return `${url.protocol}//api.${host}` + } + + return `${url.protocol}//${url.host}/api/v3` +} + +/** + * 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/GraphQL 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 + if (explicit && explicit.length > 0) { + return stripTrailingSlash(explicit) + } + return stripTrailingSlash(deriveApiUrlFromServerUrl(getGitHubServerUrl())) +} + +/** + * 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 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/test/github-urls.test.ts b/test/github-urls.test.ts new file mode 100644 index 00000000..e75f0e59 --- /dev/null +++ b/test/github-urls.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + deriveApiUrlFromServerUrl, + getCommitterEmailDomain, + getGitHubApiUrl, + getGitHubServerHost, + getGitHubServerUrl, + getOAuthAccessTokenUrl, + getOAuthAuthorizationUrl, + getOAuthIssuer, +} from '../src/utils/github-urls' + +const ENV_KEYS = [ + 'GITHUB_SERVER_URL', + 'GITHUB_API_URL', + 'NEXT_PUBLIC_GITHUB_SERVER_URL', + 'NEXT_PUBLIC_GITHUB_API_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('deriveApiUrlFromServerUrl', () => { + it('maps github.com to api.github.com', () => { + expect(deriveApiUrlFromServerUrl('https://github.com')).toBe( + 'https://api.github.com', + ) + }) + + it('maps GHE.com Data Residency tenants to api..ghe.com', () => { + expect(deriveApiUrlFromServerUrl('https://acme.ghe.com')).toBe( + 'https://api.acme.ghe.com', + ) + }) + + it('maps GHES hosts to /api/v3', () => { + expect(deriveApiUrlFromServerUrl('https://ghes.example.com')).toBe( + 'https://ghes.example.com/api/v3', + ) + }) + + it('falls back to api.github.com on invalid input', () => { + expect(deriveApiUrlFromServerUrl('not a url')).toBe( + 'https://api.github.com', + ) + }) + }) + + 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(getGitHubServerHost()).toBe('github.com') + 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('derives API URL from GITHUB_SERVER_URL', () => { + process.env.GITHUB_SERVER_URL = 'https://acme.ghe.com' + expect(getGitHubApiUrl()).toBe('https://api.acme.ghe.com') + 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('derives /api/v3 URL', () => { + process.env.GITHUB_SERVER_URL = 'https://ghes.example.com' + expect(getGitHubApiUrl()).toBe('https://ghes.example.com/api/v3') + }) + }) + + describe('explicit overrides', () => { + it('respects explicit GITHUB_API_URL', () => { + process.env.GITHUB_SERVER_URL = 'https://acme.ghe.com' + process.env.GITHUB_API_URL = 'https://custom.api.example/v3' + expect(getGitHubApiUrl()).toBe('https://custom.api.example/v3') + }) + + 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('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') + }) + }) +}) From e3f5c8e3eb6804703413efc933ab50ae035b5d59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 09:23:29 +0000 Subject: [PATCH 02/12] Initial plan From 22aac252daa5d07a8857d64cae9d9361783af1d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 09:42:27 +0000 Subject: [PATCH 03/12] Fix GitHub Enterprise URL wiring --- .env.example | 7 +- Dockerfile | 5 ++ README.md | 6 +- docs/developing.md | 2 +- scripts/webhook-relay.mjs | 3 + src/app/api/auth/lib/nextauth-options.ts | 68 +++++++++------- src/bot/rest.ts | 23 +++++- src/pages/api/webhooks.ts | 5 +- src/server/git/controller.ts | 4 +- src/server/repos/controller.ts | 4 +- src/utils/auth.ts | 4 +- src/utils/github-urls.ts | 28 ++++++- src/utils/server/committer-email.ts | 31 ++++++++ test/app/api/auth/nextauth-options.test.ts | 51 ++++++++++++ test/bot/octokit.test.ts | 92 ++++++++++++++++++++++ test/docs/docker-build-config.test.ts | 34 ++++++++ test/github-urls.test.ts | 22 +++++- test/utils/auth.test.ts | 28 +++++++ test/utils/server/committer-email.test.ts | 53 +++++++++++++ 19 files changed, 424 insertions(+), 46 deletions(-) create mode 100644 src/utils/server/committer-email.ts create mode 100644 test/app/api/auth/nextauth-options.test.ts create mode 100644 test/bot/octokit.test.ts create mode 100644 test/docs/docker-build-config.test.ts create mode 100644 test/utils/auth.test.ts create mode 100644 test/utils/server/committer-email.test.ts diff --git a/.env.example b/.env.example index c864c18b..6b9ae73b 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,8 @@ PRIVATE_ORG= # Leave unset for github.com. For GHE.com Data Residency, set GITHUB_SERVER_URL # to your tenant URL (e.g. https://acme.ghe.com). For GHES, set it to your # server URL (e.g. https://ghes.example.com). GITHUB_API_URL is derived -# automatically but can be overridden if needed. +# automatically but can be overridden if needed. GraphQL is derived from the +# REST API base and uses /api/graphql on GHES. # 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= @@ -42,7 +43,9 @@ NEXT_PUBLIC_GITHUB_API_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). +# 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 diff --git a/Dockerfile b/Dockerfile index 9c0163d4..4e0c97ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,12 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . +ARG NEXT_PUBLIC_GITHUB_SERVER_URL +ARG NEXT_PUBLIC_GITHUB_API_URL + ENV NEXT_TELEMETRY_DISABLED=1 +ENV NEXT_PUBLIC_GITHUB_SERVER_URL=$NEXT_PUBLIC_GITHUB_SERVER_URL +ENV NEXT_PUBLIC_GITHUB_API_URL=$NEXT_PUBLIC_GITHUB_API_URL RUN npm run build RUN npm prune --omit=dev diff --git a/README.md b/README.md index e02aee45..d949cc80 100644 --- a/README.md +++ b/README.md @@ -113,10 +113,11 @@ GITHUB_SERVER_URL=https://acme.ghe.com # Required for client-side hooks and UI links to point at the correct host. NEXT_PUBLIC_GITHUB_SERVER_URL=https://acme.ghe.com -# Optional. Auto-derived from GITHUB_SERVER_URL: +# Optional REST API base URL. Auto-derived from GITHUB_SERVER_URL: # github.com -> https://api.github.com # .ghe.com -> https://api..ghe.com # -> https:///api/v3 +# GraphQL is derived from this value and uses /api/graphql on GHES. # Override only if the auto-derivation does not match your instance. GITHUB_API_URL= NEXT_PUBLIC_GITHUB_API_URL= @@ -131,7 +132,8 @@ 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`) and update the `Dockerfile` to forward them into the `npm run build` step. +- 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 diff --git a/docs/developing.md b/docs/developing.md index f7fd0773..1129e9f4 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -143,7 +143,7 @@ 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` and `NEXT_PUBLIC_GITHUB_API_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). For example: +The `NEXT_PUBLIC_GITHUB_SERVER_URL` and `NEXT_PUBLIC_GITHUB_API_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 \ diff --git a/scripts/webhook-relay.mjs b/scripts/webhook-relay.mjs index 6d7d2284..60b4e2c2 100644 --- a/scripts/webhook-relay.mjs +++ b/scripts/webhook-relay.mjs @@ -14,6 +14,9 @@ if (!process.env.PUBLIC_ORG) { const url = `${process.env.NEXTAUTH_URL}/api/webhooks` +// Keep this fallback in sync with deriveApiUrlFromServerUrl in +// src/utils/github-urls.ts. The relay only needs the REST API base URL; GraphQL +// callers must use /api/graphql on GHES. const deriveApiUrl = (serverUrl) => { try { const u = new URL(serverUrl) diff --git a/src/app/api/auth/lib/nextauth-options.ts b/src/app/api/auth/lib/nextauth-options.ts index bc7e6ac8..61e0c834 100644 --- a/src/app/api/auth/lib/nextauth-options.ts +++ b/src/app/api/auth/lib/nextauth-options.ts @@ -104,6 +104,43 @@ export const refreshAccessToken = async ( const apiBaseUrl = getGitHubApiUrl() +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', @@ -124,36 +161,7 @@ export const nextAuthOptions: AuthOptions = { 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. - async request({ client, tokens }) { - // 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 - }, + request: createGitHubUserinfoRequest(apiBaseUrl), }, }), ], diff --git a/src/bot/rest.ts b/src/bot/rest.ts index 109297e0..9f36b081 100644 --- a/src/bot/rest.ts +++ b/src/bot/rest.ts @@ -1,8 +1,27 @@ import { config } from '@probot/octokit-plugin-config' import { Octokit as Core } from 'octokit' -import { getGitHubApiUrl } from '../utils/github-urls' +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(), }) diff --git a/src/pages/api/webhooks.ts b/src/pages/api/webhooks.ts index 098f2a59..cc537c99 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -1,5 +1,6 @@ import app from 'bot' import { createNodeMiddleware, createProbot, ProbotOctokit } from 'probot' +import { githubGraphQlEndpointPlugin } from 'bot/rest' import { getGitHubApiUrl } from 'utils/github-urls' import { logger } from 'utils/logger' @@ -7,7 +8,9 @@ const baseUrl = getGitHubApiUrl() // Configure Probot's Octokit with the GHE/GHES/github.com API base URL so // every `context.octokit.*` call hits the correct host. -const GheProbotOctokit = ProbotOctokit.defaults({ baseUrl }) +const GheProbotOctokit = ProbotOctokit.plugin( + githubGraphQlEndpointPlugin, +).defaults({ baseUrl }) export const probot = createProbot({ defaults: { Octokit: GheProbotOctokit } }) diff --git a/src/server/git/controller.ts b/src/server/git/controller.ts index e5225599..6ff1e1d4 100644 --- a/src/server/git/controller.ts +++ b/src/server/git/controller.ts @@ -1,8 +1,8 @@ import simpleGit, { SimpleGitOptions } from 'simple-git' import { generateAuthUrl } from '../../utils/auth' -import { getCommitterEmailDomain } from '../../utils/github-urls' 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' }) @@ -59,7 +59,7 @@ export const syncReposHandler = async ({ const options: Partial = { config: [ `user.name=pma[bot]`, - `user.email=${input.source.octokit.installationId}+pma[bot]@${getCommitterEmailDomain()}`, + `user.email=${input.source.octokit.installationId}+pma[bot]@${getCommitterEmailDomainWithWarning()}`, ], } diff --git a/src/server/repos/controller.ts b/src/server/repos/controller.ts index 845a374c..8c04989c 100644 --- a/src/server/repos/controller.ts +++ b/src/server/repos/controller.ts @@ -2,7 +2,6 @@ import simpleGit, { SimpleGitOptions } from 'simple-git' import { generateAuthUrl } from 'utils/auth' -import { getCommitterEmailDomain } from 'utils/github-urls' import { temporaryDirectory } from 'tempy' import { getConfig } from '../../bot/config' import { @@ -12,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, @@ -222,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]@${getCommitterEmailDomain()}`, + `user.email=${privateInstallationId}+pma[bot]@${getCommitterEmailDomainWithWarning()}`, ], } const git = simpleGit(tempDir, options) diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 1491157e..391a5ba7 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -2,7 +2,7 @@ import { TRPCError } from '@trpc/server' import { getConfig } from '../bot/config' import { personalOctokit } from '../bot/octokit' import { logger } from '../utils/logger' -import { getGitHubServerHost } from './github-urls' +import { getGitHubServerHost, getGitHubServerProtocol } from './github-urls' /** * Generates a git url with the access token in it @@ -19,7 +19,7 @@ export const generateAuthUrl = ( const USER = 'x-access-token' const PASS = accessToken const REPO = `${getGitHubServerHost()}/${owner}/${repo}` - return `https://${USER}:${PASS}@${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 index 4c0823e1..9fcc33ed 100644 --- a/src/utils/github-urls.ts +++ b/src/utils/github-urls.ts @@ -15,6 +15,9 @@ const DEFAULT_SERVER_URL = 'https://github.com' const DEFAULT_API_URL = 'https://api.github.com' const DEFAULT_EMAIL_DOMAIN = 'users.noreply.github.com' +const GHES_API_V3_SUFFIX_REGEX = /\/api\/v3\/?$/ +const isGithubDotComHost = (host: string) => + host === 'github.com' || host === 'www.github.com' const stripTrailingSlash = (value: string) => value.replace(/\/+$/, '') @@ -33,6 +36,9 @@ const safeUrl = (value: string | undefined | null): URL | null => { * - `https://github.com` => `https://api.github.com` * - `https://.ghe.com` => `https://api..ghe.com` * - anything else (GHES) => `/api/v3` + * + * Keep this derivation in sync with the local fallback in + * `scripts/webhook-relay.mjs`. */ export const deriveApiUrlFromServerUrl = (serverUrl: string): string => { const url = safeUrl(serverUrl) @@ -40,7 +46,7 @@ export const deriveApiUrlFromServerUrl = (serverUrl: string): string => { const host = url.host.toLowerCase() - if (host === 'github.com' || host === 'www.github.com') { + if (isGithubDotComHost(host)) { return DEFAULT_API_URL } @@ -76,6 +82,18 @@ export const getGitHubApiUrl = (): string => { return stripTrailingSlash(deriveApiUrlFromServerUrl(getGitHubServerUrl())) } +/** + * 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 apiUrl = getGitHubApiUrl() + if (GHES_API_V3_SUFFIX_REGEX.test(apiUrl)) { + return apiUrl.replace(GHES_API_V3_SUFFIX_REGEX, '/api/graphql') + } + return `${apiUrl}/graphql` +} + /** * Returns the hostname portion of the GitHub server URL (e.g. `github.com`). * Used to build authenticated git URLs. @@ -84,6 +102,14 @@ 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. */ diff --git a/src/utils/server/committer-email.ts b/src/utils/server/committer-email.ts new file mode 100644 index 00000000..ef2a3066 --- /dev/null +++ b/src/utils/server/committer-email.ts @@ -0,0 +1,31 @@ +import { getCommitterEmailDomain, getGitHubServerUrl } 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 host === 'github.com' || host === 'www.github.com' + } 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..a68e2f8d --- /dev/null +++ b/test/bot/octokit.test.ts @@ -0,0 +1,92 @@ +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.NEXT_PUBLIC_GITHUB_SERVER_URL + delete process.env.NEXT_PUBLIC_GITHUB_API_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() + }) + + it('configures REST and GraphQL endpoints for GHES', async () => { + delete process.env.GITHUB_API_URL + delete process.env.NEXT_PUBLIC_GITHUB_API_URL + process.env.GITHUB_SERVER_URL = 'https://ghes.example.com' + process.env.NEXT_PUBLIC_GITHUB_SERVER_URL = 'https://ghes.example.com' + 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' + 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 () => { + delete process.env.GITHUB_API_URL + delete process.env.NEXT_PUBLIC_GITHUB_API_URL + process.env.GITHUB_SERVER_URL = 'https://ghes.example.com' + process.env.NEXT_PUBLIC_GITHUB_SERVER_URL = 'https://ghes.example.com' + 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', + }) + }) +}) diff --git a/test/docs/docker-build-config.test.ts b/test/docs/docker-build-config.test.ts new file mode 100644 index 00000000..56acc4d4 --- /dev/null +++ b/test/docs/docker-build-config.test.ts @@ -0,0 +1,34 @@ +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( + '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', + ) + }) + + 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 index e75f0e59..d16cb092 100644 --- a/test/github-urls.test.ts +++ b/test/github-urls.test.ts @@ -3,7 +3,9 @@ import { deriveApiUrlFromServerUrl, getCommitterEmailDomain, getGitHubApiUrl, + getGitHubGraphQlUrl, getGitHubServerHost, + getGitHubServerProtocol, getGitHubServerUrl, getOAuthAccessTokenUrl, getOAuthAuthorizationUrl, @@ -68,7 +70,9 @@ describe('github-urls helpers', () => { 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', ) @@ -84,6 +88,7 @@ describe('github-urls helpers', () => { it('derives API URL from GITHUB_SERVER_URL', () => { process.env.GITHUB_SERVER_URL = 'https://acme.ghe.com' 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') }) @@ -95,9 +100,10 @@ describe('github-urls helpers', () => { }) describe('GHES configuration', () => { - it('derives /api/v3 URL', () => { + it('derives /api/v3 REST URL and /api/graphql GraphQL URL', () => { process.env.GITHUB_SERVER_URL = 'https://ghes.example.com' expect(getGitHubApiUrl()).toBe('https://ghes.example.com/api/v3') + expect(getGitHubGraphQlUrl()).toBe('https://ghes.example.com/api/graphql') }) }) @@ -106,6 +112,14 @@ describe('github-urls helpers', () => { process.env.GITHUB_SERVER_URL = 'https://acme.ghe.com' process.env.GITHUB_API_URL = 'https://custom.api.example/v3' expect(getGitHubApiUrl()).toBe('https://custom.api.example/v3') + expect(getGitHubGraphQlUrl()).toBe( + 'https://custom.api.example/v3/graphql', + ) + }) + + it('derives the GHES GraphQL endpoint from an explicit /api/v3 override', () => { + process.env.GITHUB_API_URL = 'https://ghes.example.com/api/v3' + expect(getGitHubGraphQlUrl()).toBe('https://ghes.example.com/api/graphql') }) it('prefers NEXT_PUBLIC_* over server-only env', () => { @@ -114,6 +128,12 @@ describe('github-urls helpers', () => { 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() + }) +}) From 4d2bc748dc1102c48ff6008fcdda983a8a81d078 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 14:59:10 +0000 Subject: [PATCH 04/12] refactor: reuse isGithubDotComHost in committer-email.ts --- src/utils/github-urls.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/github-urls.ts b/src/utils/github-urls.ts index 9fcc33ed..9027a3a4 100644 --- a/src/utils/github-urls.ts +++ b/src/utils/github-urls.ts @@ -16,7 +16,7 @@ const DEFAULT_SERVER_URL = 'https://github.com' const DEFAULT_API_URL = 'https://api.github.com' const DEFAULT_EMAIL_DOMAIN = 'users.noreply.github.com' const GHES_API_V3_SUFFIX_REGEX = /\/api\/v3\/?$/ -const isGithubDotComHost = (host: string) => +export const isGithubDotComHost = (host: string) => host === 'github.com' || host === 'www.github.com' const stripTrailingSlash = (value: string) => value.replace(/\/+$/, '') From b57c36b9577eed138ca0565fc70a73222b3bc179 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 15:00:27 +0000 Subject: [PATCH 05/12] refactor: reuse isGithubDotComHost in committer-email.ts to remove duplication --- src/utils/server/committer-email.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/utils/server/committer-email.ts b/src/utils/server/committer-email.ts index ef2a3066..d5120c0a 100644 --- a/src/utils/server/committer-email.ts +++ b/src/utils/server/committer-email.ts @@ -1,4 +1,8 @@ -import { getCommitterEmailDomain, getGitHubServerUrl } from '../github-urls' +import { + getCommitterEmailDomain, + getGitHubServerUrl, + isGithubDotComHost, +} from '../github-urls' import { logger } from '../logger' const githubUrlsLogger = logger.getSubLogger({ name: 'github-urls' }) @@ -8,7 +12,7 @@ let hasWarnedAboutDefaultCommitterEmailDomain = false const isGithubDotComServer = (serverUrl: string) => { try { const host = new URL(serverUrl).host.toLowerCase() - return host === 'github.com' || host === 'www.github.com' + return isGithubDotComHost(host) } catch { return true } From 5abf27524163735b4c3808ce0f8fa48d9b4cf08c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:06:30 +0000 Subject: [PATCH 06/12] Remove derived GitHub URL configuration --- .env.example | 8 ++-- Dockerfile | 2 + README.md | 17 ++++----- docs/developing.md | 3 +- env.mjs | 15 +++++--- scripts/webhook-relay.mjs | 25 +------------ src/pages/api/webhooks.ts | 15 +------- src/utils/github-urls.ts | 53 ++++++--------------------- test/bot/octokit.test.ts | 16 ++++++-- test/docs/docker-build-config.test.ts | 4 ++ test/github-urls.test.ts | 48 +++++++----------------- 11 files changed, 66 insertions(+), 140 deletions(-) diff --git a/.env.example b/.env.example index 6b9ae73b..521494cc 100644 --- a/.env.example +++ b/.env.example @@ -29,17 +29,15 @@ PUBLIC_ORG= PRIVATE_ORG= # GitHub Enterprise (GHE.com Data Residency / GHES) configuration. -# Leave unset for github.com. For GHE.com Data Residency, set GITHUB_SERVER_URL -# to your tenant URL (e.g. https://acme.ghe.com). For GHES, set it to your -# server URL (e.g. https://ghes.example.com). GITHUB_API_URL is derived -# automatically but can be overridden if needed. GraphQL is derived from the -# REST API base and uses /api/graphql on GHES. +# 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 diff --git a/Dockerfile b/Dockerfile index 4e0c97ab..022c7fa7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,12 @@ 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 ENV NEXT_PUBLIC_GITHUB_API_URL=$NEXT_PUBLIC_GITHUB_API_URL +ENV NEXT_PUBLIC_GITHUB_GRAPHQL_URL=$NEXT_PUBLIC_GITHUB_GRAPHQL_URL RUN npm run build RUN npm prune --omit=dev diff --git a/README.md b/README.md index d949cc80..db61a6fe 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ The authentication of the UI will still need to be a user's github.com user, but ## 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. Authentication, OAuth, REST/GraphQL API calls, git remotes and UI links are all driven by the GitHub host you configure. +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: @@ -113,14 +113,13 @@ GITHUB_SERVER_URL=https://acme.ghe.com # Required for client-side hooks and UI links to point at the correct host. NEXT_PUBLIC_GITHUB_SERVER_URL=https://acme.ghe.com -# Optional REST API base URL. Auto-derived from GITHUB_SERVER_URL: -# github.com -> https://api.github.com -# .ghe.com -> https://api..ghe.com -# -> https:///api/v3 -# GraphQL is derived from this value and uses /api/graphql on GHES. -# Override only if the auto-derivation does not match your instance. -GITHUB_API_URL= -NEXT_PUBLIC_GITHUB_API_URL= +# 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.: diff --git a/docs/developing.md b/docs/developing.md index 1129e9f4..b223c505 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -143,11 +143,12 @@ 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` and `NEXT_PUBLIC_GITHUB_API_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: +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 ``` diff --git a/env.mjs b/env.mjs index 54443910..115bf7c1 100644 --- a/env.mjs +++ b/env.mjs @@ -23,11 +23,11 @@ export const env = createEnv({ 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 GITHUB_SERVER_URL to your GHE base URL - // (e.g. https://acme.ghe.com or https://ghes.example.com); GITHUB_API_URL - // is derived automatically but can be overridden. + // 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). @@ -110,11 +110,12 @@ export const env = createEnv({ * 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_. */ client: { - // Mirrors of GITHUB_SERVER_URL / GITHUB_API_URL 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`. + // 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, @@ -136,9 +137,11 @@ export const env = createEnv({ 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 60b4e2c2..0ec6a142 100644 --- a/scripts/webhook-relay.mjs +++ b/scripts/webhook-relay.mjs @@ -14,33 +14,10 @@ if (!process.env.PUBLIC_ORG) { const url = `${process.env.NEXTAUTH_URL}/api/webhooks` -// Keep this fallback in sync with deriveApiUrlFromServerUrl in -// src/utils/github-urls.ts. The relay only needs the REST API base URL; GraphQL -// callers must use /api/graphql on GHES. -const deriveApiUrl = (serverUrl) => { - try { - const u = new URL(serverUrl) - const host = u.host.toLowerCase() - if (host === 'github.com' || host === 'www.github.com') { - return 'https://api.github.com' - } - if (host === 'ghe.com' || host.endsWith('.ghe.com')) { - return `${u.protocol}//api.${host}` - } - return `${u.protocol}//${u.host}/api/v3` - } catch { - return 'https://api.github.com' - } -} - const apiBaseUrl = process.env.GITHUB_API_URL ?? process.env.NEXT_PUBLIC_GITHUB_API_URL ?? - deriveApiUrl( - process.env.GITHUB_SERVER_URL ?? - process.env.NEXT_PUBLIC_GITHUB_SERVER_URL ?? - 'https://github.com', - ) + 'https://api.github.com' if (apiBaseUrl !== 'https://api.github.com') { console.warn( diff --git a/src/pages/api/webhooks.ts b/src/pages/api/webhooks.ts index cc537c99..6b2f3e92 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -1,19 +1,7 @@ import app from 'bot' -import { createNodeMiddleware, createProbot, ProbotOctokit } from 'probot' -import { githubGraphQlEndpointPlugin } from 'bot/rest' -import { getGitHubApiUrl } from 'utils/github-urls' +import { createNodeMiddleware, createProbot } from 'probot' import { logger } from 'utils/logger' -const baseUrl = getGitHubApiUrl() - -// Configure Probot's Octokit with the GHE/GHES/github.com API base URL so -// every `context.octokit.*` call hits the correct host. -const GheProbotOctokit = ProbotOctokit.plugin( - githubGraphQlEndpointPlugin, -).defaults({ baseUrl }) - -export const probot = createProbot({ defaults: { Octokit: GheProbotOctokit } }) - const probotLogger = logger.getSubLogger({ name: 'probot' }) export const config = { @@ -25,7 +13,6 @@ 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/utils/github-urls.ts b/src/utils/github-urls.ts index 9027a3a4..cf1ede2e 100644 --- a/src/utils/github-urls.ts +++ b/src/utils/github-urls.ts @@ -1,11 +1,8 @@ /** * Helpers for resolving GitHub host/API/OAuth URLs. * - * Supports github.com (default), GitHub Enterprise Cloud with Data Residency - * (`*.ghe.com`) and GitHub Enterprise Server. - * * All values fall back to github.com defaults so existing deployments are - * unaffected. + * 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 @@ -14,8 +11,8 @@ 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' -const GHES_API_V3_SUFFIX_REGEX = /\/api\/v3\/?$/ export const isGithubDotComHost = (host: string) => host === 'github.com' || host === 'www.github.com' @@ -30,33 +27,6 @@ const safeUrl = (value: string | undefined | null): URL | null => { } } -/** - * Derives the GitHub REST/GraphQL API URL from a server URL. - * - * - `https://github.com` => `https://api.github.com` - * - `https://.ghe.com` => `https://api..ghe.com` - * - anything else (GHES) => `/api/v3` - * - * Keep this derivation in sync with the local fallback in - * `scripts/webhook-relay.mjs`. - */ -export const deriveApiUrlFromServerUrl = (serverUrl: string): string => { - const url = safeUrl(serverUrl) - if (!url) return DEFAULT_API_URL - - const host = url.host.toLowerCase() - - if (isGithubDotComHost(host)) { - return DEFAULT_API_URL - } - - if (host === 'ghe.com' || host.endsWith('.ghe.com')) { - return `${url.protocol}//api.${host}` - } - - return `${url.protocol}//${url.host}/api/v3` -} - /** * Returns the base GitHub web URL (e.g. `https://github.com`). * Safe to call from both server and client code. @@ -70,16 +40,15 @@ export const getGitHubServerUrl = (): string => { } /** - * Returns the base GitHub REST/GraphQL API URL (e.g. `https://api.github.com`). + * 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 - if (explicit && explicit.length > 0) { - return stripTrailingSlash(explicit) - } - return stripTrailingSlash(deriveApiUrlFromServerUrl(getGitHubServerUrl())) + return stripTrailingSlash( + explicit && explicit.length > 0 ? explicit : DEFAULT_API_URL, + ) } /** @@ -87,11 +56,11 @@ export const getGitHubApiUrl = (): string => { * Safe to call from both server and client code. */ export const getGitHubGraphQlUrl = (): string => { - const apiUrl = getGitHubApiUrl() - if (GHES_API_V3_SUFFIX_REGEX.test(apiUrl)) { - return apiUrl.replace(GHES_API_V3_SUFFIX_REGEX, '/api/graphql') - } - return `${apiUrl}/graphql` + const explicit = + process.env.NEXT_PUBLIC_GITHUB_GRAPHQL_URL ?? process.env.GITHUB_GRAPHQL_URL + return stripTrailingSlash( + explicit && explicit.length > 0 ? explicit : DEFAULT_GRAPHQL_URL, + ) } /** diff --git a/test/bot/octokit.test.ts b/test/bot/octokit.test.ts index a68e2f8d..40180cff 100644 --- a/test/bot/octokit.test.ts +++ b/test/bot/octokit.test.ts @@ -4,8 +4,10 @@ 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 @@ -16,10 +18,13 @@ describe('Octokit GitHub Enterprise configuration', () => { }) it('configures REST and GraphQL endpoints for GHES', async () => { - delete process.env.GITHUB_API_URL - delete process.env.NEXT_PUBLIC_GITHUB_API_URL 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') @@ -38,6 +43,9 @@ describe('Octokit GitHub Enterprise configuration', () => { 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') @@ -55,10 +63,10 @@ describe('Octokit GitHub Enterprise configuration', () => { }) it('uses the configured REST API base URL for app auth requests', async () => { - delete process.env.GITHUB_API_URL - delete process.env.NEXT_PUBLIC_GITHUB_API_URL 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' diff --git a/test/docs/docker-build-config.test.ts b/test/docs/docker-build-config.test.ts index 56acc4d4..a4b9d677 100644 --- a/test/docs/docker-build-config.test.ts +++ b/test/docs/docker-build-config.test.ts @@ -10,12 +10,16 @@ describe('Dockerfile and docs for GHE client build args', () => { 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', () => { diff --git a/test/github-urls.test.ts b/test/github-urls.test.ts index d16cb092..ac6fd04c 100644 --- a/test/github-urls.test.ts +++ b/test/github-urls.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { - deriveApiUrlFromServerUrl, getCommitterEmailDomain, getGitHubApiUrl, getGitHubGraphQlUrl, @@ -15,8 +14,10 @@ import { 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 @@ -40,32 +41,6 @@ describe('github-urls helpers', () => { } }) - describe('deriveApiUrlFromServerUrl', () => { - it('maps github.com to api.github.com', () => { - expect(deriveApiUrlFromServerUrl('https://github.com')).toBe( - 'https://api.github.com', - ) - }) - - it('maps GHE.com Data Residency tenants to api..ghe.com', () => { - expect(deriveApiUrlFromServerUrl('https://acme.ghe.com')).toBe( - 'https://api.acme.ghe.com', - ) - }) - - it('maps GHES hosts to /api/v3', () => { - expect(deriveApiUrlFromServerUrl('https://ghes.example.com')).toBe( - 'https://ghes.example.com/api/v3', - ) - }) - - it('falls back to api.github.com on invalid input', () => { - expect(deriveApiUrlFromServerUrl('not a url')).toBe( - 'https://api.github.com', - ) - }) - }) - describe('defaults (backward compatibility)', () => { it('returns github.com defaults when no env is set', () => { expect(getGitHubServerUrl()).toBe('https://github.com') @@ -85,8 +60,10 @@ describe('github-urls helpers', () => { }) describe('GHE.com Data Residency configuration', () => { - it('derives API URL from GITHUB_SERVER_URL', () => { + 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') @@ -100,26 +77,27 @@ describe('github-urls helpers', () => { }) describe('GHES configuration', () => { - it('derives /api/v3 REST URL and /api/graphql GraphQL URL', () => { + 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', () => { + 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/v3/graphql', - ) + expect(getGitHubGraphQlUrl()).toBe('https://custom.api.example/graphql') }) - it('derives the GHES GraphQL endpoint from an explicit /api/v3 override', () => { + 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://ghes.example.com/api/graphql') + expect(getGitHubGraphQlUrl()).toBe('https://api.github.com/graphql') }) it('prefers NEXT_PUBLIC_* over server-only env', () => { From bb96e61db80919dbd66de697aad9a119e49b8d98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:10:08 +0000 Subject: [PATCH 07/12] Restore webhook Probot endpoint configuration --- src/pages/api/webhooks.ts | 11 ++++++++- test/bot/octokit.test.ts | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/pages/api/webhooks.ts b/src/pages/api/webhooks.ts index 6b2f3e92..c2cc65ce 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -1,7 +1,15 @@ 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' +const GheProbotOctokit = ProbotOctokit.plugin( + githubGraphQlEndpointPlugin, +).defaults({ + baseUrl: getGitHubApiUrl(), +}) + const probotLogger = logger.getSubLogger({ name: 'probot' }) export const config = { @@ -13,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/test/bot/octokit.test.ts b/test/bot/octokit.test.ts index 40180cff..9728898b 100644 --- a/test/bot/octokit.test.ts +++ b/test/bot/octokit.test.ts @@ -15,6 +15,9 @@ describe('Octokit GitHub Enterprise configuration', () => { 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 () => { @@ -97,4 +100,49 @@ describe('Octokit GitHub Enterprise configuration', () => { 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') + }) }) From 252f0df63fe0052047e5c9de6d6b95020f058593 Mon Sep 17 00:00:00 2001 From: Matteo Bianchi <37507190+mbianchidev@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:23:10 +0200 Subject: [PATCH 08/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matteo Bianchi <37507190+mbianchidev@users.noreply.github.com> --- .env.example | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 521494cc..0a137671 100644 --- a/.env.example +++ b/.env.example @@ -32,12 +32,12 @@ PRIVATE_ORG= # 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= +# 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 From 09fe8b918b77bb61b6d0367a4d7796ac5219d387 Mon Sep 17 00:00:00 2001 From: Matteo Bianchi <37507190+mbianchidev@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:23:42 +0200 Subject: [PATCH 09/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matteo Bianchi <37507190+mbianchidev@users.noreply.github.com> --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 022c7fa7..f6abafde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,9 @@ 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 -ENV NEXT_PUBLIC_GITHUB_API_URL=$NEXT_PUBLIC_GITHUB_API_URL -ENV NEXT_PUBLIC_GITHUB_GRAPHQL_URL=$NEXT_PUBLIC_GITHUB_GRAPHQL_URL +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 From e0db7aad87214858f0cfd4e0d65ceb65de9fe3cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:50:25 +0000 Subject: [PATCH 10/12] refactor: centralize GitHub environment config --- .env.example | 18 +-- Dockerfile | 7 - README.md | 14 +- docs/developing.md | 9 +- env.mjs | 45 ++++--- scripts/webhook-relay.mjs | 39 ++---- src/app/[organizationId]/page.tsx | 5 +- src/app/api/auth/lib/nextauth-options.ts | 50 ++++---- .../components/dialog/CreateMirrorDialog.tsx | 7 +- .../components/dialog/EditMirrorDialog.tsx | 7 +- .../components/flash/AppNotInstalledFlash.tsx | 5 +- src/app/components/header/ForkHeader.tsx | 5 +- src/app/context/GitHubEnvironmentProvider.tsx | 37 ++++++ src/app/layout.tsx | 50 +++++--- src/bot/octokit.ts | 49 +++---- src/bot/rest.ts | 36 +++++- src/hooks/useFork.tsx | 23 +++- src/hooks/useForks.tsx | 16 ++- src/hooks/useOrganization.tsx | 16 ++- src/hooks/useOrganizations.tsx | 15 ++- src/pages/api/webhooks.ts | 5 +- src/server/git/controller.ts | 4 +- src/server/repos/controller.ts | 16 +-- src/utils/auth.ts | 18 ++- src/utils/github-urls.ts | 111 ---------------- src/utils/server/committer-email.ts | 35 ----- test/bot/octokit.test.ts | 65 +++------- test/docs/docker-build-config.test.ts | 25 +--- test/env.test.ts | 44 +++++++ test/github-urls.test.ts | 120 ------------------ test/setup-env.ts | 24 ++++ test/utils/auth.test.ts | 12 +- test/utils/server/committer-email.test.ts | 53 -------- vitest.config.ts | 1 + 34 files changed, 381 insertions(+), 605 deletions(-) create mode 100644 src/app/context/GitHubEnvironmentProvider.tsx delete mode 100644 src/utils/github-urls.ts delete mode 100644 src/utils/server/committer-email.ts create mode 100644 test/env.test.ts delete mode 100644 test/github-urls.test.ts create mode 100644 test/setup-env.ts delete mode 100644 test/utils/server/committer-email.test.ts diff --git a/.env.example b/.env.example index 25cd21b8..c76b75cb 100644 --- a/.env.example +++ b/.env.example @@ -29,22 +29,16 @@ 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. +# 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= -# 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= +# `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= @@ -58,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/Dockerfile b/Dockerfile index f6abafde..9c0163d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,14 +10,7 @@ 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 db61a6fe..4e950c86 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ The authentication of the UI will still need to be a user's github.com user, but ## 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. +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: @@ -109,18 +109,10 @@ Set the following environment variables in addition to the GHEC variables above: # 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 @@ -131,8 +123,8 @@ 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. +- 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 diff --git a/docs/developing.md b/docs/developing.md index b223c505..4304a9ae 100644 --- a/docs/developing.md +++ b/docs/developing.md @@ -143,14 +143,7 @@ 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 -``` +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. diff --git a/env.mjs b/env.mjs index 0e1d3b85..ed40a3f6 100644 --- a/env.mjs +++ b/env.mjs @@ -22,16 +22,29 @@ export const env = createEnv({ 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(), + GITHUB_SERVER_URL: z + .string() + .url() + .optional() + .default('https://github.com') + .transform((value) => value.replace(/\/+$/, '')), + GITHUB_API_URL: z + .string() + .url() + .optional() + .default('https://api.github.com') + .transform((value) => value.replace(/\/+$/, '')), + GITHUB_GRAPHQL_URL: z + .string() + .url() + .optional() + .default('https://api.github.com/graphql') + .transform((value) => value.replace(/\/+$/, '')), + GITHUB_USER_EMAIL_DOMAIN: z + .string() + .min(1) + .optional() + .default('users.noreply.github.com'), // Custom validation for a comma separated list of strings // ex: ajhenry,github,ahpook ALLOWED_HANDLES: z @@ -114,14 +127,7 @@ export const env = createEnv({ * * 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_. */ - 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(), - }, + client: {}, /* * 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. @@ -144,9 +150,6 @@ export const env = createEnv({ 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 0ec6a142..496968b2 100644 --- a/scripts/webhook-relay.mjs +++ b/scripts/webhook-relay.mjs @@ -4,20 +4,17 @@ import crypto from 'node:crypto' 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 apiBaseUrl = - process.env.GITHUB_API_URL ?? - process.env.NEXT_PUBLIC_GITHUB_API_URL ?? - 'https://api.github.com' +const url = `${env.NEXTAUTH_URL}/api/webhooks` +const apiBaseUrl = env.GITHUB_API_URL if (apiBaseUrl !== 'https://api.github.com') { console.warn( @@ -27,13 +24,11 @@ if (apiBaseUrl !== 'https://api.github.com') { const RelayOctokit = Octokit.defaults({ baseUrl: apiBaseUrl }) -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') ?? '') +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', @@ -42,7 +37,7 @@ 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. @@ -79,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' @@ -105,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 cc1e2b1c..c0169603 100644 --- a/src/app/[organizationId]/page.tsx +++ b/src/app/[organizationId]/page.tsx @@ -24,10 +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 { getGitHubServerUrl } from 'utils/github-urls' +import { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider' const Organization = () => { const { organizationId } = useParams() + const { serverUrl } = useGitHubEnvironment() const { data, isLoading } = trpc.checkInstallation.useQuery({ orgId: organizationId as string, }) @@ -208,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 @@ -63,7 +62,7 @@ export const refreshAccessToken = async ( grant_type: 'refresh_token', }) - const url = `${getOAuthAccessTokenUrl()}?${params.toString()}` + const url = `${env.GITHUB_SERVER_URL}/login/oauth/access_token?${params.toString()}` const response = await fetch(url, { headers: { @@ -102,7 +101,7 @@ export const refreshAccessToken = async ( } } -const apiBaseUrl = getGitHubApiUrl() +const apiBaseUrl = env.GITHUB_API_URL export const createGitHubUserinfoRequest = (apiBaseUrl: string) => @@ -146,17 +145,17 @@ export const nextAuthOptions: AuthOptions = { 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: getOAuthIssuer(), + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + issuer: `${env.GITHUB_SERVER_URL}/login/oauth`, authorization: { - url: getOAuthAuthorizationUrl(), + url: `${env.GITHUB_SERVER_URL}/login/oauth/authorize`, params: { scope: 'repo, user, read:org' }, }, - token: getOAuthAccessTokenUrl(), + 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` @@ -165,7 +164,7 @@ export const nextAuthOptions: AuthOptions = { }, }), ], - secret: process.env.NEXTAUTH_SECRET!, + secret: env.NEXTAUTH_SECRET, logger: { error(code, metadata) { if (!(metadata instanceof Error) && metadata.provider) { @@ -195,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 !== '', ) @@ -233,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 @@ -310,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 467f7403..a6f42f01 100644 --- a/src/app/components/dialog/CreateMirrorDialog.tsx +++ b/src/app/components/dialog/CreateMirrorDialog.tsx @@ -9,7 +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 { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider' import { useState } from 'react' @@ -32,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) @@ -92,7 +93,7 @@ export const CreateMirrorDialog = ({ This is a private mirror of{' '} @@ -136,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) @@ -106,7 +107,7 @@ export const EditMirrorDialog = ({ This is a private mirror of{' '} @@ -150,7 +151,7 @@ export const EditMirrorDialog = ({ > Forked from{' '} { + const { serverUrl } = useGitHubEnvironment() return ( @@ -25,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 bcfe2ca0..15b32bec 100644 --- a/src/app/components/header/ForkHeader.tsx +++ b/src/app/components/header/ForkHeader.tsx @@ -8,13 +8,14 @@ import { Text, } from '@primer/react' import { ForkData } from 'hooks/useFork' -import { getGitHubServerUrl } from 'utils/github-urls' +import { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider' interface ForkHeaderProps { forkData: ForkData } export const ForkHeader = ({ forkData }: ForkHeaderProps) => { + const { serverUrl } = useGitHubEnvironment() return ( {forkData ? ( @@ -50,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 20989f78..57b0d7d7 100644 --- a/src/bot/octokit.ts +++ b/src/bot/octokit.ts @@ -1,20 +1,17 @@ import { createAppAuth } from '@octokit/auth-app' import { request as octokitRequest } from '@octokit/request' import { generatePKCS8Key } from 'utils/pem' -import { getGitHubApiUrl } from '../utils/github-urls' import { logger } from '../utils/logger' import { Octokit } from './rest' +import { env } from '../../env.mjs' -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-----') - ? // 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') ?? '') +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') /** * Generates an app access token for the app or an installation (if installationId is provided) @@ -24,11 +21,11 @@ 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: getGitHubApiUrl() }) + 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, @@ -42,10 +39,10 @@ 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, }) @@ -66,12 +63,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, }) } @@ -86,23 +85,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 9f36b081..c4d236fb 100644 --- a/src/bot/rest.ts +++ b/src/bot/rest.ts @@ -1,6 +1,6 @@ import { config } from '@probot/octokit-plugin-config' import { Octokit as Core } from 'octokit' -import { getGitHubApiUrl, getGitHubGraphQlUrl } from '../utils/github-urls' +import { logger } from '../utils/logger' type GraphQlConfigurableOctokit = { graphql: { @@ -10,10 +10,20 @@ type GraphQlConfigurableOctokit = { } } -export const githubGraphQlEndpointPlugin = (octokit: unknown) => { +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: getGitHubGraphQlUrl(), + url: options.githubGraphQlUrl, }) return {} } @@ -23,7 +33,25 @@ export const Octokit = Core.plugin( githubGraphQlEndpointPlugin, ).defaults({ userAgent: `octokit-rest.js/repo-sync-bot`, - baseUrl: getGitHubApiUrl(), }) 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 58ad36dc..68248943 100644 --- a/src/pages/api/webhooks.ts +++ b/src/pages/api/webhooks.ts @@ -1,12 +1,13 @@ import app from 'bot' import { githubGraphQlEndpointPlugin } from 'bot/rest' import { createNodeMiddleware, createProbot, ProbotOctokit } from 'probot' -import { getGitHubApiUrl } from 'utils/github-urls' +import { env } from '../../../env.mjs' const GheProbotOctokit = ProbotOctokit.plugin( githubGraphQlEndpointPlugin, ).defaults({ - baseUrl: getGitHubApiUrl(), + baseUrl: env.GITHUB_API_URL, + githubGraphQlUrl: env.GITHUB_GRAPHQL_URL, }) export const config = { diff --git a/src/server/git/controller.ts b/src/server/git/controller.ts index f584c723..0d7f247f 100644 --- a/src/server/git/controller.ts +++ b/src/server/git/controller.ts @@ -2,9 +2,9 @@ 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 { cleanupTempDir } from '../../utils/temp-dir' import { SyncReposSchema } from './schema' +import { env } from '../../../env.mjs' const gitApiLogger = logger.getSubLogger({ name: 'git-api' }) @@ -61,7 +61,7 @@ export const syncReposHandler = async ({ const options: Partial = { config: [ `user.name=pma[bot]`, - `user.email=${input.source.octokit.installationId}+pma[bot]@${getCommitterEmailDomainWithWarning()}`, + `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 b937787b..0dd2e268 100644 --- a/src/server/repos/controller.ts +++ b/src/server/repos/controller.ts @@ -11,7 +11,6 @@ import { } from '../../bot/octokit' import { Octokit } from '../../bot/rest' import { logger } from '../../utils/logger' -import { getCommitterEmailDomainWithWarning } from '../../utils/server/committer-email' import { cleanupTempDir } from '../../utils/temp-dir' import { CreateMirrorSchema, @@ -20,6 +19,7 @@ import { ListMirrorsSchema, } from './schema' import { TRPCError } from '@trpc/server' +import { env } from '../../../env.mjs' const reposApiLogger = logger.getSubLogger({ name: 'repos-api' }) @@ -206,7 +206,7 @@ export const createMirrorHandler = async ({ name: input.newRepoName, org: privateOrg, // @ts-expect-error 'internal' visibility is valid but not in octokit 5 type definitions - visibility: process.env.CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY + visibility: env.CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY ? 'internal' : 'private', description: `Mirror of ${input.forkRepoOwner}/${input.forkRepoName}`, @@ -234,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]@${getCommitterEmailDomainWithWarning()}`, + `user.email=${privateInstallationId}+pma[bot]@${env.GITHUB_USER_EMAIL_DOMAIN}`, ], } const git = simpleGit(tempDir, options) @@ -250,7 +250,7 @@ export const createMirrorHandler = async ({ await git.addRemote('mirror', mirrorRemote) // Push commits in chunks so that large pushes don't encounter timeout issues - const chunkSize = Number(process.env.MIRROR_PUSH_CHUNK_SIZE ?? 1000) + const chunkSize = env.MIRROR_PUSH_CHUNK_SIZE const commitCount = Number( ( await git.raw(['rev-list', '--first-parent', '--count', branch]) @@ -276,9 +276,7 @@ export const createMirrorHandler = async ({ await git.push(['--no-verify', 'mirror', branch]) })() - const MIRROR_SYNC_TIMEOUT_MS = Number( - process.env.MIRROR_SYNC_TIMEOUT_MS ?? 30_000, - ) + const MIRROR_SYNC_TIMEOUT_MS = env.MIRROR_SYNC_TIMEOUT_MS // Sentinel returned by the timeout branch of Promise.race so the pending path // is distinguishable from a resolved git promise without throw/catch. @@ -411,7 +409,7 @@ export const listMirrorsHandler = async ({ return { mirrors: repos, - mirrorDeletionEnabled: process.env.DISABLE_MIRROR_DELETION !== 'true', + mirrorDeletionEnabled: !env.DISABLE_MIRROR_DELETION, } } catch (error) { reposApiLogger.info('Failed to fetch mirrors', { input, error }) @@ -506,7 +504,7 @@ export const deleteMirrorHandler = async ({ }: { input: DeleteMirrorSchema }) => { - if (process.env.DISABLE_MIRROR_DELETION === 'true') { + if (env.DISABLE_MIRROR_DELETION) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Mirror deletion is disabled', diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 391a5ba7..cb4b4d57 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,8 +1,13 @@ import { TRPCError } from '@trpc/server' import { getConfig } from '../bot/config' -import { personalOctokit } from '../bot/octokit' +import { personalOctokit } from '../bot/rest' import { logger } from '../utils/logger' -import { getGitHubServerHost, getGitHubServerProtocol } from './github-urls' +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 @@ -18,8 +23,9 @@ export const generateAuthUrl = ( ) => { const USER = 'x-access-token' const PASS = accessToken - const REPO = `${getGitHubServerHost()}/${owner}/${repo}` - return `${getGitHubServerProtocol()}//${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' }) @@ -42,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({ @@ -75,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/src/utils/github-urls.ts b/src/utils/github-urls.ts deleted file mode 100644 index cf1ede2e..00000000 --- a/src/utils/github-urls.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * 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 deleted file mode 100644 index d5120c0a..00000000 --- a/src/utils/server/committer-email.ts +++ /dev/null @@ -1,35 +0,0 @@ -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/bot/octokit.test.ts b/test/bot/octokit.test.ts index 9728898b..0113cc21 100644 --- a/test/bot/octokit.test.ts +++ b/test/bot/octokit.test.ts @@ -2,36 +2,20 @@ 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 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 @@ -44,15 +28,12 @@ describe('Octokit GitHub Enterprise configuration', () => { 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') + 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 @@ -66,14 +47,7 @@ describe('Octokit GitHub Enterprise configuration', () => { }) 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.stubEnv('GITHUB_API_URL', 'https://ghes.example.com/api/v3') vi.resetModules() const defaultsSpy = vi.fn().mockReturnValue('request-client') @@ -102,13 +76,8 @@ describe('Octokit GitHub Enterprise configuration', () => { }) 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.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) @@ -116,11 +85,6 @@ describe('Octokit GitHub Enterprise configuration', () => { 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 { @@ -132,6 +96,7 @@ describe('Octokit GitHub Enterprise configuration', () => { 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 = ( diff --git a/test/docs/docker-build-config.test.ts b/test/docs/docker-build-config.test.ts index a4b9d677..c02a3d83 100644 --- a/test/docs/docker-build-config.test.ts +++ b/test/docs/docker-build-config.test.ts @@ -4,35 +4,24 @@ 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', () => { +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).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', - ) + expect(dockerfile).not.toContain('NEXT_PUBLIC_GITHUB') }) - it('documents that the bundled Dockerfile already forwards the build args', () => { + 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('The bundled `Dockerfile` already forwards them') - expect(readme).not.toContain('update the `Dockerfile`') + expect(readme).toContain('GitHub configuration is read at runtime') + expect(readme).not.toContain('NEXT_PUBLIC_GITHUB_SERVER_URL') expect(developing).toContain( - 'The bundled `Dockerfile` already forwards these build args', + '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..4f737e9c --- /dev/null +++ b/test/env.test.ts @@ -0,0 +1,44 @@ +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('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/github-urls.test.ts b/test/github-urls.test.ts deleted file mode 100644 index ac6fd04c..00000000 --- a/test/github-urls.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -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/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 index 92e5cc05..295b118b 100644 --- a/test/utils/auth.test.ts +++ b/test/utils/auth.test.ts @@ -1,13 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { generateAuthUrl } from '../../src/utils/auth' describe('generateAuthUrl', () => { afterEach(() => { + vi.resetModules() vi.unstubAllEnvs() }) - it('uses the configured server scheme and host', () => { + 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')) @@ -18,7 +20,11 @@ describe('generateAuthUrl', () => { expect(authUrl.pathname).toBe('/owner/repo') }) - it('keeps the github.com default unchanged', () => { + 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:') diff --git a/test/utils/server/committer-email.test.ts b/test/utils/server/committer-email.test.ts deleted file mode 100644 index 78c1356c..00000000 --- a/test/utils/server/committer-email.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -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() - }) -}) 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}', From 4362146602b17535106aa3ee6653e0fd64dbe152 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:51:49 +0000 Subject: [PATCH 11/12] fix: preserve runtime feature flags --- src/app/api/auth/lib/nextauth-options.ts | 2 +- src/bot/octokit.ts | 2 ++ src/server/repos/controller.ts | 12 +++++++----- src/utils/auth.ts | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app/api/auth/lib/nextauth-options.ts b/src/app/api/auth/lib/nextauth-options.ts index df42e454..efabd65f 100644 --- a/src/app/api/auth/lib/nextauth-options.ts +++ b/src/app/api/auth/lib/nextauth-options.ts @@ -1,4 +1,4 @@ -import { personalOctokit } from 'bot/rest' +import { personalOctokit } from 'bot/octokit' import { AuthOptions, Profile } from 'next-auth' import { JWT } from 'next-auth/jwt' import GitHub from 'next-auth/providers/github' diff --git a/src/bot/octokit.ts b/src/bot/octokit.ts index 57b0d7d7..95e86ed1 100644 --- a/src/bot/octokit.ts +++ b/src/bot/octokit.ts @@ -5,6 +5,8 @@ import { logger } from '../utils/logger' import { Octokit } from './rest' import { env } from '../../env.mjs' +export { personalOctokit } from './rest' + const appOctokitLogger = logger.getSubLogger({ name: 'app-octokit' }) const privateKey = !env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----') diff --git a/src/server/repos/controller.ts b/src/server/repos/controller.ts index 0dd2e268..46de6d2c 100644 --- a/src/server/repos/controller.ts +++ b/src/server/repos/controller.ts @@ -206,7 +206,7 @@ export const createMirrorHandler = async ({ name: input.newRepoName, org: privateOrg, // @ts-expect-error 'internal' visibility is valid but not in octokit 5 type definitions - visibility: env.CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY + visibility: process.env.CREATE_MIRRORS_WITH_INTERNAL_VISIBILITY ? 'internal' : 'private', description: `Mirror of ${input.forkRepoOwner}/${input.forkRepoName}`, @@ -250,7 +250,7 @@ export const createMirrorHandler = async ({ await git.addRemote('mirror', mirrorRemote) // Push commits in chunks so that large pushes don't encounter timeout issues - const chunkSize = env.MIRROR_PUSH_CHUNK_SIZE + const chunkSize = Number(process.env.MIRROR_PUSH_CHUNK_SIZE ?? 1000) const commitCount = Number( ( await git.raw(['rev-list', '--first-parent', '--count', branch]) @@ -276,7 +276,9 @@ export const createMirrorHandler = async ({ await git.push(['--no-verify', 'mirror', branch]) })() - const MIRROR_SYNC_TIMEOUT_MS = env.MIRROR_SYNC_TIMEOUT_MS + const MIRROR_SYNC_TIMEOUT_MS = Number( + process.env.MIRROR_SYNC_TIMEOUT_MS ?? 30_000, + ) // Sentinel returned by the timeout branch of Promise.race so the pending path // is distinguishable from a resolved git promise without throw/catch. @@ -409,7 +411,7 @@ export const listMirrorsHandler = async ({ return { mirrors: repos, - mirrorDeletionEnabled: !env.DISABLE_MIRROR_DELETION, + mirrorDeletionEnabled: process.env.DISABLE_MIRROR_DELETION !== 'true', } } catch (error) { reposApiLogger.info('Failed to fetch mirrors', { input, error }) @@ -504,7 +506,7 @@ export const deleteMirrorHandler = async ({ }: { input: DeleteMirrorSchema }) => { - if (env.DISABLE_MIRROR_DELETION) { + if (process.env.DISABLE_MIRROR_DELETION === 'true') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Mirror deletion is disabled', diff --git a/src/utils/auth.ts b/src/utils/auth.ts index cb4b4d57..8a27a807 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server' import { getConfig } from '../bot/config' -import { personalOctokit } from '../bot/rest' +import { personalOctokit } from '../bot/octokit' import { logger } from '../utils/logger' import { env } from '../../env.mjs' From b4e93577aa2408514212b6a4dd1c8fcc9bd22dc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:55:38 +0000 Subject: [PATCH 12/12] fix: preserve defaults during validation-free builds --- env.mjs | 24 ++++++++++++++++-------- src/bot/octokit.ts | 12 +++++++----- test/env.test.ts | 15 +++++++++++++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/env.mjs b/env.mjs index ed40a3f6..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. @@ -26,25 +31,25 @@ export const env = createEnv({ .string() .url() .optional() - .default('https://github.com') + .default(DEFAULT_GITHUB_SERVER_URL) .transform((value) => value.replace(/\/+$/, '')), GITHUB_API_URL: z .string() .url() .optional() - .default('https://api.github.com') + .default(DEFAULT_GITHUB_API_URL) .transform((value) => value.replace(/\/+$/, '')), GITHUB_GRAPHQL_URL: z .string() .url() .optional() - .default('https://api.github.com/graphql') + .default(DEFAULT_GITHUB_GRAPHQL_URL) .transform((value) => value.replace(/\/+$/, '')), GITHUB_USER_EMAIL_DOMAIN: z .string() .min(1) .optional() - .default('users.noreply.github.com'), + .default(DEFAULT_GITHUB_USER_EMAIL_DOMAIN), // Custom validation for a comma separated list of strings // ex: ajhenry,github,ahpook ALLOWED_HANDLES: z @@ -146,10 +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, - 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, + 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/src/bot/octokit.ts b/src/bot/octokit.ts index 95e86ed1..670fb258 100644 --- a/src/bot/octokit.ts +++ b/src/bot/octokit.ts @@ -9,11 +9,13 @@ export { personalOctokit } from './rest' const appOctokitLogger = logger.getSubLogger({ name: 'app-octokit' }) -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 privateKey = + 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(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') ?? '') /** * Generates an app access token for the app or an installation (if installationId is provided) diff --git a/test/env.test.ts b/test/env.test.ts index 4f737e9c..d4340b2c 100644 --- a/test/env.test.ts +++ b/test/env.test.ts @@ -27,6 +27,21 @@ describe('GitHub environment configuration', () => { 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/')