diff --git a/.changeset/github-ghes-graphql.md b/.changeset/github-ghes-graphql.md new file mode 100644 index 00000000..d92d5563 --- /dev/null +++ b/.changeset/github-ghes-graphql.md @@ -0,0 +1,5 @@ +--- +"@gh-symphony/cli": patch +--- + +Propagate configured GitHub Enterprise GraphQL endpoints into worker environments, validate `doctor` against the resolved GHES host, and surface endpoint diagnostics for #388. diff --git a/README.md b/README.md index 0341c573..50728c3c 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,35 @@ export GITHUB_GRAPHQL_TOKEN=ghp_your_classic_token `GITHUB_GRAPHQL_TOKEN` takes priority over `gh` CLI. Interactive `gh-symphony workflow init` and `gh-symphony setup` will use the env token first when it is present and valid, and only fall back to `gh` when no usable env token is available. `gh-symphony doctor` also reports the resolved auth source as `env` or `gh`. +### GitHub Enterprise Server + +For GitHub Enterprise Server, configure the GraphQL endpoint in `WORKFLOW.md` +so the orchestrator, doctor checks, and dispatched worker all use the same +host: + +```yaml +tracker: + kind: github-project + endpoint: https://github.example/api/graphql + project_id: PVT_xxx +``` + +Then initialize and validate the repository runtime: + +```bash +export GITHUB_GRAPHQL_TOKEN=ghp_your_enterprise_token +gh-symphony repo init +gh-symphony doctor +gh-symphony doctor --smoke --issue owner/repo#123 +``` + +`GITHUB_GRAPHQL_API_URL` remains an optional process-level override. If both +`tracker.endpoint` and `GITHUB_GRAPHQL_API_URL` are set, keep them identical; +`doctor` reports the resolved endpoint and warns when they disagree. During +dispatch, the GitHub tracker injects the configured `tracker.endpoint` into the +worker as `GITHUB_GRAPHQL_API_URL`, so worker-side `github_graphql` calls do not +fall back to `https://api.github.com/graphql`. + ## WORKFLOW.md `WORKFLOW.md` contains YAML front matter for lifecycle configuration and a Markdown body used as the agent prompt template. diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts index badd1b0e..4db64215 100644 --- a/packages/cli/src/commands/doctor.test.ts +++ b/packages/cli/src/commands/doctor.test.ts @@ -178,6 +178,7 @@ function authDependencies( } const originalGraphQlToken = process.env.GITHUB_GRAPHQL_TOKEN; +const originalGraphQlApiUrl = process.env.GITHUB_GRAPHQL_API_URL; const originalLinearApiKey = process.env.LINEAR_API_KEY; const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY; const originalCredentialBrokerUrl = process.env.AGENT_CREDENTIAL_BROKER_URL; @@ -401,6 +402,11 @@ afterEach(() => { } else { process.env.GITHUB_GRAPHQL_TOKEN = originalGraphQlToken; } + if (originalGraphQlApiUrl === undefined) { + delete process.env.GITHUB_GRAPHQL_API_URL; + } else { + process.env.GITHUB_GRAPHQL_API_URL = originalGraphQlApiUrl; + } if (originalLinearApiKey === undefined) { delete process.env.LINEAR_API_KEY; } else { @@ -426,6 +432,7 @@ afterEach(() => { beforeEach(() => { delete process.env.GITHUB_GRAPHQL_TOKEN; + delete process.env.GITHUB_GRAPHQL_API_URL; delete process.env.LINEAR_API_KEY; delete process.env.ANTHROPIC_API_KEY; }); @@ -482,6 +489,151 @@ describe("runDoctorDiagnostics", () => { }); }); + it("validates GitHub auth against the configured GraphQL endpoint", async () => { + const configDir = await mkdtemp(join(tmpdir(), "doctor-config-")); + const workspaceDir = join(configDir, "workspaces"); + await prepareDoctorPaths(configDir, workspaceDir); + const { repoDir, pathEnv } = await createWorkflowFixture(); + const validateGitHubToken = vi.fn(async (_token: string, source) => ({ + source, + token: "ghp_test", + login: "tester", + scopes: ["repo", "read:org", "project"], + })); + const checkGhAuthenticated = vi.fn(() => ({ + authenticated: true, + login: "tester", + })); + const checkGhScopes = vi.fn(() => ({ + valid: true, + missing: [], + scopes: ["repo", "read:org", "project"], + })); + const getGhToken = vi.fn(() => "ghp_test"); + const projectConfig = createProjectConfig(workspaceDir); + projectConfig.tracker.apiUrl = "https://github.example/api/graphql"; + + const report = await withCwd(repoDir, () => + runDoctorDiagnostics(baseOptions(configDir), [], { + ...authDependencies({ + checkGhAuthenticated: checkGhAuthenticated as never, + checkGhScopes: checkGhScopes as never, + getGhToken: getGhToken as never, + validateGitHubToken: validateGitHubToken as never, + }), + inspectManagedProjectSelection: async () => ({ + kind: "resolved", + projectId: "tenant-a", + projectConfig, + }), + getProjectDetail: (async () => createProjectDetail() as never) as never, + execFileSync: (() => "git version 2.43.0") as never, + pathEnv, + }) + ); + + expect(validateGitHubToken).toHaveBeenCalledWith("ghp_test", "gh", { + apiUrl: "https://github.example/api/graphql", + }); + expect(checkGhAuthenticated).toHaveBeenCalledWith({ + hostname: "github.example", + }); + expect(checkGhScopes).toHaveBeenCalledWith({ + hostname: "github.example", + }); + expect(getGhToken).toHaveBeenCalledWith({ + allowEnv: false, + hostname: "github.example", + }); + expect( + report.checks.find((check) => check.id === "github_graphql_endpoint") + ).toMatchObject({ + status: "pass", + summary: + "Resolved GitHub GraphQL endpoint: https://github.example/api/graphql.", + details: expect.objectContaining({ + resolvedEndpoint: "https://github.example/api/graphql", + source: "tracker", + }), + }); + }); + + it("does not warn for equivalent GitHub GraphQL endpoint spellings", async () => { + const configDir = await mkdtemp(join(tmpdir(), "doctor-config-")); + const workspaceDir = join(configDir, "workspaces"); + await prepareDoctorPaths(configDir, workspaceDir); + const { repoDir, pathEnv } = await createWorkflowFixture(); + const projectConfig = createProjectConfig(workspaceDir); + projectConfig.tracker.apiUrl = "https://GitHub.Example/api/graphql/"; + process.env.GITHUB_GRAPHQL_API_URL = "https://github.example/api/graphql"; + + const report = await withCwd(repoDir, () => + runDoctorDiagnostics(baseOptions(configDir), [], { + ...authDependencies(), + inspectManagedProjectSelection: async () => ({ + kind: "resolved", + projectId: "tenant-a", + projectConfig, + }), + getProjectDetail: (async () => createProjectDetail() as never) as never, + execFileSync: (() => "git version 2.43.0") as never, + pathEnv, + }) + ); + + expect( + report.checks.find((check) => check.id === "github_graphql_endpoint") + ).toMatchObject({ + status: "pass", + summary: + "Resolved GitHub GraphQL endpoint: https://github.example/api/graphql.", + details: expect.objectContaining({ + resolvedEndpoint: "https://github.example/api/graphql", + envApiUrl: "https://github.example/api/graphql", + trackerApiUrl: "https://github.example/api/graphql", + }), + }); + }); + + it("warns when configured and environment GitHub GraphQL endpoints differ", async () => { + const configDir = await mkdtemp(join(tmpdir(), "doctor-config-")); + const workspaceDir = join(configDir, "workspaces"); + await prepareDoctorPaths(configDir, workspaceDir); + const { repoDir, pathEnv } = await createWorkflowFixture(); + const projectConfig = createProjectConfig(workspaceDir); + projectConfig.tracker.apiUrl = "https://github.example/api/graphql"; + process.env.GITHUB_GRAPHQL_API_URL = + "https://other-ghes.example/api/graphql"; + + const report = await withCwd(repoDir, () => + runDoctorDiagnostics(baseOptions(configDir), [], { + ...authDependencies(), + inspectManagedProjectSelection: async () => ({ + kind: "resolved", + projectId: "tenant-a", + projectConfig, + }), + getProjectDetail: (async () => createProjectDetail() as never) as never, + execFileSync: (() => "git version 2.43.0") as never, + pathEnv, + }) + ); + + expect(report.ok).toBe(true); + expect( + report.checks.find((check) => check.id === "github_graphql_endpoint") + ).toMatchObject({ + status: "warn", + summary: expect.stringContaining("GITHUB_GRAPHQL_API_URL"), + remediation: expect.stringContaining("aligned"), + details: expect.objectContaining({ + resolvedEndpoint: "https://github.example/api/graphql", + envApiUrl: "https://other-ghes.example/api/graphql", + trackerApiUrl: "https://github.example/api/graphql", + }), + }); + }); + it("adds Claude readiness checks for Claude runtime commands", async () => { const configDir = await mkdtemp(join(tmpdir(), "doctor-config-")); const workspaceDir = join(configDir, "workspaces"); @@ -965,13 +1117,19 @@ describe("runDoctorDiagnostics", () => { }) ); expect(report.ok).toBe(true); - expect(getGhToken).toHaveBeenCalledWith({ allowEnv: false }); + expect(getGhToken).toHaveBeenCalledWith({ + allowEnv: false, + hostname: "github.com", + }); expect(validateGitHubToken).toHaveBeenNthCalledWith( 1, "bad-env-token", - "env" + "env", + { apiUrl: "https://api.github.com/graphql" } ); - expect(validateGitHubToken).toHaveBeenNthCalledWith(2, "gh-token", "gh"); + expect(validateGitHubToken).toHaveBeenNthCalledWith(2, "gh-token", "gh", { + apiUrl: "https://api.github.com/graphql", + }); expect( report.checks.find((check) => check.id === "gh_authentication") ).toMatchObject({ diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index d47ab7ef..f70605af 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -20,6 +20,7 @@ import { } from "@gh-symphony/tracker-github"; import { createClient, + DEFAULT_GITHUB_GRAPHQL_API_URL, findLinkedRepository, getProjectDetail, GitHubApiError, @@ -66,6 +67,7 @@ type DoctorCheckId = | "gh_authentication" | "gh_scopes" | "managed_project" + | "github_graphql_endpoint" | "github_project_resolution" | "linear_tracker_resolution" | "config_directory" @@ -365,6 +367,93 @@ function formatAuthSource(source: GitHubAuthSource): string { return source === "env" ? "GITHUB_GRAPHQL_TOKEN" : "gh CLI"; } +function normalizeGitHubGraphqlEndpoint(value?: string): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + + try { + const url = new URL(trimmed); + url.hostname = url.hostname.toLowerCase(); + url.pathname = url.pathname.replace(/\/+$/, "") || "/"; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); + } catch { + return trimmed.replace(/\/+$/, ""); + } +} + +function deriveGhHostnameFromGraphqlEndpoint( + endpoint: string +): string | undefined { + try { + const hostname = new URL(endpoint).hostname.toLowerCase(); + return hostname === "api.github.com" ? "github.com" : hostname; + } catch { + return undefined; + } +} + +function resolveGitHubGraphqlEndpoint(input: { + trackerApiUrl?: string; + envApiUrl?: string; +}): { + resolvedEndpoint: string; + source: "tracker" | "env" | "default"; + trackerApiUrl: string | null; + envApiUrl: string | null; + mismatch: boolean; + ghHostname: string | undefined; +} { + const trackerApiUrl = normalizeGitHubGraphqlEndpoint(input.trackerApiUrl); + const envApiUrl = normalizeGitHubGraphqlEndpoint(input.envApiUrl); + const resolvedEndpoint = + trackerApiUrl ?? envApiUrl ?? DEFAULT_GITHUB_GRAPHQL_API_URL; + + return { + resolvedEndpoint, + source: trackerApiUrl ? "tracker" : envApiUrl ? "env" : "default", + trackerApiUrl, + envApiUrl, + mismatch: + trackerApiUrl !== null && + envApiUrl !== null && + trackerApiUrl !== envApiUrl, + ghHostname: deriveGhHostnameFromGraphqlEndpoint(resolvedEndpoint), + }; +} + +function buildGitHubGraphqlEndpointCheck( + input: ReturnType +): DoctorCheckResult { + const details = { + resolvedEndpoint: input.resolvedEndpoint, + source: input.source, + trackerApiUrl: input.trackerApiUrl, + envApiUrl: input.envApiUrl, + ghHostname: input.ghHostname, + }; + + if (input.mismatch) { + return warnCheck( + "github_graphql_endpoint", + "GitHub GraphQL endpoint", + `Resolved GitHub GraphQL endpoint is ${input.resolvedEndpoint}, but GITHUB_GRAPHQL_API_URL is ${input.envApiUrl}.`, + "Keep WORKFLOW.md tracker.endpoint/tracker.apiUrl and GITHUB_GRAPHQL_API_URL aligned, or unset GITHUB_GRAPHQL_API_URL so the workflow endpoint is authoritative.", + details + ); + } + + return passCheck( + "github_graphql_endpoint", + "GitHub GraphQL endpoint", + `Resolved GitHub GraphQL endpoint: ${input.resolvedEndpoint}.`, + details + ); +} + function remediationStep( id: string, checkId: DoctorCheckId, @@ -697,7 +786,10 @@ async function checkLinearTrackerResolution(input: { deps: DoctorDependencies; }): Promise { const tracker = input.projectConfig.projectConfig.tracker; - const projectSlug = readStringSetting(tracker.settings, "projectSlug")?.trim(); + const projectSlug = readStringSetting( + tracker.settings, + "projectSlug" + )?.trim(); const activeStates = readLinearActiveStates(tracker.settings); const pickupLabels = readLinearPickupLabels(tracker.settings); @@ -885,7 +977,9 @@ async function fetchLinearProjectBySlug(input: { }); if (!response.ok) { - throw new Error(`Linear GraphQL request failed with HTTP ${response.status}.`); + throw new Error( + `Linear GraphQL request failed with HTTP ${response.status}.` + ); } const payload = (await response.json()) as { @@ -1561,6 +1655,9 @@ export async function runDoctorDiagnostics( > | null = null; let resolvedGithubProjectDetail: ProjectDetail | null = null; let resolvedGithubProjectBindingId: string | null = null; + let githubGraphqlEndpoint: ReturnType< + typeof resolveGitHubGraphqlEndpoint + > | null = null; const envToken = deps.getEnvGitHubToken(); const currentNodeVersion = deps.processVersion; @@ -1645,17 +1742,65 @@ export async function runDoctorDiagnostics( ); } + resolvedProjectConfig = await deps.inspectManagedProjectSelection({ + configDir: options.configDir, + requestedProjectId: parsedArgs.projectId, + }); + if (resolvedProjectConfig.kind === "resolved") { + resolvedProjectId = resolvedProjectConfig.projectId; + checks.push( + passCheck( + "managed_project", + "Managed project selection", + `Resolved managed project "${resolvedProjectConfig.projectId}".`, + { + projectId: resolvedProjectConfig.projectId, + workspaceDir: resolvedProjectConfig.projectConfig.workspaceDir, + } + ) + ); + + if (resolvedProjectConfig.projectConfig.tracker.adapter !== "linear") { + githubGraphqlEndpoint = resolveGitHubGraphqlEndpoint({ + trackerApiUrl: resolvedProjectConfig.projectConfig.tracker.apiUrl, + envApiUrl: process.env.GITHUB_GRAPHQL_API_URL, + }); + checks.push(buildGitHubGraphqlEndpointCheck(githubGraphqlEndpoint)); + } + } else { + checks.push( + failCheck( + "managed_project", + "Managed project selection", + resolvedProjectConfig.message, + "Run 'gh-symphony repo init' from the target repository.", + { + reason: resolvedProjectConfig.kind, + ...(resolvedProjectConfig.projectId + ? { projectId: resolvedProjectConfig.projectId } + : {}), + } + ) + ); + } + + const ghHostname = githubGraphqlEndpoint?.ghHostname; const ghAuth = ghInstalled - ? deps.checkGhAuthenticated() + ? deps.checkGhAuthenticated({ hostname: ghHostname }) : { authenticated: false }; const ghScopes = ghInstalled && ghAuth.authenticated - ? deps.checkGhScopes() + ? deps.checkGhScopes({ hostname: ghHostname }) : { valid: false, missing: [...REQUIRED_GH_SCOPES], scopes: [] }; + const ghHostnameArg = ghHostname ? ` --hostname ${ghHostname}` : ""; + const ghLoginCommand = `gh auth login --scopes ${REQUIRED_GH_SCOPES.join(",")}${ghHostnameArg}`; + const ghRefreshCommand = `gh auth refresh --scopes ${REQUIRED_GH_SCOPES.join(",")}${ghHostnameArg}`; if (envToken) { try { - auth = await deps.validateGitHubToken(envToken, "env"); + auth = await deps.validateGitHubToken(envToken, "env", { + apiUrl: githubGraphqlEndpoint?.resolvedEndpoint, + }); } catch (error) { envTokenError = error instanceof Error @@ -1666,8 +1811,13 @@ export async function runDoctorDiagnostics( if (!auth && ghInstalled && ghAuth.authenticated && ghScopes.valid) { try { - const ghToken = deps.getGhToken({ allowEnv: false }); - auth = await deps.validateGitHubToken(ghToken, "gh"); + const ghToken = deps.getGhToken({ + allowEnv: false, + hostname: ghHostname, + }); + auth = await deps.validateGitHubToken(ghToken, "gh", { + apiUrl: githubGraphqlEndpoint?.resolvedEndpoint, + }); } catch (error) { tokenError = error instanceof Error @@ -1703,7 +1853,7 @@ export async function runDoctorDiagnostics( "gh_authentication", "GitHub authentication", "gh auth status failed or no GitHub login is configured.", - `Run 'gh auth login --scopes ${REQUIRED_GH_SCOPES.join(",")}' and re-run the doctor command.` + `Run '${ghLoginCommand}' and re-run the doctor command.` ) ); } @@ -1737,46 +1887,12 @@ export async function runDoctorDiagnostics( "gh_scopes", "GitHub token scopes", `Missing required scopes: ${missingScopes.join(", ")}.`, - `Run 'gh auth refresh --scopes ${REQUIRED_GH_SCOPES.join(",")}' and confirm 'gh auth status' shows the updated scopes.`, + `Run '${ghRefreshCommand}' and confirm 'gh auth status${ghHostnameArg}' shows the updated scopes.`, { missing: missingScopes, scopes: ghScopes.scopes } ) ); } - resolvedProjectConfig = await deps.inspectManagedProjectSelection({ - configDir: options.configDir, - requestedProjectId: parsedArgs.projectId, - }); - if (resolvedProjectConfig.kind === "resolved") { - resolvedProjectId = resolvedProjectConfig.projectId; - checks.push( - passCheck( - "managed_project", - "Managed project selection", - `Resolved managed project "${resolvedProjectConfig.projectId}".`, - { - projectId: resolvedProjectConfig.projectId, - workspaceDir: resolvedProjectConfig.projectConfig.workspaceDir, - } - ) - ); - } else { - checks.push( - failCheck( - "managed_project", - "Managed project selection", - resolvedProjectConfig.message, - "Run 'gh-symphony repo init' from the target repository.", - { - reason: resolvedProjectConfig.kind, - ...(resolvedProjectConfig.projectId - ? { projectId: resolvedProjectConfig.projectId } - : {}), - } - ) - ); - } - if ( resolvedProjectConfig.kind === "resolved" && resolvedProjectConfig.projectConfig.tracker.adapter === "linear" @@ -1839,7 +1955,9 @@ export async function runDoctorDiagnostics( throw new Error("Managed project is not bound to a GitHub Project."); } resolvedGithubProjectBindingId = bindingId; - const client = deps.createClient(auth.token); + const client = deps.createClient(auth.token, { + apiUrl: githubGraphqlEndpoint?.resolvedEndpoint, + }); const detail = await deps.getProjectDetail(client, bindingId); resolvedGithubProjectDetail = detail; checks.push( diff --git a/packages/cli/src/github/client.test.ts b/packages/cli/src/github/client.test.ts index 0bd5950b..cf950a42 100644 --- a/packages/cli/src/github/client.test.ts +++ b/packages/cli/src/github/client.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { createClient, discoverUserProjects } from "./client.js"; +import { + createClient, + deriveGitHubRestApiUrl, + discoverUserProjects, +} from "./client.js"; function graphqlResponse(data: unknown): Response { return new Response(JSON.stringify({ data }), { @@ -8,6 +12,20 @@ function graphqlResponse(data: unknown): Response { }); } +describe("deriveGitHubRestApiUrl", () => { + it("maps public GitHub GraphQL URLs to the public REST API", () => { + expect(deriveGitHubRestApiUrl("https://api.github.com/graphql")).toBe( + "https://api.github.com" + ); + }); + + it("maps GHES /api/graphql endpoints to /api/v3", () => { + expect(deriveGitHubRestApiUrl("https://github.example/api/graphql/")).toBe( + "https://github.example/api/v3" + ); + }); +}); + describe("discoverUserProjects", () => { it("paginates viewer projects", async () => { const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { @@ -199,12 +217,18 @@ describe("discoverUserProjects", () => { reason: null, requests: 6, }); - expect(result.projects.map((project) => `${project.owner.login}:${project.title}`)) - .toEqual(["acme:Acme One", "acme:Acme Two", "beta:Beta One"]); + expect( + result.projects.map( + (project) => `${project.owner.login}:${project.title}` + ) + ).toEqual(["acme:Acme One", "acme:Acme Two", "beta:Beta One"]); }); it("returns partial results when the request budget is exhausted", async () => { - const orgLogins = Array.from({ length: 40 }, (_, index) => `org-${index + 1}`); + const orgLogins = Array.from( + { length: 40 }, + (_, index) => `org-${index + 1}` + ); const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => { const body = JSON.parse(String(init?.body)) as { query: string; diff --git a/packages/cli/src/github/client.ts b/packages/cli/src/github/client.ts index 5a2b621f..568ac2a7 100644 --- a/packages/cli/src/github/client.ts +++ b/packages/cli/src/github/client.ts @@ -1,4 +1,4 @@ -const DEFAULT_API_URL = "https://api.github.com/graphql"; +export const DEFAULT_GITHUB_GRAPHQL_API_URL = "https://api.github.com/graphql"; const REST_API_URL = "https://api.github.com"; export type GitHubClient = { @@ -139,18 +139,43 @@ export function createClient( ): GitHubClient { return { token, - apiUrl: options?.apiUrl ?? DEFAULT_API_URL, + apiUrl: options?.apiUrl ?? DEFAULT_GITHUB_GRAPHQL_API_URL, fetchImpl: options?.fetchImpl ?? fetch, }; } +export function deriveGitHubRestApiUrl(graphqlApiUrl: string): string { + try { + const url = new URL(graphqlApiUrl); + const normalizedPath = url.pathname.replace(/\/+$/, ""); + if (url.hostname.toLowerCase() === "api.github.com") { + return REST_API_URL; + } + if (normalizedPath === "/api/graphql") { + url.pathname = "/api/v3"; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); + } + if (normalizedPath.endsWith("/graphql")) { + url.pathname = normalizedPath.slice(0, -"/graphql".length) || "/"; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); + } + } catch { + // Fall back to the public GitHub REST API for malformed custom URLs. + } + + return REST_API_URL; +} + export async function getRepositoryMetadata( client: GitHubClient, owner: string, name: string ): Promise { - const restUrl = client.apiUrl.replace("/graphql", ""); - const baseUrl = restUrl === client.apiUrl ? REST_API_URL : restUrl; + const baseUrl = deriveGitHubRestApiUrl(client.apiUrl); const repoPath = `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`; let response: Response; @@ -254,8 +279,7 @@ export async function listRepositoryLabels( owner: string, name: string ): Promise { - const restUrl = client.apiUrl.replace("/graphql", ""); - const baseUrl = restUrl === client.apiUrl ? REST_API_URL : restUrl; + const baseUrl = deriveGitHubRestApiUrl(client.apiUrl); const labels: RepositoryLabel[] = []; let page = 1; @@ -313,8 +337,7 @@ export async function listRepositoryLabels( export async function validateToken(client: GitHubClient): Promise { // Use REST to get X-OAuth-Scopes header (GraphQL doesn't expose scopes) - const restUrl = client.apiUrl.replace("/graphql", ""); - const baseUrl = restUrl === client.apiUrl ? REST_API_URL : restUrl; + const baseUrl = deriveGitHubRestApiUrl(client.apiUrl); const response = await client.fetchImpl(`${baseUrl}/user`, { headers: { authorization: `Bearer ${client.token}`, @@ -415,11 +438,12 @@ export async function discoverUserProjects( break; } - const data: ViewerProjectsPageResponse = await graphql( - client, - VIEWER_PROJECTS_PAGE_QUERY, - { cursor: viewerProjectsCursor } - ); + const data: ViewerProjectsPageResponse = + await graphql( + client, + VIEWER_PROJECTS_PAGE_QUERY, + { cursor: viewerProjectsCursor } + ); viewerLogin = data.viewer.login; const projectPage: ViewerProjectsPageResponse["viewer"]["projectsV2"] = @@ -450,10 +474,10 @@ export async function discoverUserProjects( const data: ViewerOrganizationsPageResponse = await graphql( - client, - VIEWER_ORGANIZATIONS_PAGE_QUERY, - { cursor: organizationsCursor } - ); + client, + VIEWER_ORGANIZATIONS_PAGE_QUERY, + { cursor: organizationsCursor } + ); for (const orgNode of data.viewer.organizations?.nodes ?? []) { if (!orgNode) continue; @@ -462,7 +486,8 @@ export async function discoverUserProjects( hasMoreOrganizations = data.viewer.organizations?.pageInfo?.hasNextPage ?? false; - organizationsCursor = data.viewer.organizations?.pageInfo?.endCursor ?? null; + organizationsCursor = + data.viewer.organizations?.pageInfo?.endCursor ?? null; } for (const orgLogin of orgLogins) { @@ -476,15 +501,14 @@ export async function discoverUserProjects( const data: OrganizationProjectsPageResponse = await graphql( - client, - ORGANIZATION_PROJECTS_PAGE_QUERY, - { login: orgLogin, cursor: orgProjectsCursor } - ); + client, + ORGANIZATION_PROJECTS_PAGE_QUERY, + { login: orgLogin, cursor: orgProjectsCursor } + ); const projectPage: NonNullable< OrganizationProjectsPageResponse["organization"] - >["projectsV2"] = - data.organization?.projectsV2 ?? null; + >["projectsV2"] = data.organization?.projectsV2 ?? null; for (const node of projectPage?.nodes ?? []) { if (!node) continue; if ( diff --git a/packages/cli/src/github/gh-auth.test.ts b/packages/cli/src/github/gh-auth.test.ts index 21cea6c1..87b8d211 100644 --- a/packages/cli/src/github/gh-auth.test.ts +++ b/packages/cli/src/github/gh-auth.test.ts @@ -94,6 +94,31 @@ describe("checkGhAuthenticated", () => { authenticated: false, }); }); + + it("checks auth status for a configured hostname", () => { + const spawnImpl = vi.fn(() => + buildSpawnResult( + 0, + "", + [ + "github.example", + " ✓ Logged in to github.example account testuser (/home/test/.config/gh/hosts.yml)", + ].join("\n") + ) + ) as SpawnMock; + + expect( + checkGhAuthenticated({ spawnImpl, hostname: "github.example" }) + ).toEqual({ + authenticated: true, + login: "testuser", + }); + expect(spawnImpl).toHaveBeenCalledWith( + "gh", + ["auth", "status", "--hostname", "github.example"], + expect.any(Object) + ); + }); }); describe("checkGhScopes", () => { @@ -112,6 +137,30 @@ describe("checkGhScopes", () => { scopes: ["repo", "read:org"], }); }); + + it("checks scopes for a configured hostname", () => { + const spawnImpl = vi.fn(() => + buildSpawnResult( + 0, + "", + [ + "github.example", + " - Token scopes: 'repo', 'read:org', 'project'", + ].join("\n") + ) + ) as SpawnMock; + + expect(checkGhScopes({ spawnImpl, hostname: "github.example" })).toEqual({ + valid: true, + missing: [], + scopes: ["repo", "read:org", "project"], + }); + expect(spawnImpl).toHaveBeenCalledWith( + "gh", + ["auth", "status", "--hostname", "github.example"], + expect.any(Object) + ); + }); }); describe("getGhToken", () => { @@ -162,6 +211,29 @@ describe("getGhTokenWithSource", () => { }) ); }); + + it("reads the gh token for a configured hostname", () => { + const execImpl = vi.fn(() => "ghp_enterprise\n") as ExecMock; + + expect( + getGhTokenWithSource({ + execImpl, + envToken: undefined, + hostname: "github.example", + }) + ).toEqual({ + token: "ghp_enterprise", + source: "gh", + }); + expect(execImpl).toHaveBeenCalledWith( + "gh", + ["auth", "token", "--hostname", "github.example"], + expect.objectContaining({ + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }) + ); + }); }); describe("getEnvGitHubToken", () => { diff --git a/packages/cli/src/github/gh-auth.ts b/packages/cli/src/github/gh-auth.ts index f980ac06..78a377aa 100644 --- a/packages/cli/src/github/gh-auth.ts +++ b/packages/cli/src/github/gh-auth.ts @@ -229,15 +229,27 @@ export function checkGhInstalled(opts?: { execImpl?: ExecImpl }): boolean { } } -export function checkGhAuthenticated(opts?: { spawnImpl?: SpawnImpl }): { +function ghAuthHostArgs(hostname?: string): string[] { + const trimmed = hostname?.trim(); + return trimmed ? ["--hostname", trimmed] : []; +} + +export function checkGhAuthenticated(opts?: { + spawnImpl?: SpawnImpl; + hostname?: string; +}): { authenticated: boolean; login?: string; } { const spawnImpl = opts?.spawnImpl ?? spawnSync; - const result = spawnImpl("gh", ["auth", "status"], { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }); + const result = spawnImpl( + "gh", + ["auth", "status", ...ghAuthHostArgs(opts?.hostname)], + { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } + ); if ((result.status ?? 1) !== 0) { return { authenticated: false }; @@ -247,16 +259,23 @@ export function checkGhAuthenticated(opts?: { spawnImpl?: SpawnImpl }): { return { authenticated: true, login }; } -export function checkGhScopes(opts?: { spawnImpl?: SpawnImpl }): { +export function checkGhScopes(opts?: { + spawnImpl?: SpawnImpl; + hostname?: string; +}): { valid: boolean; missing: string[]; scopes: string[]; } { const spawnImpl = opts?.spawnImpl ?? spawnSync; - const result = spawnImpl("gh", ["auth", "status"], { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }); + const result = spawnImpl( + "gh", + ["auth", "status", ...ghAuthHostArgs(opts?.hostname)], + { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } + ); const output = (result.stdout ?? "").toString(); @@ -279,6 +298,7 @@ export function checkGhScopes(opts?: { spawnImpl?: SpawnImpl }): { export function getGhToken(opts?: { execImpl?: ExecImpl; allowEnv?: boolean; + hostname?: string; }): string { const envToken = opts?.allowEnv === false ? null : getEnvGitHubToken(); if (envToken) { @@ -288,12 +308,14 @@ export function getGhToken(opts?: { return getGhTokenWithSource({ execImpl: opts?.execImpl, envToken: undefined, + hostname: opts?.hostname, }).token; } export function getGhTokenWithSource(opts?: { execImpl?: ExecImpl; envToken?: string | undefined; + hostname?: string; }): { token: string; source: GitHubAuthSource; @@ -311,10 +333,14 @@ export function getGhTokenWithSource(opts?: { const execImpl = opts?.execImpl ?? execFileSync; try { - const token = execImpl("gh", ["auth", "token"], { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }) + const token = execImpl( + "gh", + ["auth", "token", ...ghAuthHostArgs(opts?.hostname)], + { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + } + ) .toString() .trim(); @@ -336,6 +362,7 @@ export async function validateGitHubToken( token: string, source: GitHubAuthSource, opts?: { + apiUrl?: string; createClientImpl?: typeof createClient; validateTokenImpl?: typeof validateToken; checkRequiredScopesImpl?: typeof checkRequiredScopes; @@ -348,7 +375,9 @@ export async function validateGitHubToken( let viewer: Awaited>; try { - const client = createClientImpl(token) as GitHubClient; + const client = createClientImpl(token, { + apiUrl: opts?.apiUrl, + }) as GitHubClient; viewer = await validateTokenImpl(client); } catch (error) { throw classifyTokenValidationError(error, source); @@ -390,6 +419,8 @@ export async function validateGitHubToken( export async function resolveGitHubAuth(opts?: { execImpl?: ExecImpl; spawnImpl?: SpawnImpl; + apiUrl?: string; + hostname?: string; createClientImpl?: typeof createClient; validateTokenImpl?: typeof validateToken; checkRequiredScopesImpl?: typeof checkRequiredScopes; @@ -423,6 +454,7 @@ export async function resolveGitHubAuth(opts?: { export function ensureGhAuth(opts?: { execImpl?: ExecImpl; spawnImpl?: SpawnImpl; + hostname?: string; }): { login: string; token: string; @@ -439,7 +471,7 @@ export function ensureGhAuth(opts?: { ); } - const auth = checkGhAuthenticated({ spawnImpl }); + const auth = checkGhAuthenticated({ spawnImpl, hostname: opts?.hostname }); if (!auth.authenticated) { throw new GhAuthError( "not_authenticated", @@ -448,7 +480,7 @@ export function ensureGhAuth(opts?: { ); } - const scopeCheck = checkGhScopes({ spawnImpl }); + const scopeCheck = checkGhScopes({ spawnImpl, hostname: opts?.hostname }); if (!scopeCheck.valid) { throw new GhAuthError( "missing_scopes", @@ -464,6 +496,7 @@ export function ensureGhAuth(opts?: { const { token } = getGhTokenWithSource({ execImpl, envToken: undefined, + hostname: opts?.hostname, }); return { login: auth.login ?? "unknown", token, source: "gh" }; } @@ -530,7 +563,7 @@ export function runGhAuthRefresh(opts?: { function parseLogin(output: string): string | undefined { const matched = output.match( - /Logged in to github\.com account\s+\*?\*?([A-Za-z0-9_-]+)\*?\*?/i + /Logged in to \S+ account\s+\*?\*?([A-Za-z0-9_-]+)\*?\*?/i ); return matched?.[1]; } diff --git a/packages/tracker-github/src/orchestrator-adapter.ts b/packages/tracker-github/src/orchestrator-adapter.ts index ed79902c..abcc7b47 100644 --- a/packages/tracker-github/src/orchestrator-adapter.ts +++ b/packages/tracker-github/src/orchestrator-adapter.ts @@ -41,8 +41,11 @@ export const githubProjectTrackerAdapter: OrchestratorTrackerAdapter = { }, buildWorkerEnvironment(project) { + const apiUrl = project.tracker.apiUrl?.trim(); + return { GITHUB_PROJECT_ID: requireTrackerSetting(project.tracker, "projectId"), + ...(apiUrl ? { GITHUB_GRAPHQL_API_URL: apiUrl } : {}), }; }, @@ -172,10 +175,7 @@ function resolveAssignedOnly( return dependencies.assignedOnly; } - const legacyAssignedOnly = readBooleanTrackerSetting( - tracker, - "assignedOnly" - ); + const legacyAssignedOnly = readBooleanTrackerSetting(tracker, "assignedOnly"); if (legacyAssignedOnly) { const warningKey = `${tracker.adapter}:${tracker.bindingId}`; if (!warnedLegacyAssignedOnlyProjectIds.has(warningKey)) { diff --git a/packages/tracker-github/src/tracker-github.test.ts b/packages/tracker-github/src/tracker-github.test.ts index 955f41eb..05267f03 100644 --- a/packages/tracker-github/src/tracker-github.test.ts +++ b/packages/tracker-github/src/tracker-github.test.ts @@ -747,6 +747,40 @@ describe("resolveTrackerAdapter", () => { expect(adapter.reviveIssue).toBeTypeOf("function"); }); + it("propagates the configured GitHub GraphQL endpoint into worker env", () => { + const adapter = resolveTrackerAdapter({ + adapter: "github-project", + bindingId: "project-123", + }); + + expect( + adapter.buildWorkerEnvironment( + { + projectId: "project-a", + slug: "project-a", + workspaceDir: "/tmp/project-a", + repository: { + owner: "acme", + name: "platform", + cloneUrl: "https://github.example/acme/platform.git", + }, + tracker: { + adapter: "github-project", + bindingId: "binding-123", + apiUrl: " https://github.example/api/graphql ", + settings: { + projectId: "project-123", + }, + }, + }, + makeTrackedIssue() + ) + ).toEqual({ + GITHUB_PROJECT_ID: "project-123", + GITHUB_GRAPHQL_API_URL: "https://github.example/api/graphql", + }); + }); + it("finds one GitHub Project issue through the targeted issue lookup", async () => { const fetchImpl = vi.fn(async (_url, init) => { const body =