diff --git a/pkg/cli/github.go b/pkg/cli/github.go index 0ea0f8a0ebf..a9780b48a2f 100644 --- a/pkg/cli/github.go +++ b/pkg/cli/github.go @@ -36,9 +36,10 @@ func getGitHubHost() string { } // getGitHubHostForRepo returns the GitHub host URL for a specific repository. -// The gh-aw repository (github/gh-aw) always uses public GitHub (https://github.com) -// regardless of enterprise GitHub host settings, since gh-aw itself is only available -// on public GitHub. For all other repositories, it uses getGitHubHost(). +// The gh-aw repository (github/gh-aw) and the agentics workflow library +// (githubnext/agentics) always use public GitHub (https://github.com) +// regardless of enterprise GitHub host settings, since these repositories are +// only available on public GitHub. For all other repositories, it uses getGitHubHost(). func getGitHubHostForRepo(repo string) string { // The gh-aw repository is always on public GitHub if repo == "github/gh-aw" || strings.HasPrefix(repo, "github/gh-aw/") { @@ -46,6 +47,12 @@ func getGitHubHostForRepo(repo string) string { return string(constants.PublicGitHubHost) } + // The agentics workflow library is always on public GitHub + if repo == "githubnext/agentics" || strings.HasPrefix(repo, "githubnext/agentics/") { + githubLog.Print("Using public GitHub host for githubnext/agentics repository") + return string(constants.PublicGitHubHost) + } + // For all other repositories, use the configured GitHub host return getGitHubHost() } diff --git a/pkg/cli/github_test.go b/pkg/cli/github_test.go index dce8ab1b02c..e248a8f3bd1 100644 --- a/pkg/cli/github_test.go +++ b/pkg/cli/github_test.go @@ -145,3 +145,69 @@ func TestGetGitHubHost(t *testing.T) { }) } } + +func TestGetGitHubHostForRepo(t *testing.T) { + tests := []struct { + name string + repo string + gheHost string + expectedHost string + }{ + { + name: "non-allowlisted repo uses configured public host", + repo: "owner/repo", + gheHost: "", + expectedHost: "https://github.com", + }, + { + name: "non-allowlisted repo uses GHE host", + repo: "owner/repo", + gheHost: "myorg.ghe.com", + expectedHost: "https://myorg.ghe.com", + }, + { + name: "github/gh-aw always uses public GitHub", + repo: "github/gh-aw", + gheHost: "myorg.ghe.com", + expectedHost: "https://github.com", + }, + { + name: "github/gh-aw with path always uses public GitHub", + repo: "github/gh-aw/workflows/ci-doctor.md", + gheHost: "myorg.ghe.com", + expectedHost: "https://github.com", + }, + { + name: "githubnext/agentics always uses public GitHub", + repo: "githubnext/agentics", + gheHost: "myorg.ghe.com", + expectedHost: "https://github.com", + }, + { + name: "githubnext/agentics with path always uses public GitHub", + repo: "githubnext/agentics/workflows/daily-plan.md", + gheHost: "myorg.ghe.com", + expectedHost: "https://github.com", + }, + { + name: "githubnext/agentics without GHE host uses public GitHub", + repo: "githubnext/agentics", + gheHost: "", + expectedHost: "https://github.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("GITHUB_SERVER_URL", "") + t.Setenv("GITHUB_ENTERPRISE_HOST", tt.gheHost) + t.Setenv("GITHUB_HOST", "") + t.Setenv("GH_HOST", "") + + host := getGitHubHostForRepo(tt.repo) + if host != tt.expectedHost { + t.Errorf("getGitHubHostForRepo(%q) = %q, want %q", tt.repo, host, tt.expectedHost) + } + }) + } +} diff --git a/pkg/cli/spec.go b/pkg/cli/spec.go index 02172136ebd..5ed1b3b55b0 100644 --- a/pkg/cli/spec.go +++ b/pkg/cli/spec.go @@ -293,16 +293,31 @@ func parseWorkflowSpec(spec string) (*WorkflowSpec, error) { return nil, fmt.Errorf("invalid workflow specification: '%s/%s' does not look like a valid GitHub repository", owner, repo) } + repoSlug := fmt.Sprintf("%s/%s", owner, repo) + + // Determine the API host for this repo. getGitHubHostForRepo returns the canonical + // host, which for well-known public-only repos (githubnext/agentics, github/gh-aw) + // is always public GitHub regardless of GHE configuration. If the repo's canonical + // host differs from the configured host, record the explicit hostname so API fetches + // target the correct server. + var explicitHost string + if repoHost := getGitHubHostForRepo(repoSlug); repoHost != getGitHubHost() { + if u, parseErr := url.Parse(repoHost); parseErr == nil && u.Host != "" { + explicitHost = u.Host + } + } + // Check if this is a wildcard specification (owner/repo/*) if workflowPath == "*" { return &WorkflowSpec{ RepoSpec: RepoSpec{ - RepoSlug: fmt.Sprintf("%s/%s", owner, repo), + RepoSlug: repoSlug, Version: version, }, WorkflowPath: "*", WorkflowName: "*", IsWildcard: true, + Host: explicitHost, }, nil } @@ -321,11 +336,12 @@ func parseWorkflowSpec(spec string) (*WorkflowSpec, error) { return &WorkflowSpec{ RepoSpec: RepoSpec{ - RepoSlug: fmt.Sprintf("%s/%s", owner, repo), + RepoSlug: repoSlug, Version: version, }, WorkflowPath: workflowPath, WorkflowName: strings.TrimSuffix(filepath.Base(workflowPath), ".md"), + Host: explicitHost, }, nil } diff --git a/pkg/cli/spec_test.go b/pkg/cli/spec_test.go index fb920ab0aa1..5f72475f61e 100644 --- a/pkg/cli/spec_test.go +++ b/pkg/cli/spec_test.go @@ -347,6 +347,93 @@ func TestParseWorkflowSpec(t *testing.T) { } } +// TestParseWorkflowSpecGHEHostPinning verifies that well-known public-only repos +// (githubnext/agentics, github/gh-aw) always get Host pinned to "github.com" +// when a GHE environment is detected, while other repos use an empty host. +func TestParseWorkflowSpecGHEHostPinning(t *testing.T) { + tests := []struct { + name string + spec string + wantHost string + wantNoHost bool // expect empty host + }{ + { + name: "githubnext/agentics three-part spec gets github.com in GHE mode", + spec: "githubnext/agentics/daily-plan", + wantHost: "github.com", + }, + { + name: "githubnext/agentics wildcard gets github.com in GHE mode", + spec: "githubnext/agentics/*", + wantHost: "github.com", + }, + { + name: "github/gh-aw three-part spec gets github.com in GHE mode", + spec: "github/gh-aw/my-workflow", + wantHost: "github.com", + }, + { + name: "non-allowlisted repo has empty host in GHE mode", + spec: "owner/repo/workflow", + wantNoHost: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate a GHE environment + t.Setenv("GITHUB_SERVER_URL", "") + t.Setenv("GITHUB_ENTERPRISE_HOST", "myorg.ghe.com") + t.Setenv("GITHUB_HOST", "") + t.Setenv("GH_HOST", "") + + spec, err := parseWorkflowSpec(tt.spec) + if err != nil { + t.Fatalf("parseWorkflowSpec(%q) unexpected error: %v", tt.spec, err) + } + + if tt.wantNoHost { + if spec.Host != "" { + t.Errorf("parseWorkflowSpec(%q) host = %q, want empty", tt.spec, spec.Host) + } + } else { + if spec.Host != tt.wantHost { + t.Errorf("parseWorkflowSpec(%q) host = %q, want %q", tt.spec, spec.Host, tt.wantHost) + } + } + }) + } +} + +// TestParseWorkflowSpecNoGHEHostPinning verifies that on public github.com the +// Host field is always empty for short-form specs (no pinning needed). +func TestParseWorkflowSpecNoGHEHostPinning(t *testing.T) { + // Clear all GHE env vars to simulate standard github.com environment + t.Setenv("GITHUB_SERVER_URL", "") + t.Setenv("GITHUB_ENTERPRISE_HOST", "") + t.Setenv("GITHUB_HOST", "") + t.Setenv("GH_HOST", "") + + specs := []string{ + "githubnext/agentics/daily-plan", + "githubnext/agentics/*", + "github/gh-aw/my-workflow", + "owner/repo/workflow", + } + + for _, s := range specs { + t.Run(s, func(t *testing.T) { + spec, err := parseWorkflowSpec(s) + if err != nil { + t.Fatalf("parseWorkflowSpec(%q) unexpected error: %v", s, err) + } + if spec.Host != "" { + t.Errorf("parseWorkflowSpec(%q) host = %q, want empty (no pinning on public GitHub)", s, spec.Host) + } + }) + } +} + func TestParseLocalWorkflowSpec(t *testing.T) { // Clear the repository slug cache to ensure clean test state ClearCurrentRepoSlugCache()