Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ If you press `shift+w` on a commit (or branch/ref) a menu will open that allows

### Show GitHub pull requests

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.
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.

## Tutorials

Expand Down
2 changes: 2 additions & 0 deletions docs-master/Config.md
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,8 @@ Where:
- `provider` is one of `github`, `bitbucket`, `bitbucketServer`, `azuredevops`, `gitlab`, `gitea` or `codeberg`
- `webDomain` is the URL where your git service exposes a web interface and APIs, e.g. `gitservice.work.com`

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>`.

## Predefined commit message prefix

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.
Expand Down
72 changes: 18 additions & 54 deletions pkg/commands/git_commands/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -336,45 +333,12 @@ func getRemotesToOwnersMap(remotes []*models.Remote) map[string]string {
return res
}

func (self *GitHubCommands) InGithubRepo(remotes []*models.Remote) bool {
if len(remotes) == 0 {
return false
}

remote := getMainRemote(remotes)

if len(remote.Urls) == 0 {
return false
}

url := remote.Urls[0]
return strings.Contains(strings.ToLower(url), "github.com")
}

func getMainRemote(remotes []*models.Remote) *models.Remote {
for _, remote := range remotes {
if remote.Name == "origin" {
return remote
}
}

// need to sort remotes by name so that this is deterministic
return lo.MinBy(remotes, func(a, b *models.Remote) bool {
return a.Name < b.Name
})
}

func (self *GitHubCommands) GetBaseRepoOwnerAndName(baseRemote *models.Remote) (string, string, error) {
if len(baseRemote.Urls) == 0 {
return "", "", fmt.Errorf("No URLs found for remote")
// 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"
}

url := baseRemote.Urls[0]

repoInfo, err := hosting_service.GetRepoInfoFromURL(url)
if err != nil {
return "", "", err
}

return repoInfo.Owner, repoInfo.Repository, nil
return "https://" + host + "/api/graphql"
}
19 changes: 19 additions & 0 deletions pkg/commands/git_commands/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions pkg/commands/git_commands/hosting_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ func (self *HostingService) GetCommitURL(commitSha string) (string, error) {
return self.getHostingServiceMgr(self.config.GetRemoteURL()).GetCommitURL(commitSha)
}

func (self *HostingService) GetRepoNameFromRemoteURL(remoteURL string) (string, error) {
return self.getHostingServiceMgr(remoteURL).GetRepoName()
func (self *HostingService) GetServiceInfo(remoteURL string) (hosting_service.ServiceInfo, error) {
return self.getHostingServiceMgr(remoteURL).GetServiceInfo()
}

// getting this on every request rather than storing it in state in case our remoteURL changes
Expand Down
38 changes: 20 additions & 18 deletions pkg/commands/hosting_service/definitions.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package hosting_service

import "regexp"

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

var (
Expand All @@ -19,7 +21,7 @@ var githubServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/compare/{{.From}}?expand=1",
pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}?expand=1",
commitURL: "/commit/{{.CommitHash}}",
regexStrings: defaultUrlRegexStrings,
urlRegexps: defaultUrlRegexps,
repoURLTemplate: defaultRepoURLTemplate,
repoNameTemplate: defaultRepoNameTemplate,
}
Expand All @@ -29,9 +31,9 @@ var bitbucketServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/pull-requests/new?source={{.From}}&t=1",
pullRequestURLIntoTargetBranch: "/pull-requests/new?source={{.From}}&dest={{.To}}&t=1",
commitURL: "/commits/{{.CommitHash}}",
regexStrings: []string{
`^(?:https?|ssh)://.*/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
`^.*@.*:/*(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
urlRegexps: []*regexp.Regexp{
regexp.MustCompile(`^(?:https?|ssh)://.*/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`),
regexp.MustCompile(`^.*@.*:/*(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`),
},
repoURLTemplate: defaultRepoURLTemplate,
repoNameTemplate: defaultRepoNameTemplate,
Expand All @@ -42,7 +44,7 @@ var gitLabServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/-/merge_requests/new?merge_request%5Bsource_branch%5D={{.From}}",
pullRequestURLIntoTargetBranch: "/-/merge_requests/new?merge_request%5Bsource_branch%5D={{.From}}&merge_request%5Btarget_branch%5D={{.To}}",
commitURL: "/-/commit/{{.CommitHash}}",
regexStrings: defaultUrlRegexStrings,
urlRegexps: defaultUrlRegexps,
repoURLTemplate: defaultRepoURLTemplate,
repoNameTemplate: defaultRepoNameTemplate,
}
Expand All @@ -52,11 +54,11 @@ var azdoServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/pullrequestcreate?sourceRef={{.From}}",
pullRequestURLIntoTargetBranch: "/pullrequestcreate?sourceRef={{.From}}&targetRef={{.To}}",
commitURL: "/commit/{{.CommitHash}}",
regexStrings: []string{
`^.+@vs-ssh\.visualstudio\.com[:/](?:v3/)?(?P<org>[^/]+)/(?P<project>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$`,
`^git@ssh.dev.azure.com.*/(?P<org>.*)/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
`^https://.*@dev.azure.com/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`,
`^https://.*/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`,
urlRegexps: []*regexp.Regexp{
regexp.MustCompile(`^.+@vs-ssh\.visualstudio\.com[:/](?:v3/)?(?P<org>[^/]+)/(?P<project>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$`),
regexp.MustCompile(`^git@ssh.dev.azure.com.*/(?P<org>.*)/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`),
regexp.MustCompile(`^https://.*@dev.azure.com/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`),
regexp.MustCompile(`^https://.*/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`),
},
repoURLTemplate: "https://{{.webDomain}}/{{.org}}/{{.project}}/_git/{{.repo}}",
repoNameTemplate: "{{.org}}/{{.project}}/{{.repo}}",
Expand All @@ -67,9 +69,9 @@ var bitbucketServerServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/pull-requests?create&sourceBranch={{.From}}",
pullRequestURLIntoTargetBranch: "/pull-requests?create&targetBranch={{.To}}&sourceBranch={{.From}}",
commitURL: "/commits/{{.CommitHash}}",
regexStrings: []string{
`^ssh://git@.*/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
`^https://.*/scm/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
urlRegexps: []*regexp.Regexp{
regexp.MustCompile(`^ssh://git@.*/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`),
regexp.MustCompile(`^https://.*/scm/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`),
},
repoURLTemplate: "https://{{.webDomain}}/projects/{{.project}}/repos/{{.repo}}",
repoNameTemplate: "{{.project}}/{{.repo}}",
Expand All @@ -80,7 +82,7 @@ var giteaServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/compare/{{.From}}",
pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}",
commitURL: "/commit/{{.CommitHash}}",
regexStrings: defaultUrlRegexStrings,
urlRegexps: defaultUrlRegexps,
repoURLTemplate: defaultRepoURLTemplate,
}

