From 00e5917b3e7e8b5896050a3d683f07353170bf24 Mon Sep 17 00:00:00 2001 From: mbianchidev Date: Thu, 28 May 2026 17:44:45 +0200 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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