diff --git a/.changeset/fix-1142-ralph-triage-ghe-api-host.md b/.changeset/fix-1142-ralph-triage-ghe-api-host.md new file mode 100644 index 000000000..44feeb4f8 --- /dev/null +++ b/.changeset/fix-1142-ralph-triage-ghe-api-host.md @@ -0,0 +1,10 @@ +--- +"@bradygaster/squad-cli": patch +"@bradygaster/squad-sdk": patch +--- + +Fix #1142: `ralph-triage.js` hardcoded `hostname: 'api.github.com'` in its `https.request()` call, so `Squad Heartbeat (Ralph)` failed with a 401 on GitHub Enterprise (the `GITHUB_TOKEN` there is only valid against the enterprise API host, not github.com). + +Added a `resolveGithubApiBase()` helper that picks the API base in order — `GITHUB_API_URL` (set by Actions on both github.com and GHE runners), then `GITHUB_SERVER_URL` + `/api/v3`, then `https://api.github.com` as a last-resort fallback — and builds the request from a `URL` object instead of a hardcoded hostname/path pair. No behavior change on github.com-hosted repos, since `GITHUB_API_URL` there already resolves to `https://api.github.com`. + +Fixed in the canonical `.squad-templates/ralph-triage.js` and synced to all 3 mirror targets (`templates/`, `packages/squad-cli/templates/`, `packages/squad-sdk/templates/`). diff --git a/.squad-templates/ralph-triage.js b/.squad-templates/ralph-triage.js index 0f533ee48..76edc90d1 100644 --- a/.squad-templates/ralph-triage.js +++ b/.squad-templates/ralph-triage.js @@ -414,13 +414,31 @@ function getOwnerRepoFromGit() { return parseOwnerRepoFromRemote(remoteUrl); } +/** + * Resolve the GitHub REST API base URL from the environment, so triage + * works on GitHub Enterprise as well as github.com. + * + * Order: GITHUB_API_URL (set by Actions on both github.com and GHE runners) + * > GITHUB_SERVER_URL + /api/v3 (GHE without an explicit API URL) + * > https://api.github.com (fallback for local/non-Actions runs). + */ +function resolveGithubApiBase() { + const apiUrl = process.env.GITHUB_API_URL; + if (apiUrl) return apiUrl.replace(/\/+$/, ''); + + const serverUrl = process.env.GITHUB_SERVER_URL; + if (serverUrl) return `${serverUrl.replace(/\/+$/, '')}/api/v3`; + + return 'https://api.github.com'; +} + function githubRequestJson(pathname, token) { return new Promise((resolve, reject) => { + const requestUrl = new URL(`${resolveGithubApiBase()}${pathname}`); const req = https.request( + requestUrl, { - hostname: 'api.github.com', method: 'GET', - path: pathname, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, @@ -539,7 +557,11 @@ async function main() { fs.writeFileSync(outputPath, `${JSON.stringify(results, null, 2)}\n`, 'utf8'); } -main().catch((error) => { - console.error(error.message); - process.exit(1); -}); +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} + +module.exports = { resolveGithubApiBase }; diff --git a/docs/src/content/docs/scenarios/ci-cd-integration.md b/docs/src/content/docs/scenarios/ci-cd-integration.md index 1f6358929..e9f5d3091 100644 --- a/docs/src/content/docs/scenarios/ci-cd-integration.md +++ b/docs/src/content/docs/scenarios/ci-cd-integration.md @@ -45,6 +45,8 @@ jobs: > ⚡ **Cron is permanently disabled.** Scheduled cron jobs are no longer supported in GitHub Actions to reduce costs. The heartbeat workflow runs on event-based triggers: when issues are closed, PRs are merged, or you manually trigger via `workflow_dispatch`. For periodic polling without events, use `squad watch` in a separate terminal (local, no GitHub Actions cost). +> 🏢 **GitHub Enterprise supported.** Ralph's triage script (`ralph-triage.js`) resolves the GitHub REST API host from `GITHUB_API_URL`, falling back to `GITHUB_SERVER_URL` + `/api/v3`, then `https://api.github.com`. On Actions runners (github.com or GHE) this is set automatically — no configuration needed. Running `squad heartbeat` outside Actions against a GHE instance? Set `GITHUB_API_URL` yourself first: `GITHUB_API_URL=https://ghe.mycompany.com/api/v3 squad heartbeat`. + Ralph reads `.squad/routing.md`, looks at open issues, and applies labels: ``` diff --git a/packages/squad-cli/templates/ralph-triage.js b/packages/squad-cli/templates/ralph-triage.js index 0f533ee48..76edc90d1 100644 --- a/packages/squad-cli/templates/ralph-triage.js +++ b/packages/squad-cli/templates/ralph-triage.js @@ -414,13 +414,31 @@ function getOwnerRepoFromGit() { return parseOwnerRepoFromRemote(remoteUrl); } +/** + * Resolve the GitHub REST API base URL from the environment, so triage + * works on GitHub Enterprise as well as github.com. + * + * Order: GITHUB_API_URL (set by Actions on both github.com and GHE runners) + * > GITHUB_SERVER_URL + /api/v3 (GHE without an explicit API URL) + * > https://api.github.com (fallback for local/non-Actions runs). + */ +function resolveGithubApiBase() { + const apiUrl = process.env.GITHUB_API_URL; + if (apiUrl) return apiUrl.replace(/\/+$/, ''); + + const serverUrl = process.env.GITHUB_SERVER_URL; + if (serverUrl) return `${serverUrl.replace(/\/+$/, '')}/api/v3`; + + return 'https://api.github.com'; +} + function githubRequestJson(pathname, token) { return new Promise((resolve, reject) => { + const requestUrl = new URL(`${resolveGithubApiBase()}${pathname}`); const req = https.request( + requestUrl, { - hostname: 'api.github.com', method: 'GET', - path: pathname, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, @@ -539,7 +557,11 @@ async function main() { fs.writeFileSync(outputPath, `${JSON.stringify(results, null, 2)}\n`, 'utf8'); } -main().catch((error) => { - console.error(error.message); - process.exit(1); -}); +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} + +module.exports = { resolveGithubApiBase }; diff --git a/packages/squad-sdk/templates/ralph-triage.js b/packages/squad-sdk/templates/ralph-triage.js index 0f533ee48..76edc90d1 100644 --- a/packages/squad-sdk/templates/ralph-triage.js +++ b/packages/squad-sdk/templates/ralph-triage.js @@ -414,13 +414,31 @@ function getOwnerRepoFromGit() { return parseOwnerRepoFromRemote(remoteUrl); } +/** + * Resolve the GitHub REST API base URL from the environment, so triage + * works on GitHub Enterprise as well as github.com. + * + * Order: GITHUB_API_URL (set by Actions on both github.com and GHE runners) + * > GITHUB_SERVER_URL + /api/v3 (GHE without an explicit API URL) + * > https://api.github.com (fallback for local/non-Actions runs). + */ +function resolveGithubApiBase() { + const apiUrl = process.env.GITHUB_API_URL; + if (apiUrl) return apiUrl.replace(/\/+$/, ''); + + const serverUrl = process.env.GITHUB_SERVER_URL; + if (serverUrl) return `${serverUrl.replace(/\/+$/, '')}/api/v3`; + + return 'https://api.github.com'; +} + function githubRequestJson(pathname, token) { return new Promise((resolve, reject) => { + const requestUrl = new URL(`${resolveGithubApiBase()}${pathname}`); const req = https.request( + requestUrl, { - hostname: 'api.github.com', method: 'GET', - path: pathname, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, @@ -539,7 +557,11 @@ async function main() { fs.writeFileSync(outputPath, `${JSON.stringify(results, null, 2)}\n`, 'utf8'); } -main().catch((error) => { - console.error(error.message); - process.exit(1); -}); +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} + +module.exports = { resolveGithubApiBase }; diff --git a/templates/ralph-triage.js b/templates/ralph-triage.js index 0f533ee48..76edc90d1 100644 --- a/templates/ralph-triage.js +++ b/templates/ralph-triage.js @@ -414,13 +414,31 @@ function getOwnerRepoFromGit() { return parseOwnerRepoFromRemote(remoteUrl); } +/** + * Resolve the GitHub REST API base URL from the environment, so triage + * works on GitHub Enterprise as well as github.com. + * + * Order: GITHUB_API_URL (set by Actions on both github.com and GHE runners) + * > GITHUB_SERVER_URL + /api/v3 (GHE without an explicit API URL) + * > https://api.github.com (fallback for local/non-Actions runs). + */ +function resolveGithubApiBase() { + const apiUrl = process.env.GITHUB_API_URL; + if (apiUrl) return apiUrl.replace(/\/+$/, ''); + + const serverUrl = process.env.GITHUB_SERVER_URL; + if (serverUrl) return `${serverUrl.replace(/\/+$/, '')}/api/v3`; + + return 'https://api.github.com'; +} + function githubRequestJson(pathname, token) { return new Promise((resolve, reject) => { + const requestUrl = new URL(`${resolveGithubApiBase()}${pathname}`); const req = https.request( + requestUrl, { - hostname: 'api.github.com', method: 'GET', - path: pathname, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}`, @@ -539,7 +557,11 @@ async function main() { fs.writeFileSync(outputPath, `${JSON.stringify(results, null, 2)}\n`, 'utf8'); } -main().catch((error) => { - console.error(error.message); - process.exit(1); -}); +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} + +module.exports = { resolveGithubApiBase }; diff --git a/test/ralph-triage.test.ts b/test/ralph-triage.test.ts index 925f4393e..7d0062f7a 100644 --- a/test/ralph-triage.test.ts +++ b/test/ralph-triage.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { describe, it, expect, expectTypeOf } from 'vitest'; +import { createRequire } from 'node:module'; +import { describe, it, expect, expectTypeOf, afterEach } from 'vitest'; import { parseRoster, parseRoutingRules, @@ -378,6 +379,44 @@ describe('triage parity', () => { }); }); +describe('resolveGithubApiBase() — GitHub Enterprise support', () => { + const require = createRequire(import.meta.url); + const { resolveGithubApiBase } = require('../templates/ralph-triage.js'); + + const originalApiUrl = process.env.GITHUB_API_URL; + const originalServerUrl = process.env.GITHUB_SERVER_URL; + + afterEach(() => { + if (originalApiUrl === undefined) delete process.env.GITHUB_API_URL; + else process.env.GITHUB_API_URL = originalApiUrl; + if (originalServerUrl === undefined) delete process.env.GITHUB_SERVER_URL; + else process.env.GITHUB_SERVER_URL = originalServerUrl; + }); + + it('uses GITHUB_API_URL when set (GHE runners set this to /api/v3)', () => { + process.env.GITHUB_API_URL = 'https://ghe.example.com/api/v3'; + delete process.env.GITHUB_SERVER_URL; + expect(resolveGithubApiBase()).toBe('https://ghe.example.com/api/v3'); + }); + + it('strips a trailing slash from GITHUB_API_URL', () => { + process.env.GITHUB_API_URL = 'https://api.github.com/'; + expect(resolveGithubApiBase()).toBe('https://api.github.com'); + }); + + it('falls back to GITHUB_SERVER_URL + /api/v3 when GITHUB_API_URL is unset', () => { + delete process.env.GITHUB_API_URL; + process.env.GITHUB_SERVER_URL = 'https://ghe.example.com'; + expect(resolveGithubApiBase()).toBe('https://ghe.example.com/api/v3'); + }); + + it('falls back to https://api.github.com when neither env var is set', () => { + delete process.env.GITHUB_API_URL; + delete process.env.GITHUB_SERVER_URL; + expect(resolveGithubApiBase()).toBe('https://api.github.com'); + }); +}); + describe('gh-cli.ts type contracts', () => { it('GhIssue includes optional body field', () => { expectTypeOf().toMatchTypeOf<{