Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/fix-1142-ralph-triage-ghe-api-host.md
Original file line number Diff line number Diff line change
@@ -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/`).
34 changes: 28 additions & 6 deletions .squad-templates/ralph-triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -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 };
2 changes: 2 additions & 0 deletions docs/src/content/docs/scenarios/ci-cd-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down
34 changes: 28 additions & 6 deletions packages/squad-cli/templates/ralph-triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -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 };
34 changes: 28 additions & 6 deletions packages/squad-sdk/templates/ralph-triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -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 };
34 changes: 28 additions & 6 deletions templates/ralph-triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -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 };
41 changes: 40 additions & 1 deletion test/ralph-triage.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 <host>/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<GhIssue>().toMatchTypeOf<{
Expand Down