diff --git a/pkg/cli/add_interactive_engine.go b/pkg/cli/add_interactive_engine.go index e07495606b..068f6d890b 100644 --- a/pkg/cli/add_interactive_engine.go +++ b/pkg/cli/add_interactive_engine.go @@ -177,7 +177,8 @@ func (c *AddInteractiveConfig) collectCopilotPAT() error { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Classic PATs (ghp_...) are not supported. You must use a fine-grained PAT (github_pat_...).")) fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Please create a token at:") - fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" https://github.com/settings/personal-access-tokens/new")) + githubHost := getGitHubHost() + fmt.Fprintln(os.Stderr, console.FormatCommandMessage(fmt.Sprintf(" %s/settings/personal-access-tokens/new", githubHost))) fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Configure the token with:") fmt.Fprintln(os.Stderr, " • Token name: Agentic Workflows Copilot") diff --git a/pkg/cli/add_interactive_workflow.go b/pkg/cli/add_interactive_workflow.go index dac353a768..965d8c8b49 100644 --- a/pkg/cli/add_interactive_workflow.go +++ b/pkg/cli/add_interactive_workflow.go @@ -92,7 +92,8 @@ func (c *AddInteractiveConfig) checkStatusAndOfferRun(ctx context.Context) error addInteractiveLog.Print("Running in Codespaces, skipping run offer and showing Actions link") fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Running in GitHub Codespaces - please trigger the workflow manually from the Actions page")) - fmt.Fprintf(os.Stderr, "🔗 https://github.com/%s/actions\n", c.RepoOverride) + githubHost := getGitHubHost() + fmt.Fprintf(os.Stderr, "🔗 %s/%s/actions\n", githubHost, c.RepoOverride) c.showFinalInstructions() return nil } diff --git a/pkg/cli/download_workflow.go b/pkg/cli/download_workflow.go index e9c51d531a..606a7d6e94 100644 --- a/pkg/cli/download_workflow.go +++ b/pkg/cli/download_workflow.go @@ -23,7 +23,8 @@ func resolveLatestReleaseViaGit(repo, currentRef string, allowMajor, verbose boo fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching latest release for %s via git ls-remote (current: %s, allow major: %v)", repo, currentRef, allowMajor))) } - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHost() + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // List all tags cmd := exec.Command("git", "ls-remote", "--tags", repoURL) @@ -102,7 +103,8 @@ func resolveLatestReleaseViaGit(repo, currentRef string, allowMajor, verbose boo func isBranchRefViaGit(repo, ref string) (bool, error) { downloadLog.Printf("Attempting git ls-remote to check if ref is branch: %s@%s", repo, ref) - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHost() + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // List all branches and check if ref matches cmd := exec.Command("git", "ls-remote", "--heads", repoURL) @@ -167,7 +169,8 @@ func resolveBranchHeadViaGit(repo, branch string, verbose bool) (string, error) fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching latest commit for branch %s in %s via git ls-remote", branch, repo))) } - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHost() + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // Get the SHA for the specific branch cmd := exec.Command("git", "ls-remote", repoURL, fmt.Sprintf("refs/heads/%s", branch)) @@ -236,7 +239,8 @@ func resolveDefaultBranchHeadViaGit(repo string, verbose bool) (string, error) { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("Fetching default branch for %s via git ls-remote", repo))) } - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHost() + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // Get HEAD to find default branch cmd := exec.Command("git", "ls-remote", "--symref", repoURL, "HEAD") @@ -326,7 +330,8 @@ func downloadWorkflowContentViaGit(repo, path, ref string, verbose bool) ([]byte downloadLog.Printf("Attempting git fallback for downloading workflow content: %s/%s@%s", repo, path, ref) // Use git archive to get the file content without cloning - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHost() + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // git archive command: git archive --remote= cmd := exec.Command("git", "archive", "--remote="+repoURL, ref, path) @@ -366,7 +371,8 @@ func downloadWorkflowContentViaGitClone(repo, path, ref string, verbose bool) ([ } defer os.RemoveAll(tmpDir) - repoURL := fmt.Sprintf("https://github.com/%s.git", repo) + githubHost := getGitHubHost() + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repo) // Initialize git repository initCmd := exec.Command("git", "-C", tmpDir, "init") diff --git a/pkg/cli/github.go b/pkg/cli/github.go index 2ca44ca86b..76ca047be3 100644 --- a/pkg/cli/github.go +++ b/pkg/cli/github.go @@ -12,12 +12,25 @@ var githubLog = logger.New("cli:github") // getGitHubHost returns the GitHub host URL from environment variables. // It checks GITHUB_SERVER_URL first (GitHub Actions standard), // then falls back to GH_HOST (gh CLI standard), +// then derives from GITHUB_API_URL if available, // and finally defaults to https://github.com func getGitHubHost() string { host := os.Getenv("GITHUB_SERVER_URL") if host == "" { host = os.Getenv("GH_HOST") } + if host == "" { + // Try to derive from GITHUB_API_URL + if apiURL := os.Getenv("GITHUB_API_URL"); apiURL != "" { + // Convert API URL to server URL + // https://api.github.com -> https://github.com + // https://github.enterprise.com/api/v3 -> https://github.enterprise.com + host = strings.Replace(apiURL, "://api.", "://", 1) + host = strings.TrimSuffix(host, "/api/v3") + host = strings.TrimSuffix(host, "/api") + githubLog.Printf("Derived GitHub host from GITHUB_API_URL: %s", host) + } + } if host == "" { host = "https://github.com" githubLog.Print("Using default GitHub host: https://github.com") @@ -25,6 +38,11 @@ func getGitHubHost() string { githubLog.Printf("Resolved GitHub host: %s", host) } + // Ensure https:// prefix if only hostname is provided (from GH_HOST) + if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { + host = "https://" + host + } + // Remove trailing slash for consistency return strings.TrimSuffix(host, "/") } diff --git a/pkg/cli/github_test.go b/pkg/cli/github_test.go index f3857ff564..9935cf1913 100644 --- a/pkg/cli/github_test.go +++ b/pkg/cli/github_test.go @@ -11,44 +11,93 @@ func TestGetGitHubHost(t *testing.T) { name string serverURL string ghHost string + apiURL string expectedHost string }{ { name: "defaults to github.com", serverURL: "", ghHost: "", + apiURL: "", expectedHost: "https://github.com", }, { name: "uses GITHUB_SERVER_URL when set", serverURL: "https://github.enterprise.com", ghHost: "", + apiURL: "", expectedHost: "https://github.enterprise.com", }, { name: "uses GH_HOST when GITHUB_SERVER_URL not set", serverURL: "", ghHost: "https://github.company.com", + apiURL: "", + expectedHost: "https://github.company.com", + }, + { + name: "adds https:// prefix to GH_HOST if missing", + serverURL: "", + ghHost: "github.company.com", + apiURL: "", expectedHost: "https://github.company.com", }, { name: "GITHUB_SERVER_URL takes precedence over GH_HOST", serverURL: "https://github.enterprise.com", ghHost: "https://github.company.com", + apiURL: "", expectedHost: "https://github.enterprise.com", }, { name: "removes trailing slash from GITHUB_SERVER_URL", serverURL: "https://github.enterprise.com/", ghHost: "", + apiURL: "", expectedHost: "https://github.enterprise.com", }, { name: "removes trailing slash from GH_HOST", serverURL: "", ghHost: "https://github.company.com/", + apiURL: "", expectedHost: "https://github.company.com", }, + { + name: "derives from GITHUB_API_URL when others not set (api subdomain)", + serverURL: "", + ghHost: "", + apiURL: "https://api.github.com", + expectedHost: "https://github.com", + }, + { + name: "derives from GITHUB_API_URL (enterprise api subdomain)", + serverURL: "", + ghHost: "", + apiURL: "https://api.github.enterprise.com", + expectedHost: "https://github.enterprise.com", + }, + { + name: "derives from GITHUB_API_URL (path-based API)", + serverURL: "", + ghHost: "", + apiURL: "https://github.enterprise.com/api/v3", + expectedHost: "https://github.enterprise.com", + }, + { + name: "GITHUB_SERVER_URL takes precedence over GITHUB_API_URL", + serverURL: "https://github.primary.com", + ghHost: "", + apiURL: "https://api.github.secondary.com", + expectedHost: "https://github.primary.com", + }, + { + name: "GH_HOST takes precedence over GITHUB_API_URL", + serverURL: "", + ghHost: "github.primary.com", + apiURL: "https://api.github.secondary.com", + expectedHost: "https://github.primary.com", + }, } for _, tt := range tests { @@ -56,6 +105,7 @@ func TestGetGitHubHost(t *testing.T) { // Set test env vars (always set to ensure clean state) t.Setenv("GITHUB_SERVER_URL", tt.serverURL) t.Setenv("GH_HOST", tt.ghHost) + t.Setenv("GITHUB_API_URL", tt.apiURL) // Test host := getGitHubHost() diff --git a/pkg/cli/pr_command.go b/pkg/cli/pr_command.go index dbe3233446..678d5568ed 100644 --- a/pkg/cli/pr_command.go +++ b/pkg/cli/pr_command.go @@ -433,7 +433,8 @@ func createTransferPR(targetOwner, targetRepo string, prInfo *PRInfo, branchName // Add fork as remote if not already present remoteName := "fork" - forkRepoURL := fmt.Sprintf("https://github.com/%s/%s.git", forkOwner, forkRepo) + githubHost := getGitHubHost() + forkRepoURL := fmt.Sprintf("%s/%s/%s.git", githubHost, forkOwner, forkRepo) // Check if fork remote exists checkRemoteCmd := exec.Command("git", "remote", "get-url", remoteName) @@ -450,7 +451,7 @@ func createTransferPR(targetOwner, targetRepo string, prInfo *PRInfo, branchName // Also ensure target repository is set as upstream remote if not already present upstreamRemote := "upstream" - targetRepoURL := fmt.Sprintf("https://github.com/%s/%s.git", targetOwner, targetRepo) + targetRepoURL := fmt.Sprintf("%s/%s/%s.git", githubHost, targetOwner, targetRepo) // Check if upstream remote exists and points to the right repo checkUpstreamCmd := exec.Command("git", "remote", "get-url", upstreamRemote) diff --git a/pkg/cli/secret_collection.go b/pkg/cli/secret_collection.go index 8addc4a8d9..b73805abe6 100644 --- a/pkg/cli/secret_collection.go +++ b/pkg/cli/secret_collection.go @@ -145,7 +145,8 @@ func promptForCopilotPAT() (string, error) { fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Classic PATs (ghp_...) are not supported. You must use a fine-grained PAT (github_pat_...).")) fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Please create a token at:") - fmt.Fprintln(os.Stderr, console.FormatCommandMessage(" https://github.com/settings/personal-access-tokens/new")) + githubHost := getGitHubHost() + fmt.Fprintln(os.Stderr, console.FormatCommandMessage(fmt.Sprintf(" %s/settings/personal-access-tokens/new", githubHost))) fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "Configure the token with:") fmt.Fprintln(os.Stderr, " • Token name: Agentic Workflows Copilot") diff --git a/pkg/cli/trial_command.go b/pkg/cli/trial_command.go index 5507e4098a..fc10929303 100644 --- a/pkg/cli/trial_command.go +++ b/pkg/cli/trial_command.go @@ -465,7 +465,8 @@ func RunWorkflowTrials(ctx context.Context, workflowSpecs []string, opts TrialOp } // Generate workflow run URL - workflowRunURL := fmt.Sprintf("https://github.com/%s/actions/runs/%s", hostRepoSlug, runID) + githubHost := getGitHubHost() + workflowRunURL := fmt.Sprintf("%s/%s/actions/runs/%s", githubHost, hostRepoSlug, runID) fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Workflow run started with ID: %s (%s)", runID, workflowRunURL))) // Wait for workflow completion @@ -571,7 +572,8 @@ func RunWorkflowTrials(ctx context.Context, workflowSpecs []string, opts TrialOp if opts.DeleteHostRepo { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Host repository will be cleaned up")) } else { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Host repository preserved: https://github.com/%s", hostRepoSlug))) + githubHost := getGitHubHost() + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Host repository preserved: %s/%s", githubHost, hostRepoSlug))) } }, UseStderr: true, @@ -596,7 +598,8 @@ func getCurrentGitHubUsername() (string, error) { // showTrialConfirmation displays a confirmation prompt to the user using parsed workflow specs func showTrialConfirmation(parsedSpecs []*WorkflowSpec, logicalRepoSlug, cloneRepoSlug, hostRepoSlug string, deleteHostRepo bool, forceDeleteHostRepo bool, autoMergePRs bool, repeatCount int, directTrialMode bool, engineOverride string) error { - hostRepoSlugURL := fmt.Sprintf("https://github.com/%s", hostRepoSlug) + githubHost := getGitHubHost() + hostRepoSlugURL := fmt.Sprintf("%s/%s", githubHost, hostRepoSlug) var sections []string diff --git a/pkg/cli/trial_repository.go b/pkg/cli/trial_repository.go index 3529ebbf13..3a15f7b1c5 100644 --- a/pkg/cli/trial_repository.go +++ b/pkg/cli/trial_repository.go @@ -78,7 +78,8 @@ func ensureTrialRepository(repoSlug string, cloneRepoSlug string, forceDeleteHos if dryRun { prefix = "[DRY RUN] " } - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("%sUsing existing host repository: https://github.com/%s", prefix, repoSlug))) + githubHost := getGitHubHost() + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("%sUsing existing host repository: %s/%s", prefix, githubHost, repoSlug))) return nil } } @@ -93,10 +94,11 @@ func ensureTrialRepository(repoSlug string, cloneRepoSlug string, forceDeleteHos } if dryRun { + githubHost := getGitHubHost() fmt.Fprintln(os.Stderr, console.FormatInfoMessage("[DRY RUN] Would create repository with description: 'GitHub Agentic Workflows host repository'")) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("[DRY RUN] Would enable GitHub Actions permissions at: https://github.com/%s/settings/actions", repoSlug))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("[DRY RUN] Would enable GitHub Actions permissions at: %s/%s/settings/actions", githubHost, repoSlug))) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("[DRY RUN] Would enable discussions")) - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("[DRY RUN] Would create host repository: https://github.com/%s", repoSlug))) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("[DRY RUN] Would create host repository: %s/%s", githubHost, repoSlug))) return nil } @@ -111,19 +113,21 @@ func ensureTrialRepository(repoSlug string, cloneRepoSlug string, forceDeleteHos if verbose { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Repository already exists (detected via create error): %s", repoSlug))) } - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Using existing host repository: https://github.com/%s", repoSlug))) + githubHost := getGitHubHost() + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Using existing host repository: %s/%s", githubHost, repoSlug))) return nil } return fmt.Errorf("failed to create host repository: %w (output: %s)", err, string(output)) } // Show host repository creation message with URL - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Created host repository: https://github.com/%s", repoSlug))) + githubHost := getGitHubHost() + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Created host repository: %s/%s", githubHost, repoSlug))) // Prompt user to enable GitHub Actions permissions fmt.Fprintln(os.Stderr, console.FormatInfoMessage("")) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("IMPORTANT: You must enable GitHub Actions permissions for the repository.")) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("1. Go to: https://github.com/%s/settings/actions", repoSlug))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("1. Go to: %s/%s/settings/actions", githubHost, repoSlug))) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("2. Under 'Workflow permissions', select 'Allow GitHub Actions to create and approve pull requests'")) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("3. Click 'Save'")) fmt.Fprintln(os.Stderr, console.FormatInfoMessage("")) @@ -182,7 +186,8 @@ func cloneTrialHostRepository(repoSlug string, verbose bool) (string, error) { } // Clone the repository using the full slug - repoURL := fmt.Sprintf("https://github.com/%s.git", repoSlug) + githubHost := getGitHubHost() + repoURL := fmt.Sprintf("%s/%s.git", githubHost, repoSlug) output, err := workflow.RunGitCombined(fmt.Sprintf("Cloning %s...", repoSlug), "clone", repoURL, tempDir) if err != nil { @@ -526,7 +531,8 @@ func cloneRepoContentsIntoHost(cloneRepoSlug string, cloneRepoVersion string, ho defer os.RemoveAll(tempCloneDir) // Clone the source repository - cloneURL := fmt.Sprintf("https://github.com/%s.git", cloneRepoSlug) + githubHost := getGitHubHost() + cloneURL := fmt.Sprintf("%s/%s.git", githubHost, cloneRepoSlug) output, err := workflow.RunGitCombined(fmt.Sprintf("Cloning %s...", cloneRepoSlug), "clone", cloneURL, tempCloneDir) if err != nil { @@ -547,7 +553,7 @@ func cloneRepoContentsIntoHost(cloneRepoSlug string, cloneRepoVersion string, ho } // Add the host repository as a new remote - hostURL := fmt.Sprintf("https://github.com/%s.git", hostRepoSlug) + hostURL := fmt.Sprintf("%s/%s.git", githubHost, hostRepoSlug) remoteCmd := exec.Command("git", "remote", "add", "host", hostURL) if output, err := remoteCmd.CombinedOutput(); err != nil { return fmt.Errorf("failed to add host remote: %w (output: %s)", err, string(output)) diff --git a/pkg/cli/update_actions.go b/pkg/cli/update_actions.go index 226cf831de..3ef45daa47 100644 --- a/pkg/cli/update_actions.go +++ b/pkg/cli/update_actions.go @@ -271,7 +271,8 @@ func getLatestActionReleaseViaGit(repo, currentVersion string, allowMajor, verbo baseRepo := extractBaseRepo(repo) updateLog.Printf("Using base repository: %s for action: %s (git fallback)", baseRepo, repo) - repoURL := fmt.Sprintf("https://github.com/%s.git", baseRepo) + githubHost := getGitHubHost() + repoURL := fmt.Sprintf("%s/%s.git", githubHost, baseRepo) // List all tags cmd := exec.Command("git", "ls-remote", "--tags", repoURL) diff --git a/pkg/parser/remote_fetch.go b/pkg/parser/remote_fetch.go index dd191380ef..a19ce78bc0 100644 --- a/pkg/parser/remote_fetch.go +++ b/pkg/parser/remote_fetch.go @@ -21,6 +21,38 @@ import ( var remoteLog = logger.New("parser:remote_fetch") +// getGitHubHost returns the GitHub host URL from environment variables. +// It checks GITHUB_SERVER_URL first (GitHub Actions standard), +// then falls back to GH_HOST (gh CLI standard), +// then derives from GITHUB_API_URL if available, +// and finally defaults to https://github.com +func getGitHubHost() string { + host := os.Getenv("GITHUB_SERVER_URL") + if host == "" { + host = os.Getenv("GH_HOST") + } + if host == "" { + // Try to derive from GITHUB_API_URL + if apiURL := os.Getenv("GITHUB_API_URL"); apiURL != "" { + // Convert API URL to server URL + // https://api.github.com -> https://github.com + // https://github.enterprise.com/api/v3 -> https://github.enterprise.com + host = strings.Replace(apiURL, "://api.", "://", 1) + host = strings.TrimSuffix(host, "/api/v3") + host = strings.TrimSuffix(host, "/api") + } + } + if host == "" { + host = "https://github.com" + } + // Ensure https:// prefix if only hostname is provided (from GH_HOST) + if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { + host = "https://" + host + } + // Remove trailing slash for consistency + return strings.TrimSuffix(host, "/") +} + // isUnderWorkflowsDirectory checks if a file path is a top-level workflow file (not in shared subdirectory) func isUnderWorkflowsDirectory(filePath string) bool { // Normalize the path to use forward slashes @@ -306,7 +338,8 @@ func downloadIncludeFromWorkflowSpec(spec string, cache *ImportCache) (string, e func resolveRefToSHAViaGit(owner, repo, ref string) (string, error) { remoteLog.Printf("Attempting git ls-remote fallback for ref resolution: %s/%s@%s", owner, repo, ref) - repoURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, repo) + githubHost := getGitHubHost() + repoURL := fmt.Sprintf("%s/%s/%s.git", githubHost, owner, repo) // Try to resolve the ref using git ls-remote // Format: git ls-remote @@ -395,9 +428,10 @@ func resolveRefToSHA(owner, repo, ref string) (string, error) { func downloadFileViaGit(owner, repo, path, ref string) ([]byte, error) { remoteLog.Printf("Attempting git fallback for %s/%s/%s@%s", owner, repo, path, ref) + githubHost := getGitHubHost() // Use git archive to get the file content without cloning // This works for public repositories without authentication - repoURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, repo) + repoURL := fmt.Sprintf("%s/%s/%s.git", githubHost, owner, repo) // git archive command: git archive --remote= cmd := exec.Command("git", "archive", "--remote="+repoURL, ref, path) @@ -432,7 +466,8 @@ func downloadFileViaGitClone(owner, repo, path, ref string) ([]byte, error) { } defer os.RemoveAll(tmpDir) - repoURL := fmt.Sprintf("https://github.com/%s/%s.git", owner, repo) + githubHost := getGitHubHost() + repoURL := fmt.Sprintf("%s/%s/%s.git", githubHost, owner, repo) // Check if ref is a SHA (40 hex characters) isSHA := len(ref) == 40 && gitutil.IsHexString(ref) diff --git a/pkg/stringutil/pat_validation.go b/pkg/stringutil/pat_validation.go index e12b729564..cd598956cf 100644 --- a/pkg/stringutil/pat_validation.go +++ b/pkg/stringutil/pat_validation.go @@ -83,17 +83,31 @@ func IsOAuthToken(token string) bool { // Returns: // - error: An error with a descriptive message if the token is not valid, nil otherwise func ValidateCopilotPAT(token string) error { + return ValidateCopilotPATWithHost(token, "https://github.com") +} + +// ValidateCopilotPATWithHost validates that a token is a valid fine-grained PAT for Copilot, +// using the provided GitHub host for error messages. +// Returns an error if the token is not a fine-grained PAT with a descriptive error message. +// +// Parameters: +// - token: The token string to validate +// - githubHost: The GitHub host URL (e.g., "https://github.com" or "https://github.enterprise.com") +// +// Returns: +// - error: An error with a descriptive message if the token is not valid, nil otherwise +func ValidateCopilotPATWithHost(token string, githubHost string) error { patType := ClassifyPAT(token) switch patType { case PATTypeFineGrained: return nil case PATTypeClassic: - return fmt.Errorf("classic personal access tokens (ghp_...) are not supported for Copilot. Please create a fine-grained PAT at https://github.com/settings/personal-access-tokens/new") + return fmt.Errorf("classic personal access tokens (ghp_...) are not supported for Copilot. Please create a fine-grained PAT at %s/settings/personal-access-tokens/new", githubHost) case PATTypeOAuth: - return fmt.Errorf("OAuth tokens (gho_...) are not supported for Copilot. Please create a fine-grained PAT at https://github.com/settings/personal-access-tokens/new") + return fmt.Errorf("OAuth tokens (gho_...) are not supported for Copilot. Please create a fine-grained PAT at %s/settings/personal-access-tokens/new", githubHost) default: - return fmt.Errorf("unrecognized token format. Please create a fine-grained PAT (starting with 'github_pat_') at https://github.com/settings/personal-access-tokens/new") + return fmt.Errorf("unrecognized token format. Please create a fine-grained PAT (starting with 'github_pat_') at %s/settings/personal-access-tokens/new", githubHost) } } diff --git a/pkg/stringutil/pat_validation_test.go b/pkg/stringutil/pat_validation_test.go index 2adaa03bea..3e1f75ea88 100644 --- a/pkg/stringutil/pat_validation_test.go +++ b/pkg/stringutil/pat_validation_test.go @@ -181,3 +181,66 @@ func TestGetPATTypeDescription(t *testing.T) { }) } } + +func TestValidateCopilotPATWithHost(t *testing.T) { + tests := []struct { + name string + token string + githubHost string + expectError bool + errorMsg string + }{ + { + name: "valid fine-grained PAT with default host", + token: "github_pat_abc123xyz", + githubHost: "https://github.com", + expectError: false, + }, + { + name: "valid fine-grained PAT with enterprise host", + token: "github_pat_abc123xyz", + githubHost: "https://github.enterprise.com", + expectError: false, + }, + { + name: "classic PAT with default host", + token: "ghp_abc123xyz", + githubHost: "https://github.com", + expectError: true, + errorMsg: "https://github.com/settings/personal-access-tokens/new", + }, + { + name: "classic PAT with enterprise host", + token: "ghp_abc123xyz", + githubHost: "https://github.enterprise.com", + expectError: true, + errorMsg: "https://github.enterprise.com/settings/personal-access-tokens/new", + }, + { + name: "OAuth token with enterprise host", + token: "gho_abc123xyz", + githubHost: "https://github.enterprise.com", + expectError: true, + errorMsg: "https://github.enterprise.com/settings/personal-access-tokens/new", + }, + { + name: "unknown token with enterprise host", + token: "random_token", + githubHost: "https://github.enterprise.com", + expectError: true, + errorMsg: "https://github.enterprise.com/settings/personal-access-tokens/new", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateCopilotPATWithHost(tt.token, tt.githubHost) + if tt.expectError { + require.Error(t, err, "should return error for invalid token") + assert.Contains(t, err.Error(), tt.errorMsg, "error message should contain expected GitHub host URL") + } else { + assert.NoError(t, err, "should not return error for valid token") + } + }) + } +}