Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/github-ghes-graphql.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
164 changes: 161 additions & 3 deletions packages/cli/src/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
});
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading