Skip to content

GitHub plugin cannot target a GitHub Enterprise Server host #2313

Description

@kennyg

The workflow

Use the GitHub plugin against a GitHub Enterprise Server host instead of github.com. An org whose repos live on ghe.example.com wants the same issue/PR panel, mention providers, and thread-spawning the plugin gives github.com users.

What happens today

The plugin cannot reach a GHE host at all. Three independent pins, at 7e10ea4b3742f3d14bde086c2963118238fec3ab:

  1. Auth is pinned. GH_HOST = "github.com" is passed explicitly as --hostname to both auth probes — gh auth status and gh auth token. A gh logged into only a GHE host fails both, so the plugin latches needs_configuration and nothing downstream runs.

  2. Repo discovery is pinned. parseGithubRemote matches github.com[:/] literally, so a GHE remote yields no repos.

  3. The manual escape hatch is closed. extraRepos entries are filtered by isRepoName at the discovery loop, a two-segment pattern that rejects the host/owner/repo form gh -R accepts for enterprise hosts.

Repro: plugins/github/server.ghe.test.ts (inlined at the end of this issue), built on the fake-gh harness already in server.auth-latch.test.ts — a gh 2.96 stub on PATH whose only credentials are for ghe.acme.com, driving the real plugin through createFakePluginHost.

pnpm exec turbo run test --filter=bb-plugin-github

 ✓ cannot authenticate against a GHE host: the probe is pinned to github.com  251ms
 × keeps a GHE repo from extraRepos instead of silently dropping it           303ms
   AssertionError: expected [] to deeply equal [ 'ghe.acme.com/platform/tools' ]

 Tests  1 failed | 26 passed (27)

The first test passes on main and characterizes the bug: with valid GHE credentials present, ghState is needs_configuration, every auth call carries --hostname github.com, and ghe.acme.com never appears in the gh call log at all. The second asserts the desired extraRepos behavior and fails on main.

Scope of what this proves: the plugin refuses a correctly-configured GHE gh and never contacts the host. It does not prove the three pins are the only github.com assumptions — nothing has executed past the auth gate against a real GHE API, so there may be more behind it. Tested at 7e10ea4b3742f3d14bde086c2963118238fec3ab, macOS 15.6, bb 0.39.0 from source.

What you would expect

A configurable GitHub host — a plugin setting defaulting to github.com — threaded through all three pins: both --hostname flags, the remote pattern, and the accepted repo-name shape.

Context and alternatives

Ruled out — setting GH_HOST in the server environment is not a workaround. run() passes no env override, so gh does inherit it and gh api / gh pr list would follow the enterprise host. But the explicit --hostname github.com on the auth probes beats GH_HOST, and repo discovery still matches nothing. The data plane would work; the auth and discovery planes still fail.

Ruled out — not a duplicate. Searched open and closed issues for "GitHub Enterprise", "GHE", "GH_HOST", and "enterprise"; no matches.

