Skip to content

Commit 18ddc9c

Browse files
authored
Support GitHub Enterprise for the Pull Requests feature (#5596)
The feature to show PR icons in the branches list only worked for github.com, but not for GitHub Enterprise remotes. This PR makes that work, if you configure a services entry for it as explained [here](https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-pull-request-urls). Prior art: #5559.
2 parents 88c540d + 692f56a commit 18ddc9c

10 files changed

Lines changed: 304 additions & 112 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ If you press `shift+w` on a commit (or branch/ref) a menu will open that allows
230230

231231
### Show GitHub pull requests
232232

233-
In the branches panel, lazygit can show which of your branches have an associated GitHub pull request by showing a GitHub icon next to the branch name; its color shows the state of the PR (open, merged, etc.). For those that have one, you can press `shift-G` to open the PR in the browser. There is no configuration needed to enable this, but it requires the [`gh`](https://cli.github.com/) tool to be installed, and you need to do `gh auth login` once to allow lazygit to access GitHub.
233+
In the branches panel, lazygit can show which of your branches have an associated GitHub pull request by showing a GitHub icon next to the branch name; its color shows the state of the PR (open, merged, etc.). For those that have one, you can press `shift-G` to open the PR in the browser. There is no configuration needed to enable this for github.com, but it requires the [`gh`](https://cli.github.com/) tool to be installed, and you need to do `gh auth login` once to allow lazygit to access GitHub. For GitHub Enterprise, also run `gh auth login --hostname <webDomain>` and add a [`services` entry](docs/Config.md#custom-pull-request-urls) for the host with the `github` provider.
234234

235235
## Tutorials
236236

docs-master/Config.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,8 @@ Where:
11171117
- `provider` is one of `github`, `bitbucket`, `bitbucketServer`, `azuredevops`, `gitlab`, `gitea` or `codeberg`
11181118
- `webDomain` is the URL where your git service exposes a web interface and APIs, e.g. `gitservice.work.com`
11191119

1120+
For the `github` provider, configuring an entry here also enables the pull-request icons in the branches panel for that host (e.g. a GitHub Enterprise Server instance). Lazygit picks up the auth token via the same mechanisms as the `gh` CLI: the `GH_ENTERPRISE_TOKEN` / `GITHUB_ENTERPRISE_TOKEN` environment variables, or `gh auth login --hostname <webDomain>`.
1121+
11201122
## Predefined commit message prefix
11211123

11221124
In situations where certain naming pattern is used for branches and commits, pattern can be used to populate commit message with prefix that is parsed from the branch name.

pkg/commands/git_commands/github.go

Lines changed: 18 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -138,19 +138,16 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
138138
return queryString, variables
139139
}
140140

141-
func (self *GitHubCommands) GetAuthToken() string {
142-
defaultHost, _ := auth.DefaultHost()
143-
token, _ := auth.TokenForHost(defaultHost)
141+
func (self *GitHubCommands) GetAuthToken(host string) string {
142+
token, _ := auth.TokenForHost(host)
144143
return token
145144
}
146145

147-
// FetchRecentPRs fetches recent pull requests using GraphQL.
148-
func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models.Remote, token string) ([]*models.GithubPullRequest, error) {
149-
repoOwner, repoName, err := self.GetBaseRepoOwnerAndName(baseRemote)
150-
if err != nil {
151-
return nil, err
152-
}
153-
146+
// FetchRecentPRs fetches recent pull requests using GraphQL. serviceInfo
147+
// identifies the GitHub instance (github.com or a GitHub Enterprise Server)
148+
// and the owner/repo to query against.
149+
func (self *GitHubCommands) FetchRecentPRs(branches []string, serviceInfo *hosting_service.ServiceInfo, token string) ([]*models.GithubPullRequest, error) {
150+
endpoint := graphQLEndpoint(serviceInfo.WebDomain)
154151
t := time.Now()
155152

156153
var g errgroup.Group
@@ -171,7 +168,7 @@ func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models
171168

172169
// Launch a goroutine for each chunk of branches
173170
g.Go(func() error {
174-
prs, err := self.fetchRecentPRsAux(repoOwner, repoName, branchChunk, token)
171+
prs, err := self.fetchRecentPRsAux(endpoint, serviceInfo.Owner, serviceInfo.Repository, branchChunk, token)
175172
if err != nil {
176173
return err
177174
}
@@ -181,7 +178,7 @@ func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models
181178
}
182179

183180
// Wait for all goroutines, then close the channel so the range loop exits
184-
err = g.Wait()
181+
err := g.Wait()
185182
close(results)
186183
if err != nil {
187184
return nil, err
@@ -198,14 +195,14 @@ func (self *GitHubCommands) FetchRecentPRs(branches []string, baseRemote *models
198195
return allPRs, nil
199196
}
200197

201-
func (self *GitHubCommands) fetchRecentPRsAux(repoOwner string, repoName string, branches []string, token string) ([]*models.GithubPullRequest, error) {
198+
func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string, repoName string, branches []string, token string) ([]*models.GithubPullRequest, error) {
202199
queryString, variables := fetchPullRequestsQuery(branches, repoOwner, repoName)
203200

204201
bodyBytes, err := json.Marshal(graphQLRequest{Query: queryString, Variables: variables})
205202
if err != nil {
206203
return nil, err
207204
}
208-
req, err := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewBuffer(bodyBytes))
205+
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(bodyBytes))
209206
if err != nil {
210207
return nil, err
211208
}
@@ -336,45 +333,12 @@ func getRemotesToOwnersMap(remotes []*models.Remote) map[string]string {
336333
return res
337334
}
338335

339-
func (self *GitHubCommands) InGithubRepo(remotes []*models.Remote) bool {
340-
if len(remotes) == 0 {
341-
return false
342-
}
343-
344-
remote := getMainRemote(remotes)
345-
346-
if len(remote.Urls) == 0 {
347-
return false
348-
}
349-
350-
url := remote.Urls[0]
351-
return strings.Contains(strings.ToLower(url), "github.com")
352-
}
353-
354-
func getMainRemote(remotes []*models.Remote) *models.Remote {
355-
for _, remote := range remotes {
356-
if remote.Name == "origin" {
357-
return remote
358-
}
359-
}
360-
361-
// need to sort remotes by name so that this is deterministic
362-
return lo.MinBy(remotes, func(a, b *models.Remote) bool {
363-
return a.Name < b.Name
364-
})
365-
}
366-
367-
func (self *GitHubCommands) GetBaseRepoOwnerAndName(baseRemote *models.Remote) (string, string, error) {
368-
if len(baseRemote.Urls) == 0 {
369-
return "", "", fmt.Errorf("No URLs found for remote")
336+
// graphQLEndpoint returns the GraphQL API URL for a GitHub host. github.com
337+
// uses a dedicated api. subdomain; GitHub Enterprise Server hangs the API off
338+
// the web host under /api/graphql.
339+
func graphQLEndpoint(host string) string {
340+
if auth.NormalizeHostname(host) == "github.com" {
341+
return "https://api.github.com/graphql"
370342
}
371-
372-
url := baseRemote.Urls[0]
373-
374-
repoInfo, err := hosting_service.GetRepoInfoFromURL(url)
375-
if err != nil {
376-
return "", "", err
377-
}
378-
379-
return repoInfo.Owner, repoInfo.Repository, nil
343+
return "https://" + host + "/api/graphql"
380344
}

pkg/commands/git_commands/github_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,25 @@ func TestGetRepoInfoFromURL(t *testing.T) {
5757
}
5858
}
5959

60+
func TestGraphQLEndpoint(t *testing.T) {
61+
cases := []struct {
62+
host string
63+
expected string
64+
}{
65+
{"github.com", "https://api.github.com/graphql"},
66+
{"www.github.com", "https://api.github.com/graphql"},
67+
{"GITHUB.com", "https://api.github.com/graphql"},
68+
{"ghe.example.com", "https://ghe.example.com/api/graphql"},
69+
{"ghe.example.com:8443", "https://ghe.example.com:8443/api/graphql"},
70+
}
71+
72+
for _, c := range cases {
73+
t.Run(c.host, func(t *testing.T) {
74+
assert.Equal(t, c.expected, graphQLEndpoint(c.host))
75+
})
76+
}
77+
}
78+
6079
func TestGenerateGithubPullRequestMap(t *testing.T) {
6180
cases := []struct {
6281
name string

pkg/commands/git_commands/hosting_service.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ func (self *HostingService) GetCommitURL(commitSha string) (string, error) {
2121
return self.getHostingServiceMgr(self.config.GetRemoteURL()).GetCommitURL(commitSha)
2222
}
2323

24-
func (self *HostingService) GetRepoNameFromRemoteURL(remoteURL string) (string, error) {
25-
return self.getHostingServiceMgr(remoteURL).GetRepoName()
24+
func (self *HostingService) GetServiceInfo(remoteURL string) (hosting_service.ServiceInfo, error) {
25+
return self.getHostingServiceMgr(remoteURL).GetServiceInfo()
2626
}
2727

2828
// getting this on every request rather than storing it in state in case our remoteURL changes

pkg/commands/hosting_service/definitions.go

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package hosting_service
22

3+
import "regexp"
4+
35
// if you want to make a custom regex for a given service feel free to test it out
46
// at https://regex101.com using the flavor Golang
5-
var defaultUrlRegexStrings = []string{
6-
`^(?:https?|ssh)://[^/]+/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
7-
`^(.*?@)?.*:/*(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
7+
var defaultUrlRegexps = []*regexp.Regexp{
8+
regexp.MustCompile(`^(?:https?|ssh)://[^/]+/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`),
9+
regexp.MustCompile(`^(.*?@)?.*:/*(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`),
810
}
911

1012
var (
@@ -19,7 +21,7 @@ var githubServiceDef = ServiceDefinition{
1921
pullRequestURLIntoDefaultBranch: "/compare/{{.From}}?expand=1",
2022
pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}?expand=1",
2123
commitURL: "/commit/{{.CommitHash}}",
22-
regexStrings: defaultUrlRegexStrings,
24+
urlRegexps: defaultUrlRegexps,
2325
repoURLTemplate: defaultRepoURLTemplate,
2426
repoNameTemplate: defaultRepoNameTemplate,
2527
}
@@ -29,9 +31,9 @@ var bitbucketServiceDef = ServiceDefinition{
2931
pullRequestURLIntoDefaultBranch: "/pull-requests/new?source={{.From}}&t=1",
3032
pullRequestURLIntoTargetBranch: "/pull-requests/new?source={{.From}}&dest={{.To}}&t=1",
3133
commitURL: "/commits/{{.CommitHash}}",
32-
regexStrings: []string{
33-
`^(?:https?|ssh)://.*/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
34-
`^.*@.*:/*(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
34+
urlRegexps: []*regexp.Regexp{
35+
regexp.MustCompile(`^(?:https?|ssh)://.*/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`),
36+
regexp.MustCompile(`^.*@.*:/*(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`),
3537
},
3638
repoURLTemplate: defaultRepoURLTemplate,
3739
repoNameTemplate: defaultRepoNameTemplate,
@@ -42,7 +44,7 @@ var gitLabServiceDef = ServiceDefinition{
4244
pullRequestURLIntoDefaultBranch: "/-/merge_requests/new?merge_request%5Bsource_branch%5D={{.From}}",
4345
pullRequestURLIntoTargetBranch: "/-/merge_requests/new?merge_request%5Bsource_branch%5D={{.From}}&merge_request%5Btarget_branch%5D={{.To}}",
4446
commitURL: "/-/commit/{{.CommitHash}}",
45-
regexStrings: defaultUrlRegexStrings,
47+
urlRegexps: defaultUrlRegexps,
4648
repoURLTemplate: defaultRepoURLTemplate,
4749
repoNameTemplate: defaultRepoNameTemplate,
4850
}
@@ -52,11 +54,11 @@ var azdoServiceDef = ServiceDefinition{
5254
pullRequestURLIntoDefaultBranch: "/pullrequestcreate?sourceRef={{.From}}",
5355
pullRequestURLIntoTargetBranch: "/pullrequestcreate?sourceRef={{.From}}&targetRef={{.To}}",
5456
commitURL: "/commit/{{.CommitHash}}",
55-
regexStrings: []string{
56-
`^.+@vs-ssh\.visualstudio\.com[:/](?:v3/)?(?P<org>[^/]+)/(?P<project>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$`,
57-
`^git@ssh.dev.azure.com.*/(?P<org>.*)/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
58-
`^https://.*@dev.azure.com/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`,
59-
`^https://.*/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`,
57+
urlRegexps: []*regexp.Regexp{
58+
regexp.MustCompile(`^.+@vs-ssh\.visualstudio\.com[:/](?:v3/)?(?P<org>[^/]+)/(?P<project>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$`),
59+
regexp.MustCompile(`^git@ssh.dev.azure.com.*/(?P<org>.*)/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`),
60+
regexp.MustCompile(`^https://.*@dev.azure.com/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`),
61+
regexp.MustCompile(`^https://.*/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`),
6062
},
6163
repoURLTemplate: "https://{{.webDomain}}/{{.org}}/{{.project}}/_git/{{.repo}}",
6264
repoNameTemplate: "{{.org}}/{{.project}}/{{.repo}}",
@@ -67,9 +69,9 @@ var bitbucketServerServiceDef = ServiceDefinition{
6769
pullRequestURLIntoDefaultBranch: "/pull-requests?create&sourceBranch={{.From}}",
6870
pullRequestURLIntoTargetBranch: "/pull-requests?create&targetBranch={{.To}}&sourceBranch={{.From}}",
6971
commitURL: "/commits/{{.CommitHash}}",
70-
regexStrings: []string{
71-
`^ssh://git@.*/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
72-
`^https://.*/scm/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
72+
urlRegexps: []*regexp.Regexp{
73+
regexp.MustCompile(`^ssh://git@.*/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`),
74+
regexp.MustCompile(`^https://.*/scm/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`),
7375
},
7476
repoURLTemplate: "https://{{.webDomain}}/projects/{{.project}}/repos/{{.repo}}",
7577
repoNameTemplate: "{{.project}}/{{.repo}}",
@@ -80,7 +82,7 @@ var giteaServiceDef = ServiceDefinition{
8082
pullRequestURLIntoDefaultBranch: "/compare/{{.From}}",
8183
pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}",
8284
commitURL: "/commit/{{.CommitHash}}",
83-
regexStrings: defaultUrlRegexStrings,
85+
urlRegexps: defaultUrlRegexps,
8486
repoURLTemplate: defaultRepoURLTemplate,
8587
}
8688

@@ -89,7 +91,7 @@ var codebergServiceDef = ServiceDefinition{
8991
pullRequestURLIntoDefaultBranch: "/compare/{{.From}}",
9092
pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}",
9193
commitURL: "/commit/{{.CommitHash}}",
92-
regexStrings: defaultUrlRegexStrings,
94+
urlRegexps: defaultUrlRegexps,
9395
repoURLTemplate: defaultRepoURLTemplate,
9496
}
9597

pkg/commands/hosting_service/hosting_service.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,42 @@ func (self *HostingServiceMgr) GetRepoName() (string, error) {
7373
return repoName, nil
7474
}
7575

76+
// ServiceInfo holds the resolved hosting service for a remote URL. Owner
77+
// comes from the "owner" named regex capture, which only exists for
78+
// owner/repo-shaped providers (github, gitlab, bitbucket, gitea, codeberg);
79+
// it's empty for azuredevops and bitbucketServer, whose URLs are organised
80+
// differently. Repository is populated for every provider, but RepoName may
81+
// have more than two segments (e.g. "org/project/repo" for azuredevops).
82+
type ServiceInfo struct {
83+
Provider string // e.g. "github"
84+
WebDomain string // e.g. "github.com", or "git.acme.com" for an on-prem instance
85+
Owner string // e.g. "jesseduffield"
86+
Repository string // e.g. "lazygit"
87+
RepoName string // e.g. "jesseduffield/lazygit"
88+
}
89+
90+
// GetServiceInfo identifies which hosting service the configured remote URL
91+
// belongs to and returns enough information to talk to its web/API host.
92+
func (self *HostingServiceMgr) GetServiceInfo() (ServiceInfo, error) {
93+
serviceDomain, err := self.getServiceDomain(self.remoteURL)
94+
if err != nil {
95+
return ServiceInfo{}, err
96+
}
97+
98+
matches, err := serviceDomain.serviceDefinition.parseRemoteUrl(self.remoteURL)
99+
if err != nil {
100+
return ServiceInfo{}, err
101+
}
102+
103+
return ServiceInfo{
104+
Provider: serviceDomain.serviceDefinition.provider,
105+
WebDomain: serviceDomain.webDomain,
106+
Owner: matches["owner"],
107+
Repository: matches["repo"],
108+
RepoName: utils.ResolvePlaceholderString(serviceDomain.serviceDefinition.repoNameTemplate, matches),
109+
}, nil
110+
}
111+
76112
func (self *HostingServiceMgr) getService() (*Service, error) {
77113
serviceDomain, err := self.getServiceDomain(self.remoteURL)
78114
if err != nil {
@@ -159,7 +195,7 @@ type ServiceDefinition struct {
159195
pullRequestURLIntoDefaultBranch string
160196
pullRequestURLIntoTargetBranch string
161197
commitURL string
162-
regexStrings []string
198+
urlRegexps []*regexp.Regexp
163199

164200
// can expect 'webdomain' to be passed in. Otherwise, you get to pick what we match in the regex
165201
repoURLTemplate string
@@ -186,8 +222,7 @@ func (self ServiceDefinition) getRepoNameFromRemoteURL(url string) (string, erro
186222
}
187223

188224
func (self ServiceDefinition) parseRemoteUrl(url string) (map[string]string, error) {
189-
for _, regexStr := range self.regexStrings {
190-
re := regexp.MustCompile(regexStr)
225+
for _, re := range self.urlRegexps {
191226
matches := utils.FindNamedMatches(re, url)
192227
if matches != nil {
193228
return matches, nil
@@ -206,8 +241,7 @@ type RepoInformation struct {
206241
// GetRepoInfoFromURL parses a remote URL (SSH or HTTPS) and extracts the
207242
// owner and repository name using the default URL regex patterns.
208243
func GetRepoInfoFromURL(url string) (RepoInformation, error) {
209-
for _, regexStr := range defaultUrlRegexStrings {
210-
re := regexp.MustCompile(regexStr)
244+
for _, re := range defaultUrlRegexps {
211245
matches := utils.FindNamedMatches(re, url)
212246
if matches != nil {
213247
return RepoInformation{

0 commit comments

Comments
 (0)