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
3 changes: 2 additions & 1 deletion .github/workflows/release.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions docs/adr/46128-auto-detect-ghes-host-from-git-remote.md
Original file line number Diff line number Diff line change
@@ -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 <host>` 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.*
2 changes: 2 additions & 0 deletions docs/src/content/docs/setup/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` to authenticate and set `GH_HOST=<host>` so repository diagnostics target the correct host.

```bash wrap
gh aw doctor
gh aw doctor --json
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/cli_consistency_help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` to authenticate and set `GH_HOST=<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) {
Expand Down
7 changes: 6 additions & 1 deletion pkg/cli/doctor_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host> and set GH_HOST=<host> so repository diagnostics
target the correct host.`,
Example: ` gh aw doctor
gh aw doctor --json
gh aw doctor --repo github/gh-aw
Expand Down
77 changes: 77 additions & 0 deletions pkg/cli/doctor_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 <host>")
}

func TestDoctorCommandExampleHasNoTabs(t *testing.T) {
cmd := NewDoctorCommand()
assert.NotContains(t, cmd.Example, "\t")
Expand Down Expand Up @@ -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))
}
Comment thread
mnkiefer marked this conversation as resolved.

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")))
}
40 changes: 38 additions & 2 deletions pkg/cli/engine_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,17 +398,53 @@ 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" {
Comment thread
mnkiefer marked this conversation as resolved.
hostURL = stringutil.NormalizeGitHubHostURL(detectedHost)
Comment thread
mnkiefer marked this conversation as resolved.
}
}

baseURL := strings.TrimRight(hostURL, "/") + "/settings/personal-access-tokens/new"
if len(values) == 0 {
return baseURL
}

return baseURL + "?" + values.Encode()
}

// promptForSystemTokenUnified prompts the user for a system-level GitHub token (PAT)
// 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)
Expand All @@ -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
Expand Down
Loading
Loading