diff --git a/README.md b/README.md index 1712e5584539a97fa0fa2afaf61a8329509157e0..df4f13c79e99b7f75e2c1235a95becb1fbd64dcf 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,10 @@ To build from source: ## Authentication +When running `glab auth login` interactively inside a Git repository, `glab` automatically +detects GitLab instances from your Git remotes and presents them as options. This saves you +from having to manually type the hostname. + ### OAuth (GitLab.com) To authenticate your installation of `glab` with an OAuth application connected to GitLab.com: diff --git a/docs/source/auth/login.md b/docs/source/auth/login.md index be33729a0cfeab4a7f2a7d6fefe929f2bc5905a2..938fc91cbab40230ce2434b172059e4d8b60b81c 100644 --- a/docs/source/auth/login.md +++ b/docs/source/auth/login.md @@ -19,6 +19,10 @@ You can pass in a token on standard input by using `--stdin`. The minimum required scopes for the token are: `api`, `write_repository`. Configuration and credentials are stored in the global configuration file (default `~/.config/glab-cli/config.yml`) +When running in interactive mode inside a Git repository, `glab` will automatically detect +GitLab instances from your Git remotes and present them as options, saving you from having to +manually type the hostname. + ```plaintext glab auth login [flags] ``` @@ -27,6 +31,7 @@ glab auth login [flags] ```console # Start interactive setup +# (If in a Git repository, glab will detect and suggest GitLab instances from remotes) $ glab auth login # Authenticate against `gitlab.com` by reading the token from a file diff --git a/internal/commands/auth/login/helper.go b/internal/commands/auth/login/helper.go index b17e09ca0bafb464c1cc52721811e1d971e972f2..1be82cf28f73305535f1084cb9b6d9c5e831288c 100644 --- a/internal/commands/auth/login/helper.go +++ b/internal/commands/auth/login/helper.go @@ -4,6 +4,8 @@ import ( "bufio" "fmt" "net/url" + "slices" + "sort" "strings" "gitlab.com/gitlab-org/cli/internal/mcpannotations" @@ -11,6 +13,8 @@ import ( "gitlab.com/gitlab-org/cli/internal/cmdutils" "gitlab.com/gitlab-org/cli/internal/config" + "gitlab.com/gitlab-org/cli/internal/git" + "gitlab.com/gitlab-org/cli/internal/glinstance" "gitlab.com/gitlab-org/cli/internal/iostreams" "github.com/spf13/cobra" @@ -148,3 +152,155 @@ func (o *options) run() error { return nil } + +// DetectedHost represents a GitLab hostname detected from git remotes +type DetectedHost struct { + Hostname string + Remotes []string // Names of remotes pointing to this host + Score int // Priority score for sorting + Authenticated bool // Whether user is already authenticated to this host +} + +// detectGitLabHosts detects GitLab hostnames from git remotes in the current repository +func detectGitLabHosts(cfg config.Config) ([]DetectedHost, error) { + // Check if we're in a git repository + _, err := git.ToplevelDir() + if err != nil { + // Not in a git repo, return empty list + return nil, err + } + + // Get git remotes + gitRemotes, err := git.Remotes() + if err != nil { + return nil, err + } + + if len(gitRemotes) == 0 { + return nil, nil + } + + // Get authenticated hosts from config + authenticatedHosts := make(map[string]bool) + if hosts, err := cfg.Hosts(); err == nil { + for _, host := range hosts { + authenticatedHosts[host] = true + } + } + + // Group remotes by hostname + hostMap := make(map[string][]string) + for _, remote := range gitRemotes { + hostname := extractHostFromRemote(remote) + if hostname == "" { + continue + } + hostMap[hostname] = append(hostMap[hostname], remote.Name) + } + + // Convert to DetectedHost slice + var detectedHosts []DetectedHost + for hostname, remoteNames := range hostMap { + host := DetectedHost{ + Hostname: hostname, + Remotes: remoteNames, + Authenticated: authenticatedHosts[hostname], + } + detectedHosts = append(detectedHosts, host) + } + + // Prioritize and sort + detectedHosts = prioritizeHosts(detectedHosts) + + return detectedHosts, nil +} + +// extractHostFromRemote extracts the hostname from a git remote +func extractHostFromRemote(remote *git.Remote) string { + // Try FetchURL first, then PushURL + var u *url.URL + if remote.FetchURL != nil { + u = remote.FetchURL + } else if remote.PushURL != nil { + u = remote.PushURL + } + + if u == nil { + return "" + } + + return u.Host +} + +// prioritizeHosts sorts detected hosts by priority score +func prioritizeHosts(hosts []DetectedHost) []DetectedHost { + // Calculate scores + for i := range hosts { + hosts[i].Score = calculateHostScore(&hosts[i]) + } + + // Sort by score (descending) + sort.Slice(hosts, func(i, j int) bool { + return hosts[i].Score > hosts[j].Score + }) + + return hosts +} + +// calculateHostScore calculates a priority score for a detected host +func calculateHostScore(host *DetectedHost) int { + score := 0 + + // Sum scores from all remotes pointing to this host + for _, remoteName := range host.Remotes { + score += remoteNameScore(remoteName) + } + + // Already authenticated hosts get high priority + if host.Authenticated { + score += 10 + } + + // gitlab.com gets a boost as the default instance + if host.Hostname == glinstance.DefaultHostname { + score += 5 + } + + return score +} + +// remoteNameScore assigns a priority score based on remote name +func remoteNameScore(name string) int { + switch strings.ToLower(name) { + case "origin": + return 3 + case "upstream": + return 2 + case "gitlab": + return 1 + default: + return 0 + } +} + +// formatDetectedHost formats a detected host for display in survey prompt +func formatDetectedHost(host DetectedHost) string { + // Handle nil or empty remotes + if len(host.Remotes) == 0 { + if host.Authenticated { + return fmt.Sprintf("%s [authenticated]", host.Hostname) + } + return host.Hostname + } + + // Sort remote names for consistent display + remotes := make([]string, len(host.Remotes)) + copy(remotes, host.Remotes) + slices.Sort(remotes) + + result := fmt.Sprintf("%s (%s)", host.Hostname, strings.Join(remotes, ", ")) + if host.Authenticated { + result += " [authenticated]" + } + return result +} diff --git a/internal/commands/auth/login/helper_test.go b/internal/commands/auth/login/helper_test.go index 50cee7193e69f4b84795479ffc0442368c5e81a1..0edc5abfc47de5e7b11844eb0409a6ba83b24321 100644 --- a/internal/commands/auth/login/helper_test.go +++ b/internal/commands/auth/login/helper_test.go @@ -320,3 +320,190 @@ func Test_helperRun(t *testing.T) { }) } } + +func Test_remoteNameScore(t *testing.T) { + tests := []struct { + name string + remoteName string + expectedScore int + }{ + {"origin gets highest score", "origin", 3}, + {"upstream gets second highest", "upstream", 2}, + {"gitlab gets third", "gitlab", 1}, + {"other names get zero", "fork", 0}, + {"case insensitive - ORIGIN", "ORIGIN", 3}, + {"case insensitive - Upstream", "Upstream", 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := remoteNameScore(tt.remoteName) + assert.Equal(t, tt.expectedScore, score) + }) + } +} + +func Test_calculateHostScore(t *testing.T) { + tests := []struct { + name string + host DetectedHost + expectedScore int + }{ + { + name: "gitlab.com with origin remote", + host: DetectedHost{ + Hostname: "gitlab.com", + Remotes: []string{"origin"}, + Authenticated: false, + }, + expectedScore: 8, // gitlab.com bonus (5) + origin (3) + }, + { + name: "authenticated self-hosted with origin", + host: DetectedHost{ + Hostname: "gitlab.company.com", + Remotes: []string{"origin"}, + Authenticated: true, + }, + expectedScore: 13, // authenticated (10) + origin (3) + }, + { + name: "multiple remotes to same host", + host: DetectedHost{ + Hostname: "gitlab.company.com", + Remotes: []string{"origin", "upstream"}, + Authenticated: false, + }, + expectedScore: 5, // origin (3) + upstream (2) + }, + { + name: "unknown remote name", + host: DetectedHost{ + Hostname: "gitlab.example.com", + Remotes: []string{"fork"}, + Authenticated: false, + }, + expectedScore: 0, // fork (0) + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + score := calculateHostScore(&tt.host) + assert.Equal(t, tt.expectedScore, score) + }) + } +} + +func Test_prioritizeHosts(t *testing.T) { + tests := []struct { + name string + hosts []DetectedHost + expectedOrder []string // Expected order of hostnames + }{ + { + name: "authenticated host prioritized", + hosts: []DetectedHost{ + {Hostname: "gitlab.com", Remotes: []string{"fork"}, Authenticated: false}, + {Hostname: "gitlab.company.com", Remotes: []string{"origin"}, Authenticated: true}, + }, + expectedOrder: []string{"gitlab.company.com", "gitlab.com"}, + }, + { + name: "origin beats upstream", + hosts: []DetectedHost{ + {Hostname: "gitlab.upstream.com", Remotes: []string{"upstream"}, Authenticated: false}, + {Hostname: "gitlab.origin.com", Remotes: []string{"origin"}, Authenticated: false}, + }, + expectedOrder: []string{"gitlab.origin.com", "gitlab.upstream.com"}, + }, + { + name: "gitlab.com gets boost", + hosts: []DetectedHost{ + {Hostname: "gitlab.example.com", Remotes: []string{"origin"}, Authenticated: false}, + {Hostname: "gitlab.com", Remotes: []string{"fork"}, Authenticated: false}, + }, + expectedOrder: []string{"gitlab.com", "gitlab.example.com"}, // gitlab.com bonus (5) + fork (0) = 5 > origin (3) + }, + { + name: "multiple remotes increase score", + hosts: []DetectedHost{ + {Hostname: "gitlab.single.com", Remotes: []string{"origin"}, Authenticated: false}, + {Hostname: "gitlab.multi.com", Remotes: []string{"origin", "upstream"}, Authenticated: false}, + }, + expectedOrder: []string{"gitlab.multi.com", "gitlab.single.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sorted := prioritizeHosts(tt.hosts) + var actualOrder []string + for _, host := range sorted { + actualOrder = append(actualOrder, host.Hostname) + } + assert.Equal(t, tt.expectedOrder, actualOrder) + }) + } +} + +func Test_formatDetectedHost(t *testing.T) { + tests := []struct { + name string + host DetectedHost + expected string + }{ + { + name: "single remote, not authenticated", + host: DetectedHost{ + Hostname: "gitlab.com", + Remotes: []string{"origin"}, + Authenticated: false, + }, + expected: "gitlab.com (origin)", + }, + { + name: "multiple remotes, authenticated", + host: DetectedHost{ + Hostname: "gitlab.company.com", + Remotes: []string{"origin", "upstream"}, + Authenticated: true, + }, + expected: "gitlab.company.com (origin, upstream) [authenticated]", + }, + { + name: "remotes sorted alphabetically", + host: DetectedHost{ + Hostname: "gitlab.example.com", + Remotes: []string{"upstream", "fork", "origin"}, + Authenticated: false, + }, + expected: "gitlab.example.com (fork, origin, upstream)", + }, + { + name: "nil remotes, not authenticated", + host: DetectedHost{ + Hostname: "gitlab.example.com", + Remotes: nil, + Authenticated: false, + }, + expected: "gitlab.example.com", + }, + { + name: "empty remotes, authenticated", + host: DetectedHost{ + Hostname: "gitlab.company.com", + Remotes: []string{}, + Authenticated: true, + }, + expected: "gitlab.company.com [authenticated]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatDetectedHost(tt.host) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/commands/auth/login/login.go b/internal/commands/auth/login/login.go index 572e92da88e51129fc9941913f22b0e17c948990..8c2a0429ca3168c1233016d420fdc10bc15d50d7 100644 --- a/internal/commands/auth/login/login.go +++ b/internal/commands/auth/login/login.go @@ -69,9 +69,14 @@ func NewCmdLogin(f cmdutils.Factory) *cobra.Command { You can pass in a token on standard input by using %[1]s--stdin%[1]s. The minimum required scopes for the token are: %[1]sapi%[1]s, %[1]swrite_repository%[1]s. Configuration and credentials are stored in the global configuration file (default %[1]s~/.config/glab-cli/config.yml%[1]s) + + When running in interactive mode inside a Git repository, %[1]sglab%[1]s will automatically detect + GitLab instances from your Git remotes and present them as options, saving you from having to + manually type the hostname. `, "`"), Example: heredoc.Docf(` # Start interactive setup + # (If in a Git repository, glab will detect and suggest GitLab instances from remotes) $ glab auth login # Authenticate against %[1]sgitlab.com%[1]s by reading the token from a file @@ -246,46 +251,93 @@ func loginRun(ctx context.Context, opts *LoginOptions) error { isSelfHosted := false if hostname == "" { - var hostType int - options := []string{} - if hosts, err := cfg.Hosts(); err == nil { - options = append(options, hosts...) - } - if !slices.Contains(options, opts.defaultHostname) { - options = append(options, opts.defaultHostname) - } - options = append(options, "GitLab Self-Managed or GitLab Dedicated instance") - - err := survey.AskOne(&survey.Select{ - Message: "What GitLab instance do you want to sign in to?", - Options: options, - }, &hostType) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) - } - - isSelfHosted = hostType == len(options)-1 + // Try to detect GitLab hosts from git remotes + detectedHosts, detectErr := detectGitLabHosts(cfg) + + if detectErr == nil && len(detectedHosts) > 0 { + // We have detected hosts, present them to the user + options := []string{} + for _, host := range detectedHosts { + options = append(options, formatDetectedHost(host)) + } + options = append(options, "Enter a different hostname") - if isSelfHosted { - hostname = opts.defaultHostname - apiHostname = hostname - err := survey.AskOne(&survey.Input{ - Message: "GitLab hostname:", - }, &hostname, survey.WithValidator(hostnameValidator)) + var selectedIndex int + err := survey.AskOne(&survey.Select{ + Message: "Found GitLab instances in git remotes. Select one:", + Options: options, + }, &selectedIndex) if err != nil { return fmt.Errorf("could not prompt: %w", err) } - err = survey.AskOne(&survey.Input{ - Message: "API hostname:", - Help: "For instances with a different hostname for the API endpoint.", - Default: hostname, - }, &apiHostname, survey.WithValidator(hostnameValidator)) + + // Check if user selected "Enter a different hostname" (last option) + if selectedIndex == len(options)-1 { + // Fall back to manual entry + hostname = opts.defaultHostname + apiHostname = hostname + err := survey.AskOne(&survey.Input{ + Message: "GitLab hostname:", + }, &hostname, survey.WithValidator(hostnameValidator)) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + err = survey.AskOne(&survey.Input{ + Message: "API hostname:", + Help: "For instances with a different hostname for the API endpoint.", + Default: hostname, + }, &apiHostname, survey.WithValidator(hostnameValidator)) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } else { + // User selected a detected host by index + hostname = detectedHosts[selectedIndex].Hostname + apiHostname = hostname + } + } else { + // No detected hosts or detection failed, fall back to original behavior + var hostType int + options := []string{} + if hosts, err := cfg.Hosts(); err == nil { + options = append(options, hosts...) + } + if !slices.Contains(options, opts.defaultHostname) { + options = append(options, opts.defaultHostname) + } + options = append(options, "GitLab Self-Managed or GitLab Dedicated instance") + + err := survey.AskOne(&survey.Select{ + Message: "What GitLab instance do you want to sign in to?", + Options: options, + }, &hostType) if err != nil { return fmt.Errorf("could not prompt: %w", err) } - } else { - hostname = options[hostType] - apiHostname = hostname + + isSelfHosted = hostType == len(options)-1 + + if isSelfHosted { + hostname = opts.defaultHostname + apiHostname = hostname + err := survey.AskOne(&survey.Input{ + Message: "GitLab hostname:", + }, &hostname, survey.WithValidator(hostnameValidator)) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + err = survey.AskOne(&survey.Input{ + Message: "API hostname:", + Help: "For instances with a different hostname for the API endpoint.", + Default: hostname, + }, &apiHostname, survey.WithValidator(hostnameValidator)) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } else { + hostname = options[hostType] + apiHostname = hostname + } } } else { isSelfHosted = glinstance.IsSelfHosted(hostname)