summaryrefslogtreecommitdiffstats
path: root/pkg
diff options
context:
space:
mode:
authorJesse Duffield <jessedduffield@gmail.com>2024-06-03 22:12:09 +1000
committerJesse Duffield <jessedduffield@gmail.com>2024-06-29 17:39:38 +1000
commitbcb70119bbb4b48a60fbe0786e026e50acc52cdf (patch)
tree86517ae73e5554b14112e69e905715eee091baed /pkg
parent26c3e0d333a0e4404080b9fa3081fc2364367f20 (diff)
Show github pull request status against branch
Diffstat (limited to 'pkg')
-rw-r--r--pkg/app/app_test.go47
-rw-r--r--pkg/commands/git.go6
-rw-r--r--pkg/commands/git_commands/github.go432
-rw-r--r--pkg/commands/git_commands/hosting_service.go34
-rw-r--r--pkg/commands/hosting_service/definitions.go13
-rw-r--r--pkg/commands/hosting_service/hosting_service.go49
-rw-r--r--pkg/commands/models/github.go24
-rw-r--r--pkg/config/user_config.go3
-rw-r--r--pkg/gui/background.go13
-rw-r--r--pkg/gui/context/branches_context.go2
-rw-r--r--pkg/gui/controllers.go1
-rw-r--r--pkg/gui/controllers/helpers/helpers.go2
-rw-r--r--pkg/gui/controllers/helpers/refresh_helper.go135
-rw-r--r--pkg/gui/controllers/helpers/suggestions_helper.go24
-rw-r--r--pkg/gui/controllers/helpers/upstream_helper.go17
-rw-r--r--pkg/gui/gui.go29
-rw-r--r--pkg/gui/presentation/branches.go54
-rw-r--r--pkg/gui/types/common.go14
-rw-r--r--pkg/gui/types/refresh.go1
-rw-r--r--pkg/i18n/english.go6
20 files changed, 862 insertions, 44 deletions
diff --git a/pkg/app/app_test.go b/pkg/app/app_test.go
new file mode 100644
index 000000000..82b1cd204
--- /dev/null
+++ b/pkg/app/app_test.go
@@ -0,0 +1,47 @@
+package app
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsValidGhVersion(t *testing.T) {
+ type scenario struct {
+ versionStr string
+ expectedResult bool
+ }
+
+ scenarios := []scenario{
+ {
+ "",
+ false,
+ },
+ {
+ `gh version 1.0.0 (2020-08-23)
+ https://github.com/cli/cli/releases/tag/v1.0.0`,
+ false,
+ },
+ {
+ `gh version 2.0.0 (2021-08-23)
+ https://github.com/cli/cli/releases/tag/v2.0.0`,
+ true,
+ },
+ {
+ `gh version 1.1.0 (2021-10-14)
+ https://github.com/cli/cli/releases/tag/v1.1.0
+
+ A new release of gh is available: 1.1.0 → v2.2.0
+ To upgrade, run: brew update && brew upgrade gh
+ https://github.com/cli/cli/releases/tag/v2.2.0`,
+ false,
+ },
+ }
+
+ for _, s := range scenarios {
+ t.Run(s.versionStr, func(t *testing.T) {
+ result := isGhVersionValid(s.versionStr)
+ assert.Equal(t, result, s.expectedResult)
+ })
+ }
+}
diff --git a/pkg/commands/git.go b/pkg/commands/git.go
index 7e7d9354f..742b4c32e 100644
--- a/pkg/commands/git.go
+++ b/pkg/commands/git.go
@@ -38,6 +38,8 @@ type GitCommand struct {
Worktree *git_commands.WorktreeCommands
Version *git_commands.GitVersion
RepoPaths *git_commands.RepoPaths
+ GitHub *git_commands.GitHubCommands
+ HostingService *git_commands.HostingService
Loaders Loaders
}
@@ -133,6 +135,8 @@ func NewGitCommandAux(
bisectCommands := git_commands.NewBisectCommands(gitCommon)
worktreeCommands := git_commands.NewWorktreeCommands(gitCommon)
blameCommands := git_commands.NewBlameCommands(gitCommon)
+ gitHubCommands := git_commands.NewGitHubCommand(gitCommon)
+ hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)
branchLoader := git_commands.NewBranchLoader(cmn, gitCommon, cmd, branchCommands.CurrentBranchInfo, configCommands)
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
@@ -164,6 +168,8 @@ func NewGitCommandAux(
WorkingTree: workingTreeCommands,
Worktree: worktreeCommands,
Version: version,
+ GitHub: gitHubCommands,
+ HostingService: hostingServiceCommands,
Loaders: Loaders{
BranchLoader: branchLoader,
CommitFileLoader: commitFileLoader,
diff --git a/pkg/commands/git_commands/github.go b/pkg/commands/git_commands/github.go
new file mode 100644
index 000000000..ddce8304a
--- /dev/null
+++ b/pkg/commands/git_commands/github.go
@@ -0,0 +1,432 @@
+package git_commands
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/cli/go-gh/v2/pkg/auth"
+ gogit "github.com/jesseduffield/go-git/v5"
+ "github.com/jesseduffield/lazygit/pkg/commands/models"
+ "github.com/samber/lo"
+ "golang.org/x/sync/errgroup"
+)
+
+type GitHubCommands struct {
+ *GitCommon
+}
+
+func NewGitHubCommand(gitCommon *GitCommon) *GitHubCommands {
+ return &GitHubCommands{
+ GitCommon: gitCommon,
+ }
+}
+
+// https://github.com/cli/cli/issues/2300
+func (self *GitHubCommands) BaseRepo() error {
+ cmdArgs := NewGitCmd("config").
+ Arg("--local", "--get-regexp", ".gh-resolved").
+ ToArgv()
+
+ return self.cmd.New(cmdArgs).DontLog().Run()
+}
+
+// Ex: git config --local --add "remote.origin.gh-resolved" "jesseduffield/lazygit"
+func (self *GitHubCommands) SetBaseRepo(repository string) (string, error) {
+ cmdArgs := NewGitCmd("config").
+ Arg("--local", "--add", "remote.origin.gh-resolved", repository).
+ ToArgv()
+
+ return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
+}
+
+type Response struct {
+ Data RepositoryQuery `json:"data"`
+}
+
+type RepositoryQuery struct {
+ Repository map[string]PullRequest `json:"repository"`
+}
+
+type PullRequest struct {
+ Edges []PullRequestEdge `json:"edges"`
+}
+
+type PullRequestEdge struct {
+ Node PullRequestNode `json:"node"`
+}
+
+type PullRequestNode struct {
+ Title string `json:"title"`
+ HeadRefName string `json:"headRefName"`
+ Number int `json:"number"`
+ Url string `json:"url"`
+ HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
+ State string `json:"state"`
+}
+
+type GithubRepositoryOwner struct {
+ Login string `json:"login"`
+}
+
+func fetchPullRequestsQuery(branches []string, owner string, repo string) string {
+ var queries []string
+ for i, branch := range branches {
+ // We're making a sub-query per branch, and arbitrarily labelling each subquery
+ // as a1, a2, etc.
+ fieldName := fmt.Sprintf("a%d", i+1)
+ // TODO: scope down by remote too if we can (right now if you search for master, you can get multiple results back, and all from forks)
+ queries = append(queries, fmt.Sprintf(`%s: pullRequests(first: 1, headRefName: "%s") {
+ edges {
+ node {
+ title
+ headRefName
+ state
+ number
+ url
+ headRepositoryOwner {
+ login
+ }
+ }
+ }
+ }`, fieldName, branch))
+ }
+
+ queryString := fmt.Sprintf(`{
+ repository(owner: "%s", name: "%s") {
+ %s
+ }
+}`, owner, repo, strings.Join(queries, "\n"))
+
+ return queryString
+}
+
+// FetchRecentPRs fetches recent pull requests using GraphQL.
+func (self *GitHubCommands) FetchRecentPRs(branches []string) ([]*models.GithubPullRequest, error) {
+ repoOwner, repoName, err := self.GetBaseRepoOwnerAndName()
+ if err != nil {
+ return nil, err
+ }
+
+ t := time.Now()
+
+ var g errgroup.Group
+ results := make(chan []*models.GithubPullRequest)
+
+ // We want at most 5 concurrent requests, but no less than 10 branches per request
+ concurrency := 5
+ minBranchesPerRequest := 10
+ branchesPerRequest := max(len(branches)/concurrency, minBranchesPerRequest)
+ for i := 0; i < len(branches); i += branchesPerRequest {
+ end := i + branchesPerRequest
+ if end > len(branches) {
+ end = len(branches)
+ }
+ branchChunk := branches[i:end]
+
+ // Launch a goroutine for each chunk of branches
+ g.Go(func() error {
+ prs, err := self.FetchRecentPRsAux(repoOwner, repoName, branchChunk)
+ if err != nil {
+ return err
+ }
+ results <- prs
+ return nil
+ })
+ }
+
+ // Close the results channel when all goroutines are done
+ go func() {
+ g.Wait()
+ close(results)
+ }()
+
+ // Collect results from all goroutines
+ var allPRs []*models.GithubPullRequest
+ for prs := range results {
+ allPRs = append(allPRs, prs...)
+ }
+
+ if err := g.Wait(); err != nil {
+ return nil, err
+ }
+
+ self.Log.Warnf("Fetched PRs in %s", time.Since(t))
+
+ return allPRs, nil
+}
+
+func (self *GitHubCommands) FetchRecentPRsAux(repoOwner string, repoName string, branches []string) ([]*models.GithubPullRequest, error) {
+ queryString := fetchPullRequestsQuery(branches, repoOwner, repoName)
+ escapedQueryString := strconv.Quote(queryString)
+
+ body := fmt.Sprintf(`{"query": %s}`, escapedQueryString)
+ req, err := http.NewRequest("POST", "https://api.github.com/graphql", bytes.NewBuffer([]byte(body)))
+ if err != nil {
+ return nil, err
+ }
+
+ defaultHost, _ := auth.DefaultHost()
+ token, _ := auth.TokenForHost(defaultHost)
+ if token == "" {
+ return nil, fmt.Errorf("No token found for GitHub")
+ }
+ req.Header.Set("Authorization", "token "+token)
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyStr := new(bytes.Buffer)
+ bodyStr.ReadFrom(resp.Body)
+ return nil, fmt.Errorf("GraphQL query failed with status: %s. Body: %s", resp.Status, bodyStr.String())
+ }
+
+ bodyBytes, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ var result Response
+ err = json.Unmarshal(bodyBytes, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ prs := []*models.GithubPullRequest{}
+ for _, repoQuery := range result.Data.Repository {
+ for _, edge := range repoQuery.Edges {
+ node := edge.Node
+ pr := &models.GithubPullRequest{
+ HeadRefName: node.HeadRefName,
+ Number: node.Number,
+ State: node.State,
+ Url: node.Url,
+ HeadRepositoryOwner: models.GithubRepositoryOwner{
+ Login: node.HeadRepositoryOwner.Login,
+ },
+ }
+ prs = append(prs, pr)
+ }
+ }
+
+ return prs, nil
+}
+
+// returns a map from branch name to pull request
+func GenerateGithubPullRequestMap(
+ prs []*models.GithubPullRequest,
+ branches []*models.Branch,
+ remotes []*models.Remote,
+) map[string]*models.GithubPullRequest {
+ res := map[string]*models.GithubPullRequest{}
+
+ if len(prs) == 0 {
+ return res
+ }
+
+ remotesToOwnersMap := getRemotesToOwnersMap(remotes)
+
+ if len(remotesToOwnersMap) == 0 {
+ return res
+ }
+
+ // A PR can be identified by two things: the owner e.g. 'jesseduffield' and the
+ // branch name e.g. 'feature/my-feature'. The owner might be different
+ // to the owner of the repo if the PR is from a fork of that repo.
+ type prKey struct {
+ owner string
+ branchName string
+ }
+
+ prByKey := map[prKey]models.GithubPullRequest{}
+
+ for _, pr := range prs {
+ prByKey[prKey{owner: pr.UserName(), branchName: pr.BranchName()}] = *pr
+ }
+
+ for _, branch := range branches {
+ if !branch.IsTrackingRemote() {
+ continue
+ }
+
+ // TODO: support branches whose UpstreamRemote contains a full git
+ // URL rather than just a remote name.
+ owner, foundRemoteOwner := remotesToOwnersMap[branch.UpstreamRemote]
+ if !foundRemoteOwner {
+ continue
+ }
+
+ pr, hasPr := prByKey[prKey{owner: owner, branchName: branch.UpstreamBranch}]
+
+ if !hasPr {
+ continue
+ }
+
+ res[branch.Name] = &pr
+ }
+
+ return res
+}
+
+func getRemotesToOwnersMap(remotes []*models.Remote) map[string]string {
+ res := map[string]string{}
+ for _, remote := range remotes {
+ if len(remote.Urls) == 0 {
+ continue
+ }
+
+ res[remote.Name] = getRepoInfoFromURL(remote.Urls[0]).Owner
+ }
+ return res
+}
+
+type RepoInformation struct {
+ Owner string
+ Repository string
+}
+
+// TODO: move this into hosting_service.go
+func getRepoInfoFromURL(url string) RepoInformation {
+ isHTTP := strings.HasPrefix(url, "http")
+
+ if isHTTP {
+ splits := strings.Split(url, "/")
+ owner := strings.Join(splits[3:len(splits)-1], "/")
+ repo := strings.TrimSuffix(splits[len(splits)-1], ".git")
+
+ return RepoInformation{
+ Owner: owner,
+ Repository: repo,
+ }
+ }
+
+ tmpSplit := strings.Split(url, ":")
+ splits := strings.Split(tmpSplit[1], "/")
+ owner := strings.Join(splits[0:len(splits)-1], "/")
+ repo := strings.TrimSuffix(splits[len(splits)-1], ".git")
+
+ return RepoInformation{
+ Owner: owner,
+ Repository: repo,
+ }
+}
+
+// return <installed>, <valid version>
+func (self *GitHubCommands) DetermineGitHubCliState() (bool, bool) {
+ output, err := self.cmd.New([]string{"gh", "--version"}).DontLog().RunWithOutput()
+ if err != nil {
+ // assuming a failure here means that it's not installed
+ return false, false
+ }
+
+ if !isGhVersionValid(output) {
+ return true, false
+ }
+
+ return true, true
+}
+
+func isGhVersionValid(versionStr string) bool {
+ // output should be something like:
+ // gh version 2.0.0 (2021-08-23)
+ // https://github.com/cli/cli/releases/tag/v2.0.0
+ re := regexp.MustCompile(`[^\d]+([\d\.]+)`)
+ matches := re.FindStringSubmatch(versionStr)
+
+ if len(matches) == 0 {
+ return false
+ }
+
+ ghVersion := matches[1]
+ majorVersion, err := strconv.Atoi(ghVersion[0:1])
+ if err != nil {
+ return false
+ }
+ if majorVersion < 2 {
+ return false
+ }
+
+ return true
+}
+
+func (self *GitHubCommands) InGithubRepo() bool {
+ remotes, err := self.repo.Remotes()
+ if err != nil {
+ self.Log.Error(err)
+ return false
+ }
+
+ if len(remotes) == 0 {
+ return false
+ }
+
+ remote := GetMainRemote(remotes)
+
+ if len(remote.Config().URLs) == 0 {
+ return false
+ }
+
+ url := remote.Config().URLs[0]
+ return strings.Contains(url, "github.com")
+}
+
+func GetMainRemote(remotes []*gogit.Remote) *gogit.Remote {
+ for _, remote := range remotes {
+ if remote.Config().Name == "origin" {
+ return remote
+ }
+ }
+
+ // need to sort remotes by name so that this is deterministic
+ return lo.MinBy(remotes, func(a, b *gogit.Remote) bool {
+ return a.Config().Name < b.Config().Name
+ })
+}
+
+func GetSuggestedRemoteName(remotes []*models.Remote) string {
+ if len(remotes) == 0 {
+ return "origin"
+ }
+
+ for _, remote := range remotes {
+ if remote.Name == "origin" {
+ return remote.Name
+ }
+ }
+
+ return remotes[0].Name
+}
+
+func (self *GitHubCommands) GetBaseRepoOwnerAndName() (string, string, error) {
+ remotes, err := self.repo.Remotes()
+ if err != nil {
+ return "", "", err
+ }
+
+ if len(remotes) == 0 {
+ return "", "", fmt.Errorf("No remotes found")
+ }
+
+ firstRemote := remotes[0]
+ if len(firstRemote.Config().URLs) == 0 {
+ return "", "", fmt.Errorf("No URLs found for remote")
+ }
+
+ url := firstRemote.Config().URLs[0]
+
+ repoInfo := getRepoInfoFromURL(url)
+
+ return repoInfo.Owner, repoInfo.Repository, nil
+}
diff --git a/pkg/commands/git_commands/hosting_service.go b/pkg/commands/git_commands/hosting_service.go
new file mode 100644
index 000000000..a7295658d
--- /dev/null
+++ b/pkg/commands/git_commands/hosting_service.go
@@ -0,0 +1,34 @@
+package git_commands
+
+import "github.com/jesseduffield/lazygit/pkg/commands/hosting_service"
+
+// a hosting service is something like github, gitlab, bitbucket etc
+type HostingService struct {
+ *GitCommon
+}
+
+func NewHostingServiceCommand(gitCommon *GitCommon) *HostingService {
+ return &HostingService{
+ GitCommon: gitCommon,
+ }
+}
+
+func (self *HostingService) GetPullRequestURL(from string, to string) (string, error) {
+ return self.getHostingServiceMgr(self.config.GetRemoteURL()).GetPullRequestURL(from, to)
+}
+
+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()
+}
+
+// getting this on every request rather than storing it in state in case our remoteURL changes
+// from one invocation to the next. Note however that we're currently caching config
+// results so we might want to invalidate the cache here if it becomes a problem.
+func (self *HostingService) getHostingServiceMgr(remoteURL string) *hosting_service.HostingServiceMgr {
+ configServices := self.UserConfig.Services
+ return hosting_service.NewHostingServiceMgr(self.Log, self.Tr, remoteURL, configServices)
+}
diff --git a/pkg/commands/hosting_service/definitions.go b/pkg/commands/hosting_service/definitions.go
index ff872cd8c..d431af433 100644
--- a/pkg/commands/hosting_service/definitions.go
+++ b/pkg/commands/hosting_service/definitions.go
@@ -6,7 +6,11 @@ var defaultUrlRegexStrings = []string{
`^(?:https?|ssh)://[^/]+/(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
`^.*?@.*:(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
}
-var defaultRepoURLTemplate = "https://{{.webDomain}}/{{.owner}}/{{.repo}}"
+
+var (
+ defaultRepoURLTemplate = "https://{{.webDomain}}/{{.owner}}/{{.repo}}"
+ defaultRepoNameTemplate = "{{.owner}}/{{.repo}}"
+)
// we've got less type safety using go templates but this lends itself better to
// users adding custom service definitions in their config
@@ -17,6 +21,7 @@ var githubServiceDef = ServiceDefinition{
commitURL: "/commit/{{.CommitHash}}",
regexStrings: defaultUrlRegexStrings,
repoURLTemplate: defaultRepoURLTemplate,
+ repoNameTemplate: defaultRepoNameTemplate,
}
var bitbucketServiceDef = ServiceDefinition{
@@ -29,6 +34,7 @@ var bitbucketServiceDef = ServiceDefinition{
`^.*@.*:(?P<owner>.*)/(?P<repo>.*?)(?:\.git)?$`,
},
repoURLTemplate: defaultRepoURLTemplate,
+ repoNameTemplate: defaultRepoNameTemplate,
}
var gitLabServiceDef = ServiceDefinition{
@@ -38,6 +44,7 @@ var gitLabServiceDef = ServiceDefinition{
commitURL: "/-/commit/{{.CommitHash}}",
regexStrings: defaultUrlRegexStrings,
repoURLTemplate: defaultRepoURLTemplate,
+ repoNameTemplate: defaultRepoNameTemplate,
}
var azdoServiceDef = ServiceDefinition{
@@ -50,6 +57,8 @@ var azdoServiceDef = ServiceDefinition{
`^https://.*@dev.azure.com/(?P<org>.*?)/(?P<project>.*?)/_git/(?P<repo>.*?)(?:\.git)?$`,
},
repoURLTemplate: "https://{{.webDomain}}/{{.org}}/{{.project}}/_git/{{.repo}}",
+ // TODO: verify this is actually correct
+ repoNameTemplate: "{{.org}}/{{.project}}/{{.repo}}",
}
var bitbucketServerServiceDef = ServiceDefinition{
@@ -62,6 +71,8 @@ var bitbucketServerServiceDef = ServiceDefinition{
`^https://.*/scm/(?P<project>.*)/(?P<repo>.*?)(?:\.git)?$`,
},
repoURLTemplate: "https://{{.webDomain}}/projects/{{.project}}/repos/{{.repo}}",
+ // TODO: verify this is actually correct
+ repoNameTemplate: "{{.project}}/{{.repo}}",
}
var giteaServiceDef = ServiceDefinition{
diff --git a/pkg/commands/hosting_service/hosting_service.go b/pkg/commands/hosting_service/hosting_service.go
index 2e913b654..69c26aa65 100644
--- a/pkg/commands/hosting_service/hosting_service.go
+++ b/pkg/commands/hosting_service/hosting_service.go
@@ -62,6 +62,18 @@ func (self *HostingServiceMgr) GetCommitURL(commitHash string) (string, error) {
return pullRequestURL, nil
}
+// e.g. 'jesseduffield/lazygit'
+func (self *HostingServiceMgr) GetRepoName() (string, error) {
+ gitService, err := self.getService()
+ if err != nil {
+ return "", err
+ }
+
+ repoName := gitService.repoName
+
+ return repoName, nil
+}
+
func (self *HostingServiceMgr) getService() (*Service, error) {
serviceDomain, err := self.getServiceDomain(self.remoteURL)
if err != nil {
@@ -73,8 +85,14 @@ func (self *HostingServiceMgr) getService() (*Service, error) {
return nil, err
}
+ repoName, err := serviceDomain.serviceDefinition.getRepoNameFromRemoteURL(self.remoteURL)
+ if err != nil {
+ return nil, err
+ }
+
return &Service{
repoURL: repoURL,
+ repoName: repoName,
ServiceDefinition: serviceDomain.serviceDefinition,
}, nil
}
@@ -146,23 +164,44 @@ type ServiceDefinition struct {
// can expect 'webdomain' to be passed in. Otherwise, you get to pick what we match in the regex
repoURLTemplate string
+ repoNameTemplate string
}
func (self ServiceDefinition) getRepoURLFromRemoteURL(url string, webDomain string) (string, error) {
+ matches, err := self.parseRemoteUrl(url)
+ if err != nil {
+ return "", err
+ }
+
+ matches["webDomain"] = webDomain
+ return utils.ResolvePlaceholderString(self.repoURLTemplate, matches), nil
+}
+
+func (self ServiceDefinition) getRepoNameFromRemoteURL(url string) (string, error) {
+ matches, err := self.parseRemoteUrl(url)
+ if err != nil {
+ return "", err
+ }
+
+ return utils.ResolvePlaceholderString(self.repoNameTemplate, matches), nil
+}
+
+func (self ServiceDefinition) parseRemoteUrl(url string) (map[string]string, error) {
for _, regexStr := range self.regexStrings {
re := regexp.MustCompile(regexStr)
- input := utils.FindNamedMatches(re, url)
- if input != nil {
- input["webDomain"] = webDomain
- return utils.ResolvePlaceholderString(self.repoURLTemplate, input), nil
+ matches := utils.FindNamedMatches(re, url)
+ if matches != nil {
+ return matches, nil
}
}
- return "", errors.New("Failed to parse repo information from url")
+ return nil, errors.New("Failed to parse repo information from url")
}
type Service struct {
repoURL string
+ // e.g. 'jesseduffield/lazygit'
+ repoName string
ServiceDefinition
}
diff --git a/pkg/commands/models/github.go b/pkg/commands/models/github.go
new file mode 100644
index 000000000..d938fe1ee
--- /dev/null
+++ b/pkg/commands/models/github.go
@@ -0,0 +1,24 @@
+package models
+
+// TODO: see if I need to store the head repo name in case it differs from the base repo
+type GithubPullRequest struct {
+ HeadRefName string `json:"headRefName"`
+ Number int `json:"number"`
+ State string `json:"state"` // "MERGED", "OPEN", "CLOSED"
+ Url string `json:"url"`
+ HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
+}
+
+func (pr *GithubPullRequest) UserName() string {
+ // e.g. 'jesseduffield'
+ return pr.HeadRepositoryOwner.Login
+}
+
+func (pr *GithubPullRequest) BranchName() string {
+ // e.g. 'feature/my-feature'
+ return pr.HeadRefName
+}
+
+type GithubRepositoryOwner struct {
+ Login string `json:"login"`
+}
diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go
index 26d10f73a..dce596ee3 100644
--- a/pkg/config/user_config.go
+++ b/pkg/config/user_config.go
@@ -244,6 +244,8 @@ type GitConfig struct {
// When copying commit hashes to the clipboard, truncate them to this
// length. Set to 40 to disable truncation.
TruncateCopiedCommitHashesTo int `yaml:"truncateCopiedCommitHashesTo"`
+ // If true and if if `gh` is installed and on version >=2, we will use `gh` to display pull requests against branches.
+ EnableGithubCli bool `yaml:"enableGithubCli"`
}
type PagerType string
@@ -748,6 +750,7 @@ func GetDefaultConfig() *UserConfig {
CommitPrefixes: map[string]CommitPrefixConfig(nil),
ParseEmoji: false,
TruncateCopiedCommitHashesTo: 12,
+ EnableGithubCli: true,
},
Refresher: RefresherConfig{
RefreshInterval: 10,
diff --git a/pkg/gui/background.go b/pkg/gui/background.go
index 061502a43..4a64be0e2 100644
--- a/pkg/gui/background.go
+++ b/pkg/gui/background.go
@@ -30,7 +30,8 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
if userConfig.Git.AutoFetch {
fetchInterval := userConfig.Refresher.FetchInterval
if fetchInterval > 0 {
- go utils.Safe(self.startBackgroundFetch)
+ refreshInterval := self.gui.UserConfig.Refresher.FetchInterval
+ go utils.Safe(func() { self.startBackgroundFetch(refreshInterval) })
} else {
self.gui.c.Log.Errorf(
"Value of config option 'refresher.fetchInterval' (%d) is invalid, disabling auto-fetch",
@@ -73,19 +74,15 @@ func (self *BackgroundRoutineMgr) startBackgroundRoutines() {
}
}
-func (self *BackgroundRoutineMgr) startBackgroundFetch() {
+func (self *BackgroundRoutineMgr) startBackgroundFetch(refreshInterval int) {
self.gui.waitForIntro.Wait()
isNew := self.gui.IsNewRepo
- userConfig := self.gui.UserConfig
- if !isNew {
- time.After(time.Duration(userConfig.Refresher.FetchInterval) * time.Second)
- }
err := self.backgroundFetch()
if err != nil && strings.Contains(err.Error(), "exit status 128") && isNew {
_ = self.gui.c.Alert(self.gui.c.Tr.NoAutomaticGitFetchTitle, self.gui.c.Tr.NoAutomaticGitFetchBody)
} else {
- self.goEvery(time.Second*time.Duration(userConfig.Refresher.FetchInterval), self.gui.stopChan, func() error {
+ self.goEvery(time.Second*time.Duration(refreshInterval), self.gui.stopChan, func() error {
err := self.backgroundFetch()
self.gui.c.Render()
return err
@@ -129,7 +126,7 @@ func (self *BackgroundRoutineMgr) goEvery(interval time.Duration, stop chan stru
func (self *BackgroundRoutineMgr) backgroundFetch() (err error) {
err = self.gui.git.Sync.FetchBackground()
- _ = self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.ASYNC})
+ _ = self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS}, Mode: types.ASYNC})
return err
}
diff --git a/pkg/gui/context/branches_context.go b/pkg/gui/context/branches_context.go
index d289f2729..72008e98a 100644
--- a/pkg/gui/context/branches_context.go
+++ b/pkg/gui/context/branches_context.go
@@ -28,6 +28,8 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext {
return presentation.GetBranchListDisplayStrings(
viewModel.GetItems(),
c.State().GetItemOperation,
+ c.Model().PullRequests,
+ c.Model().Remotes,
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
c.Modes().Diffing.Ref,
c.Views().Branches.Width(),
diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go
index ba39fef5a..9988ae671 100644
--- a/pkg/gui/controllers.go
+++ b/pkg/gui/controllers.go
@@ -69,6 +69,7 @@ func (gui *Gui) resetHelpersAndControllers() {
mergeConflictsHelper,
worktreeHelper,
searchHelper,
+ suggestionsHelper,
)
diffHelper := helpers.NewDiffHelper(helperCommon)
cherryPickHelper := helpers.NewCherryPickHelper(
diff --git a/pkg/gui/controllers/helpers/helpers.go b/pkg/gui/controllers/helpers/helpers.go
index 1f1050dc9..9b3a8ab9a 100644
--- a/pkg/gui/controllers/helpers/helpers.go
+++ b/