Alternative considered: github is in OFFICIAL_PLUGINS, not BUILTIN_PLUGINS (builtin-registry.ts#L158-L160), so it is store-only and an org can ship a private fork under a different plugin id instead. That works, but every GHE org carries the same four-line divergence forever, which is why this seems worth having upstream.

Scope: four edits in one file — the constant becomes a setting, it feeds both --hostname flags, the remote pattern is built from it, and isRepoName widens to accept the three-segment form. Happy to send the PR if the approach sounds right.

Repro test

Drop this at plugins/github/server.ghe.test.ts and run pnpm exec turbo run test --filter=bb-plugin-github.

plugins/github/server.ghe.test.ts
// Repro: the github plugin is pinned to github.com and cannot be pointed at a
// GitHub Enterprise Server host.
//
// A fake `gh` on PATH holds credentials for `ghe.acme.com` only — the exact
// state of a developer at an org that runs GHE. github.com is not configured.
import {
  chmodSync,
  existsSync,
  mkdtempSync,
  readFileSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import plugin from "./server";

const GHE_HOST = "ghe.acme.com";

let binDir: string;
let callLog: string;
const originalPath = process.env.PATH;

function ghCalls(): string[] {
  if (!existsSync(callLog)) return [];
  return readFileSync(callLog, "utf8")
    .trim()
    .split("\n")
    .filter((line: string) => line.length > 0);
}

beforeEach(() => {
  binDir = mkdtempSync(join(tmpdir(), "bb-ghe-"));
  callLog = join(binDir, "gh-calls.log");
  // Mimics real `gh` 2.96 logged into GHE only: any github.com-scoped auth
  // command fails with gh's verbatim wording, the GHE host succeeds.
  writeFileSync(
    join(binDir, "gh"),
    `#!/usr/bin/env bash
echo "$*" >> "${callLog}"
case "$1 $2" in
  "--version ") echo "gh version 2.96.0 (fake)"; exit 0;;
  "auth token")
    case " $* " in
      *" ${GHE_HOST} "*) echo "gho_fake_ghe_token"; exit 0;;
      *) echo "no oauth token found for github.com" >&2; exit 1;;
    esac;;
  "auth status")
    case " $* " in
      *" ${GHE_HOST} "*)
        echo "${GHE_HOST}"
        echo "  ✓ Logged in to ${GHE_HOST} account someone (keyring)"; exit 0;;
      *)
        echo "You are not logged into any accounts on github.com" >&2; exit 1;;
    esac;;
  *) echo "[]"; exit 0;;
esac
`,
  );
  chmodSync(join(binDir, "gh"), 0o755);
  process.env.PATH = `${binDir}:${originalPath ?? ""}`;
});

afterEach(() => {
  process.env.PATH = originalPath;
  rmSync(binDir, { recursive: true, force: true });
});

describe("github plugin on GitHub Enterprise", () => {
  // Characterization of today's behavior: passes on main, and should be
  // inverted by any fix that makes the host configurable.
  it("cannot authenticate against a GHE host: the probe is pinned to github.com", async () => {
    const { bb, harness } = createFakePluginHost({ pluginId: "github" });
    await plugin(bb);
    const status = (await harness.callRpc("status")) as {
      ghOk: boolean;
      ghState: string;
    };

    // gh is installed and holds valid credentials — for GHE. The plugin
    // reports itself unconfigured anyway.
    expect(status.ghOk).toBe(false);
    expect(status.ghState).toBe("needs_configuration");

    // Root cause: every auth call names github.com explicitly, so `GH_HOST`
    // in the server environment cannot redirect it. The GHE host is never
    // asked about at all.
    const authCalls = ghCalls().filter((line) => line.startsWith("auth "));
    expect(authCalls.length).toBeGreaterThan(0);
    for (const call of authCalls) {
      expect(call).toContain("--hostname github.com");
    }
    expect(ghCalls().join("\n")).not.toContain(GHE_HOST);
  });

  it("keeps a GHE repo from extraRepos instead of silently dropping it", async () => {
    const { bb, harness } = createFakePluginHost({
      pluginId: "github",
      settings: { extraRepos: `${GHE_HOST}/platform/tools` },
    });
    await plugin(bb);
    const status = (await harness.callRpc("status")) as {
      repos: { repo: string }[];
    };

    // Desired behavior — FAILS on main. `gh -R host/owner/repo` is the
    // documented enterprise form, but isRepoName() accepts exactly two
    // segments, so today the entry is discarded silently: no error, no
    // warning, `repos` just comes back empty and the panel stays blank.
    expect(status.repos.map((entry) => entry.repo)).toEqual([
      `${GHE_HOST}/platform/tools`,
    ]);
  });
});

AGENT GENERATED

Metadata

Metadata

Assignees

No one assigned

    Labels

    githubBuilt-in plugin: github

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions