From 82af76151230b8937148639daeae39a9bf427d2f Mon Sep 17 00:00:00 2001 From: HyunSoo Date: Fri, 31 Jul 2026 10:39:08 +0900 Subject: [PATCH] fix(github): honor REST and GraphQL endpoints --- .../opencode/src/cli/cmd/github.handler.ts | 15 ++- .../opencode/src/cli/cmd/github.shared.ts | 17 ++++ .../test/cli/github-endpoints.test.ts | 92 +++++++++++++++++++ 3 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/test/cli/github-endpoints.test.ts diff --git a/packages/opencode/src/cli/cmd/github.handler.ts b/packages/opencode/src/cli/cmd/github.handler.ts index d55c0bf3fbfa..ee53b7e750f8 100644 --- a/packages/opencode/src/cli/cmd/github.handler.ts +++ b/packages/opencode/src/cli/cmd/github.handler.ts @@ -3,8 +3,6 @@ import { exec } from "child_process" import { Filesystem } from "@/util/filesystem" import * as prompts from "@clack/prompts" import { map, pipe, sortBy, values } from "remeda" -import { Octokit } from "@octokit/rest" -import { graphql } from "@octokit/graphql" import * as core from "@actions/core" import * as github from "@actions/github" import type { Context } from "@actions/github/lib/context" @@ -33,7 +31,7 @@ import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" import { parseGitHubRemote } from "@/util/repository" import { Effect } from "effect" -import { extractResponseText, formatPromptTooLargeError } from "./github.shared" +import { createGithubClients, extractResponseText, formatPromptTooLargeError } from "./github.shared" type GitHubAuthor = { login: string @@ -429,8 +427,8 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai" let appToken: string - let octoRest: Octokit - let octoGraph: typeof graphql + let octoRest: ReturnType["rest"] + let octoGraph: ReturnType["graph"] let gitConfig: string let session: { id: SessionID; title: string; version: string } let shareId: string | undefined @@ -479,10 +477,9 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?: const actionToken = isMock ? args.token! : await getOidcToken() appToken = await exchangeForAppToken(actionToken) } - octoRest = new Octokit({ auth: appToken }) - octoGraph = graphql.defaults({ - headers: { authorization: `token ${appToken}` }, - }) + const clients = createGithubClients(appToken) + octoRest = clients.rest + octoGraph = clients.graph const { userPrompt, promptFiles } = await getUserPrompt() if (!useGithubToken) { diff --git a/packages/opencode/src/cli/cmd/github.shared.ts b/packages/opencode/src/cli/cmd/github.shared.ts index 157d0156fb00..5dde232d82bb 100644 --- a/packages/opencode/src/cli/cmd/github.shared.ts +++ b/packages/opencode/src/cli/cmd/github.shared.ts @@ -1,4 +1,6 @@ import type { SessionV1 } from "@opencode-ai/core/v1/session" +import { graphql } from "@octokit/graphql" +import { Octokit } from "@octokit/rest" export { parseGitHubRemote } from "@/util/repository" @@ -28,3 +30,18 @@ export function formatPromptTooLargeError(files: { filename: string; content: st : "" return `PROMPT_TOO_LARGE: The prompt exceeds the model's context limit.${fileDetails}` } + +export function createGithubClients(auth: string) { + const restBaseUrl = (process.env["GITHUB_API_URL"] ?? "https://api.github.com").replace(/\/+$/, "") + const graphqlBaseUrl = (process.env["GITHUB_GRAPHQL_URL"] ?? "https://api.github.com") + .replace(/\/+$/, "") + .replace(/\/graphql$/, "") + + return { + rest: new Octokit({ auth, baseUrl: restBaseUrl }), + graph: graphql.defaults({ + baseUrl: graphqlBaseUrl, + headers: { authorization: `token ${auth}` }, + }), + } +} diff --git a/packages/opencode/test/cli/github-endpoints.test.ts b/packages/opencode/test/cli/github-endpoints.test.ts new file mode 100644 index 000000000000..a968732334c5 --- /dev/null +++ b/packages/opencode/test/cli/github-endpoints.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { createGithubClients } from "../../src/cli/cmd/github.shared" + +const endpointCases = [ + { + name: "uses GitHub.com when both endpoint variables are unset", + restBaseUrl: undefined, + graphqlBaseUrl: undefined, + expectedURLs: ["https://api.github.com/repos/acme/widgets", "https://api.github.com/graphql"], + }, + { + name: "uses GITHUB_API_URL only for REST and removes trailing slashes", + restBaseUrl: "https://github.example.test/api/v3///", + graphqlBaseUrl: undefined, + expectedURLs: ["https://github.example.test/api/v3/repos/acme/widgets", "https://api.github.com/graphql"], + }, + { + name: "uses GITHUB_GRAPHQL_URL only for GraphQL", + restBaseUrl: undefined, + graphqlBaseUrl: "https://github.example.test/api/graphql", + expectedURLs: ["https://api.github.com/repos/acme/widgets", "https://github.example.test/api/graphql"], + }, + { + name: "uses independent REST and GraphQL endpoints when both are set", + restBaseUrl: "https://github.example.test/api/v3", + graphqlBaseUrl: "https://github.example.test/api/graphql", + expectedURLs: [ + "https://github.example.test/api/v3/repos/acme/widgets", + "https://github.example.test/api/graphql", + ], + }, + { + name: "does not duplicate a terminal GraphQL path when removing trailing slashes", + restBaseUrl: undefined, + graphqlBaseUrl: "https://github.example.test/api/graphql///", + expectedURLs: ["https://api.github.com/repos/acme/widgets", "https://github.example.test/api/graphql"], + }, + { + name: "preserves a custom GraphQL base before the client appends its path", + restBaseUrl: undefined, + graphqlBaseUrl: "https://github.example.test/custom///", + expectedURLs: ["https://api.github.com/repos/acme/widgets", "https://github.example.test/custom/graphql"], + }, +] as const + +const originalGithubApiUrl = process.env["GITHUB_API_URL"] +const originalGithubGraphqlUrl = process.env["GITHUB_GRAPHQL_URL"] +const originalFetch = globalThis.fetch + +function setEndpoint(variable: "GITHUB_API_URL" | "GITHUB_GRAPHQL_URL", value: string | undefined) { + if (value === undefined) { + delete process.env[variable] + return + } + process.env[variable] = value +} + +afterEach(() => { + setEndpoint("GITHUB_API_URL", originalGithubApiUrl) + setEndpoint("GITHUB_GRAPHQL_URL", originalGithubGraphqlUrl) + globalThis.fetch = originalFetch +}) + +describe("createGithubClients", () => { + for (const endpointCase of endpointCases) { + test(endpointCase.name, async () => { + setEndpoint("GITHUB_API_URL", endpointCase.restBaseUrl) + setEndpoint("GITHUB_GRAPHQL_URL", endpointCase.graphqlBaseUrl) + const requests: Request[] = [] + globalThis.fetch = Object.assign( + async (...[input, init]: Parameters) => { + const request = new Request(input, init) + requests.push(request) + return new Response(JSON.stringify({ data: { repository: {} } }), { + headers: { "content-type": "application/json" }, + }) + }, + { preconnect: originalFetch.preconnect }, + ) + const clients = createGithubClients("endpoint-test-token") + + await clients.rest.rest.repos.get({ owner: "acme", repo: "widgets" }) + await clients.graph('query { repository(owner: "acme", name: "widgets") { id } }') + + expect(requests.map((request) => request.url)).toEqual([...endpointCase.expectedURLs]) + expect(requests.map((request) => request.headers.get("authorization"))).toEqual([ + "token endpoint-test-token", + "token endpoint-test-token", + ]) + }) + } +})