mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-06-20 01:19:23 +02:00
Support GitHub Enterprise for the pull-requests feature
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) <noreply@anthropic.com>
This commit is contained in:
@@ -138,19 +138,16 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
|
|||||||
return queryString, variables
|
return queryString, variables
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *GitHubCommands) GetAuthToken() string {
|
func (self *GitHubCommands) GetAuthToken(host string) string {
|
||||||
defaultHost, _ := auth.DefaultHost()
|
token, _ := auth.TokenForHost(host)
|
||||||
token, _ := auth.TokenForHost(defaultHost)
|
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchRecentPRs fetches recent pull requests using GraphQL.
|
// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo
|
||||||
func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models.Remote, token string) ([]*models.GithubPullRequest, error) {
|
// identifies the GitHub instance (github.com or a GitHub Enterprise Server)
|
||||||
repoOwner, repoName, err := self.GetBaseRepoOwnerAndName(baseRemote)
|
// and the owner/repo to query against.
|
||||||
if err != nil {
|
func (self *GitHubCommands) FetchRecentPRs(branches []string, serviceInfo *hosting_service.ServiceInfo, token string) ([]*models.GithubPullRequest, error) {
|
||||||
return nil, err
|
endpoint := graphQLEndpoint(serviceInfo.WebDomain)
|
||||||
}
|
|
||||||
|
|
||||||
t := time.Now()
|
t := time.Now()
|
||||||
|
|
||||||
var g errgroup.Group
|
var g errgroup.Group
|
||||||
@@ -171,7 +168,7 @@ func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models
|
|||||||
|
|
||||||
// Launch a goroutine for each chunk of branches
|
// Launch a goroutine for each chunk of branches
|
||||||
g.Go(func() error {
|
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 {
|
if err != nil {
|
||||||
return err
|
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
|
// Wait for all goroutines, then close the channel so the range loop exits
|
||||||
err = g.Wait()
|
err := g.Wait()
|
||||||
close(results)
|
close(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -198,14 +195,14 @@ func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models
|
|||||||
return allPRs, nil
|
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)
|
queryString, variables := fetchPullRequestsQuery(branches, repoOwner, repoName)
|
||||||
|
|
||||||
bodyBytes, err := json.Marshal(graphQLRequest{Query: queryString, Variables: variables})
|
bodyBytes, err := json.Marshal(graphQLRequest{Query: queryString, Variables: variables})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -336,6 +333,16 @@ func getRemotesToOwnersMap(remotes []*models.Remote) map[string]string {
|
|||||||
return res
|
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 {
|
func (self *GitHubCommands) InGithubRepo(remotes []*models.Remote) bool {
|
||||||
if len(remotes) == 0 {
|
if len(remotes) == 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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) {
|
func TestGenerateGithubPullRequestMap(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jesseduffield/generics/set"
|
"github.com/jesseduffield/generics/set"
|
||||||
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
"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/commands/models"
|
||||||
"github.com/jesseduffield/lazygit/pkg/config"
|
"github.com/jesseduffield/lazygit/pkg/config"
|
||||||
"github.com/jesseduffield/lazygit/pkg/gocui"
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
||||||
@@ -812,39 +813,33 @@ func (self *RefreshHelper) refreshGithubPullRequests() {
|
|||||||
self.c.Mutexes().RefreshingPullRequestsMutex.Lock()
|
self.c.Mutexes().RefreshingPullRequestsMutex.Lock()
|
||||||
defer self.c.Mutexes().RefreshingPullRequestsMutex.Unlock()
|
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().PullRequests = nil
|
||||||
self.c.Model().PullRequestsMap = nil
|
self.c.Model().PullRequestsMap = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authToken := self.c.Git().GitHub.GetAuthToken()
|
baseInfo := getGithubBaseRemote(githubRemotes, self.c.Git().GitHub.ConfiguredBaseRemoteName())
|
||||||
if authToken == "" {
|
if baseInfo == nil {
|
||||||
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 {
|
|
||||||
self.c.Model().PullRequests = nil
|
self.c.Model().PullRequests = nil
|
||||||
self.c.Model().PullRequestsMap = nil
|
self.c.Model().PullRequestsMap = nil
|
||||||
|
|
||||||
if len(githubRemotes) > 0 && !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] {
|
if !self.githubBaseRemotePromptDismissed[self.c.Git().RepoPaths.RepoPath()] {
|
||||||
self.promptForBaseGithubRepo(authToken, githubRemotes)
|
self.promptForBaseGithubRepo(githubRemotes)
|
||||||
}
|
}
|
||||||
return
|
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()))
|
self.c.LogAction(fmt.Sprintf("Error fetching pull requests from GitHub: %s", err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type githubRemoteInfo struct {
|
type githubRemoteInfo struct {
|
||||||
remote *models.Remote
|
remote *models.Remote
|
||||||
repoName string
|
serviceInfo hosting_service.ServiceInfo
|
||||||
|
authToken string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *RefreshHelper) getGithubRemotes() []githubRemoteInfo {
|
func (self *RefreshHelper) getGithubRemotes() []githubRemoteInfo {
|
||||||
@@ -852,23 +847,44 @@ func (self *RefreshHelper) getGithubRemotes() []githubRemoteInfo {
|
|||||||
if len(remote.Urls) == 0 {
|
if len(remote.Urls) == 0 {
|
||||||
return githubRemoteInfo{}, false
|
return githubRemoteInfo{}, false
|
||||||
}
|
}
|
||||||
repoName, err := self.c.Git().HostingService.GetRepoNameFromRemoteURL(remote.Urls[0])
|
serviceInfo, err := self.c.Git().HostingService.GetServiceInfo(remote.Urls[0])
|
||||||
if err != nil {
|
if err != nil || serviceInfo.Provider != "github" {
|
||||||
return githubRemoteInfo{}, false
|
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 {
|
// getAuthenticatedGithubRemotes drops remotes for which no auth token is
|
||||||
findRemoteByName := func(name string) *models.Remote {
|
// 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 {
|
info, ok := lo.Find(githubRemotes, func(info githubRemoteInfo) bool {
|
||||||
return info.remote.Name == name
|
return info.remote.Name == name
|
||||||
})
|
})
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return info.remote
|
return &info
|
||||||
}
|
}
|
||||||
|
|
||||||
if configuredRemoteName != "" {
|
if configuredRemoteName != "" {
|
||||||
@@ -876,29 +892,29 @@ func getGithubBaseRemote(githubRemotes []githubRemoteInfo, configuredRemoteName
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(githubRemotes) == 1 {
|
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
|
// 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.
|
// made against, but if it exists it's pretty likely to be the one we want.
|
||||||
if remote := findRemoteByName("upstream"); remote != nil {
|
if info := findRemoteByName("upstream"); info != nil {
|
||||||
return remote
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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 {
|
menuItems := lo.Map(githubRemotes, func(info githubRemoteInfo, _ int) *types.MenuItem {
|
||||||
return &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 {
|
OnPress: func() error {
|
||||||
return self.c.WithWaitingStatus(self.c.Tr.FetchingPullRequests, func(gocui.Task) 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 {
|
if err := self.c.Git().GitHub.SetConfiguredBaseRemoteName(info.remote.Name); err != nil {
|
||||||
self.c.Log.Error(err)
|
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()))
|
self.c.LogAction(fmt.Sprintf("Error fetching pull requests from GitHub: %s", err.Error()))
|
||||||
}
|
}
|
||||||
return nil
|
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 {
|
if len(self.c.Model().Branches) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -940,7 +956,7 @@ func (self *RefreshHelper) setGithubPullRequests(authToken string, baseRemote *m
|
|||||||
return branch.UpstreamBranch
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package helpers
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
|
||||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -60,14 +61,64 @@ func TestGetGithubBaseRemote(t *testing.T) {
|
|||||||
assert.Nil(t, result)
|
assert.Nil(t, result)
|
||||||
} else {
|
} else {
|
||||||
assert.NotNil(t, result)
|
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 {
|
func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo {
|
||||||
return lo.Map(names, func(name string, _ int) 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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user