Expand All @@ -89,7 +91,7 @@ var codebergServiceDef = ServiceDefinition{
pullRequestURLIntoDefaultBranch: "/compare/{{.From}}",
pullRequestURLIntoTargetBranch: "/compare/{{.To}}...{{.From}}",
commitURL: "/commit/{{.CommitHash}}",
regexStrings: defaultUrlRegexStrings,
urlRegexps: defaultUrlRegexps,
repoURLTemplate: defaultRepoURLTemplate,
}

Expand Down
44 changes: 39 additions & 5 deletions pkg/commands/hosting_service/hosting_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,42 @@ func (self *HostingServiceMgr) GetRepoName() (string, error) {
return repoName, nil
}

// ServiceInfo holds the resolved hosting service for a remote URL. Owner
// comes from the "owner" named regex capture, which only exists for
// owner/repo-shaped providers (github, gitlab, bitbucket, gitea, codeberg);
// it's empty for azuredevops and bitbucketServer, whose URLs are organised
// differently. Repository is populated for every provider, but RepoName may
// have more than two segments (e.g. "org/project/repo" for azuredevops).
type ServiceInfo struct {
Provider string // e.g. "github"
WebDomain string // e.g. "github.com", or "git.acme.com" for an on-prem instance
Owner string // e.g. "jesseduffield"
Repository string // e.g. "lazygit"
RepoName string // e.g. "jesseduffield/lazygit"
}

// GetServiceInfo identifies which hosting service the configured remote URL
// belongs to and returns enough information to talk to its web/API host.
func (self *HostingServiceMgr) GetServiceInfo() (ServiceInfo, error) {
serviceDomain, err := self.getServiceDomain(self.remoteURL)
if err != nil {
return ServiceInfo{}, err
}

matches, err := serviceDomain.serviceDefinition.parseRemoteUrl(self.remoteURL)
if err != nil {
return ServiceInfo{}, err
}

return ServiceInfo{
Provider: serviceDomain.serviceDefinition.provider,
WebDomain: serviceDomain.webDomain,
Owner: matches["owner"],
Repository: matches["repo"],
RepoName: utils.ResolvePlaceholderString(serviceDomain.serviceDefinition.repoNameTemplate, matches),
}, nil
}

func (self *HostingServiceMgr) getService() (*Service, error) {
serviceDomain, err := self.getServiceDomain(self.remoteURL)
if err != nil {
Expand Down Expand Up @@ -159,7 +195,7 @@ type ServiceDefinition struct {
pullRequestURLIntoDefaultBranch string
pullRequestURLIntoTargetBranch string
commitURL string
regexStrings []string
urlRegexps []*regexp.Regexp

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

func (self ServiceDefinition) parseRemoteUrl(url string) (map[string]string, error) {
for _, regexStr := range self.regexStrings {
re := regexp.MustCompile(regexStr)
for _, re := range self.urlRegexps {
matches := utils.FindNamedMatches(re, url)
if matches != nil {
return matches, nil
Expand All @@ -206,8 +241,7 @@ type RepoInformation struct {
// GetRepoInfoFromURL parses a remote URL (SSH or HTTPS) and extracts the
// owner and repository name using the default URL regex patterns.
func GetRepoInfoFromURL(url string) (RepoInformation, error) {
for _, regexStr := range defaultUrlRegexStrings {
re := regexp.MustCompile(regexStr)
for _, re := range defaultUrlRegexps {
matches := utils.FindNamedMatches(re, url)
if matches != nil {
return RepoInformation{
Expand Down
Loading
Loading