diff --git a/.github/workflows/release.lock.yml b/.github/workflows/release.lock.yml index 9840a38941a..b670dfbb32e 100644 --- a/.github/workflows/release.lock.yml +++ b/.github/workflows/release.lock.yml @@ -1,5 +1,6 @@ # gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"2d6e82aec6fea9061e1ef1cbb085324e6ed6c079230d70bbbb96611bb18cf7cf","body_hash":"646353d7bb4e5523bc85349c2cce38188190095a303f83cc95961ff145a47043","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.70"}} -# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e","version":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"anchore/sbom-action","sha":"e22c389904149dbc22b58101806040fa8d37a610","version":"v0.24.0"},{"repo":"docker/build-push-action","sha":"53b7df96c91f9c12dcc8a07bcb9ccacbed38856a","version":"v7.3.0"},{"repo":"docker/login-action","sha":"af1e73f918a031802d376d3c8bbc3fe56130a9b0","version":"v4.4.0"},{"repo":"docker/metadata-action","sha":"dc802804100637a589fabce1cb79ff13a1411302","version":"v6.2.0"},{"repo":"docker/setup-buildx-action","sha":"bb05f3f5519dd87d3ba754cc423b652a5edd6d2c","version":"v4.2.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.35","digest":"sha256:2202f63e8650b2b8b0d38033b44a05387b2b71ad3e690c4d23a34786f5462aed","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.35@sha256:2202f63e8650b2b8b0d38033b44a05387b2b71ad3e690c4d23a34786f5462aed"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.35","digest":"sha256:755b79d0dfda82bd6b43a208d68666721e504110c5d342a4eeb199802644ff04","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.35@sha256:755b79d0dfda82bd6b43a208d68666721e504110c5d342a4eeb199802644ff04"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.35","digest":"sha256:f69282ec7b1326ba53891c399cf5b10475c0d3ccf4e1519b33d234a5427b57d3","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.35@sha256:f69282ec7b1326ba53891c399cf5b10475c0d3ccf4e1519b33d234a5427b57d3"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.1","digest":"sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.1@sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.6.0","digest":"sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3","pinned_image":"ghcr.io/github/github-mcp-server:v1.6.0@sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3"}]}# This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GH_AW_OTEL_GRAFANA_AUTHORIZATION","GH_AW_OTEL_GRAFANA_ENDPOINT","GH_AW_OTEL_SENTRY_AUTHORIZATION","GH_AW_OTEL_SENTRY_ENDPOINT","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-go","sha":"b7ad1dad31e06c5925ef5d2fc7ad053ef454303e","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"anchore/sbom-action","sha":"e22c389904149dbc22b58101806040fa8d37a610","version":"v0.24.0"},{"repo":"docker/build-push-action","sha":"53b7df96c91f9c12dcc8a07bcb9ccacbed38856a","version":"v7.3.0"},{"repo":"docker/login-action","sha":"af1e73f918a031802d376d3c8bbc3fe56130a9b0","version":"v4.4.0"},{"repo":"docker/metadata-action","sha":"dc802804100637a589fabce1cb79ff13a1411302","version":"v6.2.0"},{"repo":"docker/setup-buildx-action","sha":"bb05f3f5519dd87d3ba754cc423b652a5edd6d2c","version":"v4.2.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.35","digest":"sha256:2202f63e8650b2b8b0d38033b44a05387b2b71ad3e690c4d23a34786f5462aed","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.35@sha256:2202f63e8650b2b8b0d38033b44a05387b2b71ad3e690c4d23a34786f5462aed"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.35","digest":"sha256:755b79d0dfda82bd6b43a208d68666721e504110c5d342a4eeb199802644ff04","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.35@sha256:755b79d0dfda82bd6b43a208d68666721e504110c5d342a4eeb199802644ff04"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.35","digest":"sha256:f69282ec7b1326ba53891c399cf5b10475c0d3ccf4e1519b33d234a5427b57d3","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.35@sha256:f69282ec7b1326ba53891c399cf5b10475c0d3ccf4e1519b33d234a5427b57d3"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.1","digest":"sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.1@sha256:ad2a979c2cd8b50098e84938ca9c9c1580eb8e91526f101a90adfba7859b2c32"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.6.0","digest":"sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3","pinned_image":"ghcr.io/github/github-mcp-server:v1.6.0@sha256:2b0c48b070f61e9d3969269ead600f62d00fb237b60ac849ef3d166ee7de9ad3"}]} +# This file was automatically generated by gh-aw. DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # # ___ _ _ # / _ \ | | (_) diff --git a/docs/adr/46128-auto-detect-ghes-host-from-git-remote.md b/docs/adr/46128-auto-detect-ghes-host-from-git-remote.md new file mode 100644 index 00000000000..cb5e104f6ef --- /dev/null +++ b/docs/adr/46128-auto-detect-ghes-host-from-git-remote.md @@ -0,0 +1,45 @@ +# ADR-46128: Auto-Detect GitHub Enterprise Host from Git Remote When GH_HOST Is Unset + +**Date**: 2026-07-17 +**Status**: Draft +**Deciders**: mnkiefer + +--- + +### Context + +`gh-aw` commands that create PATs or verify authentication (e.g., `doctor`, `setup auth`, `setup repository`) previously hardcoded `github.com` as the target host unless the user explicitly set `GH_HOST`. Users operating in GitHub Enterprise Server (GHES) checkouts who have not set `GH_HOST` would receive PAT creation URLs and auth guidance pointing to the wrong host, causing silent failures or confusing redirects. A reliable, zero-configuration signal for the GHES host already exists in the local git repository's `origin` remote URL. + +### Decision + +We will implement implicit GHES host detection by parsing the `origin` remote URL at command runtime. When `GH_HOST` is unset and the detected host is not `github.com`, the tool will call `workflow.SetDefaultGHHost(detectedHost)` to configure the session host before executing auth checks or constructing PAT creation URLs. PAT URL construction is refactored into a shared `buildPATCreationURL` helper that applies this detection for both Copilot and generic PAT flows. + +### Alternatives Considered + +#### Alternative 1: Require Explicit GH_HOST Configuration + +Users must set `GH_HOST` (or `GITHUB_HOST` / `GITHUB_ENTERPRISE_HOST` / `GITHUB_SERVER_URL`) before using GHES-targeting commands. The tool documents this requirement and returns a clear error when the host cannot be determined. This approach is simpler and deterministic but imposes manual setup friction on every GHES user and breaks the zero-configuration experience the tool aims for. + +#### Alternative 2: Env-Var-Only Expansion Without Git Remote Inspection + +Expand host detection to check a prioritized list of environment variables (`GITHUB_SERVER_URL`, `GITHUB_ENTERPRISE_HOST`, `GITHUB_HOST`) in addition to `GH_HOST`, without inspecting the git remote. This avoids any filesystem I/O and is safe in all working-directory contexts but fails when none of these variables are set — which is the common case for developers running locally outside CI/CD pipelines. + +### Consequences + +#### Positive +- GHES users in a checkout receive correct PAT creation URLs and auth commands without manual host configuration. +- The `buildPATCreationURL` helper consolidates host-aware URL construction, eliminating the hardcoded `github.com` string in both Copilot PAT and system PAT flows. +- New integration-style tests verify host detection end-to-end by creating real temporary git repos with remote URLs. + +#### Negative +- Auto-detection couples command behavior to the current working directory's git state; commands run outside a git repo or in a repo with a non-GitHub remote will silently fall back to `github.com` rather than surfacing a clear error. +- Git remote inspection adds I/O at command startup for `setup auth` and `setup repository check`, which could slow down commands in environments with sluggish filesystem access. +- If the `origin` remote points to a mirror or proxy (not the canonical GHES host), the detected host will be wrong, and the error may not be obvious to the user. + +#### Neutral +- The `doctor` command long description is updated to document the auto-detection behavior and the manual fallback (`gh auth login --hostname ` or `GH_HOST`), keeping user-facing guidance in sync with the implementation. +- The change is scoped to host detection at command entry; downstream `gh` invocations already use the configured default host once `workflow.SetDefaultGHHost` is called. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/docs/src/content/docs/setup/cli.md b/docs/src/content/docs/setup/cli.md index a6c1bebe0d2..daae5c36aff 100644 --- a/docs/src/content/docs/setup/cli.md +++ b/docs/src/content/docs/setup/cli.md @@ -271,6 +271,8 @@ See [Authentication](/gh-aw/reference/auth/) for details. Run diagnostics to verify CLI authentication and repository setup. +When running inside a GitHub Enterprise checkout and `GH_HOST` is unset, `doctor` auto-detects the host from the git remote. Outside a checkout, run `gh auth login --hostname ` to authenticate and set `GH_HOST=` so repository diagnostics target the correct host. + ```bash wrap gh aw doctor gh aw doctor --json diff --git a/pkg/cli/cli_consistency_help_test.go b/pkg/cli/cli_consistency_help_test.go index 95fb3d6d4f2..8fdf8759d39 100644 --- a/pkg/cli/cli_consistency_help_test.go +++ b/pkg/cli/cli_consistency_help_test.go @@ -86,6 +86,7 @@ func TestCLIDocsReflectStatusAuditAndExperimentsCommands(t *testing.T) { assert.Contains(t, text, "**Options:** `--repo/-r`, `--dir/-d`, `--require-owner-type`, `--json/-j`", "doctor docs should include the --dir shorthand") assert.Contains(t, text, "`--require-owner-type` accepts `any`, `user`, or `org` and defaults to `any`", "doctor docs should document the full owner type set and default") assert.Contains(t, text, "`--dir` and `--require-owner-type` require `--repo`", "doctor docs should document the repo requirement for repository-only flags") + assert.Contains(t, text, "Outside a checkout, run `gh auth login --hostname ` to authenticate and set `GH_HOST=` so repository diagnostics target the correct host.", "doctor docs should explain that enterprise hosts outside a checkout require both authentication and host selection") } func TestSubcommandListingsUseHyphenBullets(t *testing.T) { diff --git a/pkg/cli/doctor_command.go b/pkg/cli/doctor_command.go index 24e0a52b114..b3a03b23545 100644 --- a/pkg/cli/doctor_command.go +++ b/pkg/cli/doctor_command.go @@ -16,7 +16,12 @@ func NewDoctorCommand() *cobra.Command { Long: `Run diagnostics to verify CLI authentication and repository setup. Checks GitHub CLI authentication. When --repo is provided, also verifies the -repository exists, resolves the owner type, and inspects checkout state.`, +repository exists, resolves the owner type, and inspects checkout state. + +When running inside a GitHub Enterprise checkout and GH_HOST is unset, doctor +auto-detects the host from the git remote. Outside a checkout, authenticate with +gh auth login --hostname and set GH_HOST= so repository diagnostics +target the correct host.`, Example: ` gh aw doctor gh aw doctor --json gh aw doctor --repo github/gh-aw diff --git a/pkg/cli/doctor_command_test.go b/pkg/cli/doctor_command_test.go index 3a4a8c1c040..af5d22ee9ca 100644 --- a/pkg/cli/doctor_command_test.go +++ b/pkg/cli/doctor_command_test.go @@ -3,9 +3,14 @@ package cli import ( + "context" "errors" + "os" + "path/filepath" "testing" + "github.com/github/gh-aw/pkg/testutil" + "github.com/github/gh-aw/pkg/workflow" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,6 +43,12 @@ func TestDoctorCommandAdvertisesJSONExample(t *testing.T) { assert.Contains(t, cmd.Example, "gh aw doctor --repo github/gh-aw --json") } +func TestDoctorCommandLongMentionsEnterpriseHostFallback(t *testing.T) { + cmd := NewDoctorCommand() + assert.Contains(t, cmd.Long, "auto-detects the host from the git remote") + assert.Contains(t, cmd.Long, "gh auth login --hostname ") +} + func TestDoctorCommandExampleHasNoTabs(t *testing.T) { cmd := NewDoctorCommand() assert.NotContains(t, cmd.Example, "\t") @@ -210,3 +221,69 @@ func TestDoctorCommandAllowsVerboseWithoutRepo(t *testing.T) { assert.True(t, authCalled) assert.True(t, verbose) } + +func TestRunSetupAuthAutoDetectsDefaultGHHost(t *testing.T) { + workflow.SetDefaultGHHost("") + t.Cleanup(func() { workflow.SetDefaultGHHost("") }) + if prev, ok := os.LookupEnv("GH_HOST"); ok { + t.Cleanup(func() { _ = os.Setenv("GH_HOST", prev) }) + } else { + t.Cleanup(func() { _ = os.Unsetenv("GH_HOST") }) + } + require.NoError(t, os.Unsetenv("GH_HOST")) + + tempDir := testutil.TempDir(t, "doctor-gh-host-*") + require.NoError(t, initTestGitRepo(tempDir)) + require.NoError(t, addOriginRemoteToTestRepo(tempDir, "https://ghes.example.com/owner/repo.git")) + t.Chdir(tempDir) + + runtime := setupRepositoryRuntime{ + checkAuth: func(context.Context) error { + assert.Equal(t, "ghes.example.com", getGHHostFromCommandEnv(workflow.ExecGH("auth", "status"))) + return nil + }, + } + + require.NoError(t, runSetupAuthWithRuntime(SetupAuthOptions{}, runtime)) +} + +func TestRunSetupRepositoryCheckAutoDetectsDefaultGHHost(t *testing.T) { + workflow.SetDefaultGHHost("") + t.Cleanup(func() { workflow.SetDefaultGHHost("") }) + if prev, ok := os.LookupEnv("GH_HOST"); ok { + t.Cleanup(func() { _ = os.Setenv("GH_HOST", prev) }) + } else { + t.Cleanup(func() { _ = os.Unsetenv("GH_HOST") }) + } + require.NoError(t, os.Unsetenv("GH_HOST")) + + tempDir := testutil.TempDir(t, "doctor-repo-gh-host-*") + require.NoError(t, initTestGitRepo(tempDir)) + require.NoError(t, addOriginRemoteToTestRepo(tempDir, "git@ghes.example.com:owner/repo.git")) + t.Chdir(tempDir) + + checkoutDir := filepath.Join(tempDir, "checkout") + require.NoError(t, os.MkdirAll(checkoutDir, 0755)) + + runtime := setupRepositoryRuntime{ + checkAuth: func(context.Context) error { + assert.Equal(t, "ghes.example.com", getGHHostFromCommandEnv(workflow.ExecGH("auth", "status"))) + return nil + }, + repoExists: func(context.Context, string) (bool, error) { return true, nil }, + ownerType: func(context.Context, string) (string, error) { return "Organization", nil }, + dirOriginRepo: func(string) (string, error) { + return "owner/repo", nil + }, + checkCleanWorktree: func(bool) error { return nil }, + } + + err := runSetupRepositoryCheckWithRuntime(SetupRepositoryCheckOptions{ + Repo: "owner/repo", + Dir: checkoutDir, + RequireOwnerType: "any", + }, runtime) + require.Error(t, err) + assert.Contains(t, err.Error(), "git checkout") + assert.Equal(t, "ghes.example.com", getGHHostFromCommandEnv(workflow.ExecGH("auth", "status"))) +} diff --git a/pkg/cli/engine_secrets.go b/pkg/cli/engine_secrets.go index 3c9b3eebc4c..95d540673db 100644 --- a/pkg/cli/engine_secrets.go +++ b/pkg/cli/engine_secrets.go @@ -398,10 +398,45 @@ func promptForCopilotPATUnified(req SecretRequirement, config EngineSecretConfig } func buildCopilotPATCreationURL() string { - const baseURL = "https://github.com/settings/personal-access-tokens/new" values := url.Values{} values.Set("name", constants.CopilotGitHubToken) values.Set("user_copilot_requests", "read") + return buildPATCreationURL(values) +} + +func buildGenericPATCreationURL() string { + return buildPATCreationURL(nil) +} + +// isAnyGitHubHostEnvVarSet returns true when any of the environment variables +// consumed by getGitHubHost() is explicitly set. When at least one is present +// the caller has made an explicit host choice and the git-remote fallback should +// not be consulted. +func isAnyGitHubHostEnvVarSet() bool { + for _, envVar := range []string{"GITHUB_SERVER_URL", "GITHUB_ENTERPRISE_HOST", "GITHUB_HOST", "GH_HOST"} { + if os.Getenv(envVar) != "" { //nolint:osgetenvlibrary + return true + } + } + return false +} + +func buildPATCreationURL(values url.Values) string { + hostURL := getGitHubHost() + // Only consult the git remote when the caller has not made an explicit host + // choice via an environment variable. Falling back when an env var selects + // public GitHub would silently override that explicit choice. + if !isAnyGitHubHostEnvVarSet() { + if detectedHost := getHostFromOriginRemote(); detectedHost != "" && detectedHost != "github.com" { + hostURL = stringutil.NormalizeGitHubHostURL(detectedHost) + } + } + + baseURL := strings.TrimRight(hostURL, "/") + "/settings/personal-access-tokens/new" + if len(values) == 0 { + return baseURL + } + return baseURL + "?" + values.Encode() } @@ -409,6 +444,7 @@ func buildCopilotPATCreationURL() string { // This uses PAT-specific wording instead of "API key" since system secrets are GitHub tokens func promptForSystemTokenUnified(req SecretRequirement, config EngineSecretConfig) error { engineSecretsLog.Printf("Prompting for system token: %s", req.Name) + patURL := buildGenericPATCreationURL() fmt.Fprintln(os.Stderr, "") fmt.Fprintf(os.Stderr, "%s requires a GitHub Personal Access Token (PAT).\n", req.Name) @@ -417,7 +453,7 @@ func promptForSystemTokenUnified(req SecretRequirement, config EngineSecretConfi fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Recommended scopes: "+req.Description)) fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Create a token at:") - fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" https://github.com/settings/personal-access-tokens/new")) + fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" "+patURL)) fmt.Fprintln(os.Stderr, "") var token string diff --git a/pkg/cli/engine_secrets_test.go b/pkg/cli/engine_secrets_test.go index 8e7f5ad1c01..81d6ea07ad9 100644 --- a/pkg/cli/engine_secrets_test.go +++ b/pkg/cli/engine_secrets_test.go @@ -12,6 +12,7 @@ import ( "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/setutil" + "github.com/github/gh-aw/pkg/testutil" ) func TestGetRequiredSecretsForEngine(t *testing.T) { @@ -147,18 +148,70 @@ func TestGetRequiredSecretsForEngineAttributes(t *testing.T) { } func TestBuildCopilotPATCreationURL(t *testing.T) { - rawURL := buildCopilotPATCreationURL() - parsed, err := url.Parse(rawURL) - require.NoError(t, err) - - assert.Equal(t, "https", parsed.Scheme) - assert.Equal(t, "github.com", parsed.Host) - assert.Equal(t, "/settings/personal-access-tokens/new", parsed.Path) - assert.Equal(t, constants.CopilotGitHubToken, parsed.Query().Get("name"), "token name must be prefilled to COPILOT_GITHUB_TOKEN") - assert.Equal(t, "read", parsed.Query().Get("user_copilot_requests")) - assert.Empty(t, parsed.Query().Get("contents"), "Copilot PAT setup URL should not request unrelated repository permissions") - assert.Empty(t, parsed.Query().Get("issues"), "Copilot PAT setup URL should not request unrelated issue permissions") - assert.Empty(t, parsed.Query().Get("pull_requests"), "Copilot PAT setup URL should not request unrelated pull request permissions") + t.Run("defaults to public github", func(t *testing.T) { + // Clear all host env vars so the remote-detection fallback is exercised in + // isolation. Higher-priority variables (GITHUB_SERVER_URL, etc.) are set + // by GitHub Actions and would otherwise override the expected default. + t.Setenv("GITHUB_SERVER_URL", "") + t.Setenv("GITHUB_ENTERPRISE_HOST", "") + t.Setenv("GITHUB_HOST", "") + t.Setenv("GH_HOST", "") + + // Run outside any git checkout so that getHostFromOriginRemote() has no + // remote to detect, guaranteeing the github.com default is returned. + tmpDir := testutil.TempDir(t, "copilot-pat-url-default-*") + t.Chdir(tmpDir) + + rawURL := buildCopilotPATCreationURL() + parsed, err := url.Parse(rawURL) + require.NoError(t, err) + + assert.Equal(t, "https", parsed.Scheme) + assert.Equal(t, "github.com", parsed.Host) + assert.Equal(t, "/settings/personal-access-tokens/new", parsed.Path) + assert.Equal(t, constants.CopilotGitHubToken, parsed.Query().Get("name"), "token name must be prefilled to COPILOT_GITHUB_TOKEN") + assert.Equal(t, "read", parsed.Query().Get("user_copilot_requests")) + assert.Empty(t, parsed.Query().Get("contents"), "Copilot PAT setup URL should not request unrelated repository permissions") + assert.Empty(t, parsed.Query().Get("issues"), "Copilot PAT setup URL should not request unrelated issue permissions") + assert.Empty(t, parsed.Query().Get("pull_requests"), "Copilot PAT setup URL should not request unrelated pull request permissions") + }) + + t.Run("uses GH_HOST when gh auth is configured for enterprise", func(t *testing.T) { + // Clear higher-priority variables first so GH_HOST (the lowest-priority + // consumer in getGitHubHost) is actually honoured. + t.Setenv("GITHUB_SERVER_URL", "") + t.Setenv("GITHUB_ENTERPRISE_HOST", "") + t.Setenv("GITHUB_HOST", "") + t.Setenv("GH_HOST", "ghe.example.com") + + rawURL := buildCopilotPATCreationURL() + parsed, err := url.Parse(rawURL) + require.NoError(t, err) + + assert.Equal(t, "https", parsed.Scheme) + assert.Equal(t, "ghe.example.com", parsed.Host) + assert.Equal(t, "/settings/personal-access-tokens/new", parsed.Path) + }) + + t.Run("falls back to origin remote host when GH_HOST is unset", func(t *testing.T) { + t.Setenv("GH_HOST", "") + t.Setenv("GITHUB_HOST", "") + t.Setenv("GITHUB_ENTERPRISE_HOST", "") + t.Setenv("GITHUB_SERVER_URL", "") + + tmpDir := testutil.TempDir(t, "copilot-pat-url-host-*") + require.NoError(t, initTestGitRepo(tmpDir)) + require.NoError(t, addOriginRemoteToTestRepo(tmpDir, "https://ghes.example.com/org/repo.git")) + t.Chdir(tmpDir) + + rawURL := buildCopilotPATCreationURL() + parsed, err := url.Parse(rawURL) + require.NoError(t, err) + + assert.Equal(t, "https", parsed.Scheme) + assert.Equal(t, "ghes.example.com", parsed.Host) + assert.Equal(t, "/settings/personal-access-tokens/new", parsed.Path) + }) } func TestEnsureSecretAvailable_CopilotRepromptsWithOverwrite(t *testing.T) { diff --git a/pkg/cli/setup_repository.go b/pkg/cli/setup_repository.go index 2ce26728943..bdda33189c4 100644 --- a/pkg/cli/setup_repository.go +++ b/pkg/cli/setup_repository.go @@ -17,6 +17,19 @@ import ( var setupRepositoryLog = logger.New("cli:setup_repository") +func configureDefaultGHHostFromOriginRemoteIfUnset() { + if os.Getenv("GH_HOST") != "" { //nolint:osgetenvlibrary + return + } + + if detectedHost := getHostFromOriginRemote(); detectedHost != "" && detectedHost != "github.com" { + setupRepositoryLog.Printf("Auto-detected GHES host from git remote: %s", detectedHost) + workflow.SetDefaultGHHost(detectedHost) + } else if detectedHost == "github.com" { + workflow.SetDefaultGHHost("") + } +} + type SetupAuthOptions struct { Ctx context.Context JSON bool @@ -299,6 +312,8 @@ func runSetupAuthWithRuntime(opts SetupAuthOptions, runtime setupRepositoryRunti ctx = context.Background() } + configureDefaultGHHostFromOriginRemoteIfUnset() + if err := runtime.checkAuth(ctx); err != nil { return fmt.Errorf("failed to verify GitHub CLI authentication: %w", err) } @@ -353,6 +368,8 @@ func runSetupRepositoryCheckWithRuntime(opts SetupRepositoryCheckOptions, runtim ctx = context.Background() } + configureDefaultGHHostFromOriginRemoteIfUnset() + if err := runtime.checkAuth(ctx); err != nil { return fmt.Errorf("failed to verify GitHub CLI authentication: %w", err) } diff --git a/pkg/cli/tokens_bootstrap.go b/pkg/cli/tokens_bootstrap.go index dd8f43d1eb8..40a04513390 100644 --- a/pkg/cli/tokens_bootstrap.go +++ b/pkg/cli/tokens_bootstrap.go @@ -43,6 +43,11 @@ Only required secrets are prompted for. Optional secrets are not shown.`, func runTokensBootstrap(engine, repo string, nonInteractive bool) error { tokensBootstrapLog.Printf("Running tokens bootstrap: engine=%s, repo=%s, nonInteractive=%v", engine, repo, nonInteractive) + + // Configure the gh CLI host from the git remote before any gh calls so that + // secret discovery and upload both target the correct host. + configureDefaultGHHostFromOriginRemoteIfUnset() + var repoSlug string var err error