From 1c79fe24d0bf1a4b1727dd7a782288f9d8b07666 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 8 May 2026 06:59:10 +0200 Subject: [PATCH] Support GitHub Enterprise for the pull-requests feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branches-panel PR icons only worked for github.com remotes. There was no fundamental reason — the auth library we already vendor (cli/go-gh) supports enterprise tokens out of the box (GH_ENTERPRISE_TOKEN, gh auth's keyring), and the user-facing 'services' config has long been the documented way to tell lazygit "this domain is a github service" for the View-PR-URL feature. The fetcher just hardcoded github.com in three places: - a substring check on the remote URL to decide we're "in a github repo", - the GraphQL endpoint (always api.github.com/graphql), and - the auth lookup (always against the default host). Plumb the resolved web domain through instead. Detection now goes through the hosting_service ("is this remote's provider 'github'?"), which means a user with services: { 'git.acme.com': 'github:git.acme.com' } configured gets PR icons on their GHE remotes too. Replacing the substring check with a provider check also tightens a latent bug in getGithubRemotes: it previously accepted any remote whose URL parsed with the default regex, including gitlab and bitbucket — masked today only by the InGithubRepo gate, but exposed once the gate goes away. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/commands/git_commands/github.go | 35 +++++---- pkg/commands/git_commands/github_test.go | 19 +++++ pkg/gui/controllers/helpers/refresh_helper.go | 76 +++++++++++-------- .../helpers/refresh_helper_test.go | 55 +++++++++++++- 4 files changed, 139 insertions(+), 46 deletions(-) diff --git a/pkg/commands/git_commands/github.go b/pkg/commands/git_commands/github.go index 85893615d..02bbebdc2 100644 --- a/pkg/commands/git_commands/github.go +++ b/pkg/commands/git_commands/github.go @@ -138,19 +138,16 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin return queryString, variables } -func (self *GitHubCommands) GetAuthToken() string { - defaultHost, _ := auth.DefaultHost() - token, _ := auth.TokenForHost(defaultHost) +func (self *GitHubCommands) GetAuthToken(host string) string { + token, _ := auth.TokenForHost(host) return token } -// FetchRecentPRs fetches recent pull requests using GraphQL. -func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models.Remote, token string) ([]*models.GithubPullRequest, error) { - repoOwner, repoName, err := self.GetBaseRepoOwnerAndName(baseRemote) - if err != nil { - return nil, err - } - +// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo +// identifies the GitHub instance (github.com or a GitHub Enterprise Server) +// and the owner/repo to query against. +func (self *GitHubCommands) FetchRecentPRs(branches []string, serviceInfo *hosting_service.ServiceInfo, token string) ([]*models.GithubPullRequest, error) { + endpoint := graphQLEndpoint(serviceInfo.WebDomain) t := time.Now() var g errgroup.Group @@ -171,7 +168,7 @@ func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models // Launch a goroutine for each chunk of branches g.Go(func() error { - prs, err := self.fetchRecentPRsAux(repoOwner, repoName, branchChunk, token) + prs, err := self.fetchRecentPRsAux(endpoint, serviceInfo.Owner, serviceInfo.Repository, branchChunk, token) if err != nil { return err } @@ -181,7 +178,7 @@ func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models } // Wait for all goroutines, then close the channel so the range loop exits - err = g.Wait() + err := g.Wait() close(results) if err != nil { return nil, err @@ -198,14 +195,14 @@ func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models return allPRs, nil } -func (self *GitHubCommands) fetchRecentPRsAux(repoOwner string, repoName string, branches []string, token string) ([]*models.GithubPullRequest, error) { +func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string, repoName string, branches []string, token string) ([]*models.GithubPullRequest, error) { queryString, variables := fetchPullRequestsQuery(branches, repoOwner, repoName) bodyBytes, err := json.Marshal(graphQLRequest{Query: queryString, Variables: variables}) if err != nil { return nil, err } - req, err := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewBuffer(bodyBytes)) + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(bodyBytes)) if err != nil { return nil, err } @@ -336,6 +333,16 @@ func getRemotesToOwnersMap(remotes []*models.Remote) map[string]string { return res } +// graphQLEndpoint returns the GraphQL API URL for a GitHub host. github.com +// uses a dedicated api. subdomain; GitHub Enterprise Server hangs the API off +// the web host under /api/graphql. +func graphQLEndpoint(host string) string { + if auth.NormalizeHostname(host) == "github.com" { + return "https://api.github.com/graphql" + } + return "https://" + host + "/api/graphql" +} + func (self *GitHubCommands) InGithubRepo(remotes []*models.Remote) bool { if len(remotes) == 0 { return false diff --git a/pkg/commands/git_commands/github_test.go b/pkg/commands/git_commands/github_test.go index d9d55ffd1..b332ba12a 100644 --- a/pkg/commands/git_commands/github_test.go +++ b/pkg/commands/git_commands/github_test.go @@ -57,6 +57,25 @@ func TestGetRepoInfoFromURL(t *testing.T) { } } +func TestGraphQLEndpoint(t *testing.T) { + cases := []struct { + host string + expected string + }{ + {"github.com", "https://api.github.com/graphql"}, + {"www.github.com", "https://api.github.com/graphql"}, + {"GITHUB.com", "https://api.github.com/graphql"}, + {"ghe.example.com", "https://ghe.example.com/api/graphql"}, + {"ghe.example.com:8443", "https://ghe.example.com:8443/api/graphql"}, + } + + for _, c := range cases { + t.Run(c.host, func(t *testing.T) { + assert.Equal(t, c.expected, graphQLEndpoint(c.host)) + }) + } +} + func TestGenerateGithubPullRequestMap(t *testing.T) { cases := []struct { name string diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 77de9ca4a..6c554deff 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -8,6 +8,7 @@ import ( "github.com/jesseduffield/generics/set" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" + "github.com/jesseduffield/lazygit/pkg/commands/hosting_service" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" @@ -812,39 +813,33 @@ func (self *RefreshHelper) refreshGithubPullRequests() { self.c.Mutexes().RefreshingPullRequestsMutex.Lock() defer self.c.Mutexes().RefreshingPullRequestsMutex.Unlock() - if !self.c.Git().GitHub.InGithubRepo(self.c.Model().Remotes) { + githubRemotes := getAuthenticatedGithubRemotes(self.getGithubRemotes(), self.c.Git().GitHub.GetAuthToken) + if len(githubRemotes) == 0 { self.c.Model().PullRequests = nil self.c.Model().PullRequestsMap = nil return } - authToken := self.c.Git().GitHub.GetAuthToken() - if authToken == "" { - self.c.Model().PullRequests = nil - self.c.Model().PullRequestsMap = nil - return - } - - githubRemotes := self.getGithubRemotes() - baseRemote := getGithubBaseRemote(githubRemotes, self.c.Git().GitHub.ConfiguredBaseRemoteName()) - if baseRemote == nil { + baseInfo := getGithubBaseRemote(githubRemotes, self.c.Git().GitHub.ConfiguredBaseRemoteName()) + if baseInfo == nil { self.c.Model().PullRequests = nil self.c.Model().PullRequestsMap = nil - if len(githubRemotes) > 0 && !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] { - self.promptForBaseGithubRepo(authToken, githubRemotes) + if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] { + self.promptForBaseGithubRepo(githubRemotes) } return } - if err := self.setGithubPullRequests(authToken, baseRemote); err != nil { + if err := self.setGithubPullRequests(baseInfo); err != nil { self.c.LogAction(fmt.Sprintf("Error fetching pull requests from GitHub: %s", err.Error())) } } type githubRemoteInfo struct { - remote *models.Remote - repoName string + remote *models.Remote + serviceInfo hosting_service.ServiceInfo + authToken string } func (self *RefreshHelper) getGithubRemotes() []githubRemoteInfo { @@ -852,23 +847,44 @@ func (self *RefreshHelper) getGithubRemotes() []githubRemoteInfo { if len(remote.Urls) == 0 { return githubRemoteInfo{}, false } - repoName, err := self.c.Git().HostingService.GetRepoNameFromRemoteURL(remote.Urls[0]) - if err != nil { + serviceInfo, err := self.c.Git().HostingService.GetServiceInfo(remote.Urls[0]) + if err != nil || serviceInfo.Provider != "github" { return githubRemoteInfo{}, false } - return githubRemoteInfo{remote: remote, repoName: repoName}, true + return githubRemoteInfo{remote: remote, serviceInfo: serviceInfo}, true }) } -func getGithubBaseRemote(githubRemotes []githubRemoteInfo, configuredRemoteName string) *models.Remote { - findRemoteByName := func(name string) *models.Remote { +// getAuthenticatedGithubRemotes drops remotes for which no auth token is +// available and attaches the resolved token to the rest. Token lookups are +// cached by host so that multiple remotes pointing at the same instance +// (e.g. origin + a fork on github.com) only trigger one lookup. +func getAuthenticatedGithubRemotes(githubRemotes []githubRemoteInfo, getAuthToken func(host string) string) []githubRemoteInfo { + tokensByHost := map[string]string{} + return lo.FilterMap(githubRemotes, func(info githubRemoteInfo, _ int) (githubRemoteInfo, bool) { + host := info.serviceInfo.WebDomain + token, cached := tokensByHost[host] + if !cached { + token = getAuthToken(host) + tokensByHost[host] = token + } + if token == "" { + return githubRemoteInfo{}, false + } + info.authToken = token + return info, true + }) +} + +func getGithubBaseRemote(githubRemotes []githubRemoteInfo, configuredRemoteName string) *githubRemoteInfo { + findRemoteByName := func(name string) *githubRemoteInfo { info, ok := lo.Find(githubRemotes, func(info githubRemoteInfo) bool { return info.remote.Name == name }) if !ok { return nil } - return info.remote + return &info } if configuredRemoteName != "" { @@ -876,29 +892,29 @@ func getGithubBaseRemote(githubRemotes []githubRemoteInfo, configuredRemoteName } if len(githubRemotes) == 1 { - return githubRemotes[0].remote + return &githubRemotes[0] } // Not sure if "upstream" is really a common convention for the name of the remote that PRs are // made against, but if it exists it's pretty likely to be the one we want. - if remote := findRemoteByName("upstream"); remote != nil { - return remote + if info := findRemoteByName("upstream"); info != nil { + return info } return nil } -func (self *RefreshHelper) promptForBaseGithubRepo(authToken string, githubRemotes []githubRemoteInfo) { +func (self *RefreshHelper) promptForBaseGithubRepo(githubRemotes []githubRemoteInfo) { menuItems := lo.Map(githubRemotes, func(info githubRemoteInfo, _ int) *types.MenuItem { return &types.MenuItem{ - LabelColumns: []string{info.remote.Name, style.FgCyan.Sprint(info.repoName)}, + LabelColumns: []string{info.remote.Name, style.FgCyan.Sprint(info.serviceInfo.RepoName)}, OnPress: func() error { return self.c.WithWaitingStatus(self.c.Tr.FetchingPullRequests, func(gocui.Task) error { if err := self.c.Git().GitHub.SetConfiguredBaseRemoteName(info.remote.Name); err != nil { self.c.Log.Error(err) } - if err := self.setGithubPullRequests(authToken, info.remote); err != nil { + if err := self.setGithubPullRequests(&info); err != nil { self.c.LogAction(fmt.Sprintf("Error fetching pull requests from GitHub: %s", err.Error())) } return nil @@ -928,7 +944,7 @@ func (self *RefreshHelper) rebuildPullRequestsMap() { ) } -func (self *RefreshHelper) setGithubPullRequests(authToken string, baseRemote *models.Remote) error { +func (self *RefreshHelper) setGithubPullRequests(baseInfo *githubRemoteInfo) error { if len(self.c.Model().Branches) == 0 { return nil } @@ -940,7 +956,7 @@ func (self *RefreshHelper) setGithubPullRequests(authToken string, baseRemote *m return branch.UpstreamBranch }) - prs, err := self.c.Git().GitHub.FetchRecentPRs(branchNames, baseRemote, authToken) + prs, err := self.c.Git().GitHub.FetchRecentPRs(branchNames, &baseInfo.serviceInfo, baseInfo.authToken) if err != nil { return err } diff --git a/pkg/gui/controllers/helpers/refresh_helper_test.go b/pkg/gui/controllers/helpers/refresh_helper_test.go index dea3b8b81..cebd044c4 100644 --- a/pkg/gui/controllers/helpers/refresh_helper_test.go +++ b/pkg/gui/controllers/helpers/refresh_helper_test.go @@ -3,6 +3,7 @@ package helpers import ( "testing" + "github.com/jesseduffield/lazygit/pkg/commands/hosting_service" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/samber/lo" "github.com/stretchr/testify/assert" @@ -60,14 +61,64 @@ func TestGetGithubBaseRemote(t *testing.T) { assert.Nil(t, result) } else { assert.NotNil(t, result) - assert.Equal(t, c.expected, result.Name) + assert.Equal(t, c.expected, result.remote.Name) } }) } } +func TestGetAuthenticatedGithubRemotes(t *testing.T) { + githubRemotes := []githubRemoteInfo{ + makeGithubRemoteInfo("origin", "github.com"), + makeGithubRemoteInfo("fork", "github.com"), + makeGithubRemoteInfo("enterprise", "ghe.example.com"), + makeGithubRemoteInfo("missing-auth", "no-token.example.com"), + } + + callsByHost := map[string]int{} + result := getAuthenticatedGithubRemotes(githubRemotes, func(host string) string { + callsByHost[host]++ + switch host { + case "github.com": + return "github-token" + case "ghe.example.com": + return "ghe-token" + default: + return "" + } + }) + + assert.Equal(t, []githubRemoteInfo{ + makeAuthenticatedGithubRemoteInfo("origin", "github.com", "github-token"), + makeAuthenticatedGithubRemoteInfo("fork", "github.com", "github-token"), + makeAuthenticatedGithubRemoteInfo("enterprise", "ghe.example.com", "ghe-token"), + }, result) + // Two remotes share github.com; the lookup runs only once. + assert.Equal(t, map[string]int{ + "github.com": 1, + "ghe.example.com": 1, + "no-token.example.com": 1, + }, callsByHost) +} + func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo { return lo.Map(names, func(name string, _ int) githubRemoteInfo { - return githubRemoteInfo{remote: &models.Remote{Name: name}, repoName: name} + return makeGithubRemoteInfo(name, name) }) } + +func makeGithubRemoteInfo(name string, webDomain string) githubRemoteInfo { + return githubRemoteInfo{ + remote: &models.Remote{Name: name}, + serviceInfo: hosting_service.ServiceInfo{ + RepoName: name, + WebDomain: webDomain, + }, + } +} + +func makeAuthenticatedGithubRemoteInfo(name string, webDomain string, authToken string) githubRemoteInfo { + info := makeGithubRemoteInfo(name, webDomain) + info.authToken = authToken + return info +}