Skip to content

Commit cd8cfbd

Browse files
committed
add github support
1 parent 5cb82a4 commit cd8cfbd

File tree

23 files changed

+648
-72
lines changed

23 files changed

+648
-72
lines changed

docs/Config.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ git:
104104
overrideGpg: false # prevents lazygit from spawning a separate process when using GPG
105105
disableForcePushing: false
106106
parseEmoji: false
107+
# if `gh` is installed and on version >=2, we will use it to display pull requests against branches.
108+
enableGithubCli: true
107109
diffContextSize: 3 # how many lines of context are shown around a change in diffs
108110
os:
109111
editPreset: '' # see 'Configuring File Editing' section

pkg/app/app.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@ func Run(
4848
err = app.Run(startArgs)
4949
}
5050

51+
// not using if/else here because we're reassigning to `err` above
5152
if err != nil {
5253
if errorMessage, known := knownError(common.Tr, err); known {
5354
log.Fatal(errorMessage)
5455
}
5556
newErr := errors.Wrap(err, 0)
5657
stackTrace := newErr.ErrorStack()
57-
app.Log.Error(stackTrace)
5858

5959
log.Fatalf("%s: %s\n\n%s", common.Tr.ErrorOccurred, constants.Links.Issues, stackTrace)
6060
}
@@ -129,6 +129,7 @@ func NewApp(config config.AppConfigurer, common *common.Common) (*App, error) {
129129
if err != nil {
130130
return app, err
131131
}
132+
132133
return app, nil
133134
}
134135

pkg/app/app_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package app
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestIsValidGhVersion(t *testing.T) {
10+
type scenario struct {
11+
versionStr string
12+
expectedResult bool
13+
}
14+
15+
scenarios := []scenario{
16+
{
17+
"",
18+
false,
19+
},
20+
{
21+
`gh version 1.0.0 (2020-08-23)
22+
https://github.com/cli/cli/releases/tag/v1.0.0`,
23+
false,
24+
},
25+
{
26+
`gh version 2.0.0 (2021-08-23)
27+
https://github.com/cli/cli/releases/tag/v2.0.0`,
28+
true,
29+
},
30+
{
31+
`gh version 1.1.0 (2021-10-14)
32+
https://github.com/cli/cli/releases/tag/v1.1.0
33+
34+
A new release of gh is available: 1.1.0 → v2.2.0
35+
To upgrade, run: brew update && brew upgrade gh
36+
https://github.com/cli/cli/releases/tag/v2.2.0`,
37+
false,
38+
},
39+
}
40+
41+
for _, s := range scenarios {
42+
t.Run(s.versionStr, func(t *testing.T) {
43+
result := isGhVersionValid(s.versionStr)
44+
assert.Equal(t, result, s.expectedResult)
45+
})
46+
}
47+
}

pkg/commands/git.go

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,25 @@ import (
2020

2121
// GitCommand is our main git interface
2222
type GitCommand struct {
23-
Branch *git_commands.BranchCommands
24-
Commit *git_commands.CommitCommands
25-
Config *git_commands.ConfigCommands
26-
Custom *git_commands.CustomCommands
27-
Diff *git_commands.DiffCommands
28-
File *git_commands.FileCommands
29-
Flow *git_commands.FlowCommands
30-
Patch *git_commands.PatchCommands
31-
Rebase *git_commands.RebaseCommands
32-
Remote *git_commands.RemoteCommands
33-
Stash *git_commands.StashCommands
34-
Status *git_commands.StatusCommands
35-
Submodule *git_commands.SubmoduleCommands
36-
Sync *git_commands.SyncCommands
37-
Tag *git_commands.TagCommands
38-
WorkingTree *git_commands.WorkingTreeCommands
39-
Bisect *git_commands.BisectCommands
23+
Branch *git_commands.BranchCommands
24+
Commit *git_commands.CommitCommands
25+
Config *git_commands.ConfigCommands
26+
Custom *git_commands.CustomCommands
27+
Diff *git_commands.DiffCommands
28+
File *git_commands.FileCommands
29+
Flow *git_commands.FlowCommands
30+
Patch *git_commands.PatchCommands
31+
Rebase *git_commands.RebaseCommands
32+
Remote *git_commands.RemoteCommands
33+
Stash *git_commands.StashCommands
34+
Status *git_commands.StatusCommands
35+
Submodule *git_commands.SubmoduleCommands
36+
Sync *git_commands.SyncCommands
37+
Tag *git_commands.TagCommands
38+
WorkingTree *git_commands.WorkingTreeCommands
39+
Bisect *git_commands.BisectCommands
40+
GitHub *git_commands.GitHubCommands
41+
HostingService *git_commands.HostingService
4042

4143
Loaders Loaders
4244
}
@@ -127,6 +129,8 @@ func NewGitCommandAux(
127129
})
128130
patchCommands := git_commands.NewPatchCommands(gitCommon, rebaseCommands, commitCommands, statusCommands, stashCommands, patchBuilder)
129131
bisectCommands := git_commands.NewBisectCommands(gitCommon)
132+
gitHubCommands := git_commands.NewGitHubCommand(gitCommon)
133+
hostingServiceCommands := git_commands.NewHostingServiceCommand(gitCommon)
130134

131135
branchLoader := git_commands.NewBranchLoader(cmn, cmd, branchCommands.CurrentBranchInfo, configCommands)
132136
commitFileLoader := git_commands.NewCommitFileLoader(cmn, cmd)
@@ -137,23 +141,25 @@ func NewGitCommandAux(
137141
tagLoader := git_commands.NewTagLoader(cmn, cmd)
138142

139143
return &GitCommand{
140-
Branch: branchCommands,
141-
Commit: commitCommands,
142-
Config: configCommands,
143-
Custom: customCommands,
144-
Diff: diffCommands,
145-
File: fileCommands,
146-
Flow: flowCommands,
147-
Patch: patchCommands,
148-
Rebase: rebaseCommands,
149-
Remote: remoteCommands,
150-
Stash: stashCommands,
151-
Status: statusCommands,
152-
Submodule: submoduleCommands,
153-
Sync: syncCommands,
154-
Tag: tagCommands,
155-
Bisect: bisectCommands,
156-
WorkingTree: workingTreeCommands,
144+
Branch: branchCommands,
145+
Commit: commitCommands,
146+
Config: configCommands,
147+
Custom: customCommands,
148+
Diff: diffCommands,
149+
File: fileCommands,
150+
Flow: flowCommands,
151+
Patch: patchCommands,
152+
Rebase: rebaseCommands,
153+
Remote: remoteCommands,
154+
Stash: stashCommands,
155+
Status: statusCommands,
156+
Submodule: submoduleCommands,
157+
Sync: syncCommands,
158+
Tag: tagCommands,
159+
Bisect: bisectCommands,
160+
WorkingTree: workingTreeCommands,
161+
GitHub: gitHubCommands,
162+
HostingService: hostingServiceCommands,
157163
Loaders: Loaders{
158164
BranchLoader: branchLoader,
159165
CommitFileLoader: commitFileLoader,
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
package git_commands
2+
3+
import (
4+
"encoding/json"
5+
"regexp"
6+
"strconv"
7+
"strings"
8+
9+
"github.com/jesseduffield/lazygit/pkg/commands/models"
10+
)
11+
12+
type GitHubCommands struct {
13+
*GitCommon
14+
}
15+
16+
func NewGitHubCommand(gitCommon *GitCommon) *GitHubCommands {
17+
return &GitHubCommands{
18+
GitCommon: gitCommon,
19+
}
20+
}
21+
22+
// https://github.com/cli/cli/issues/2300
23+
func (self *GitHubCommands) BaseRepo() error {
24+
cmdArgs := NewGitCmd("config").
25+
Arg("--local", "--get-regexp", ".gh-resolved").
26+
ToArgv()
27+
28+
return self.cmd.New(cmdArgs).DontLog().Run()
29+
}
30+
31+
// Ex: git config --local --add "remote.origin.gh-resolved" "jesseduffield/lazygit"
32+
func (self *GitHubCommands) SetBaseRepo(repository string) (string, error) {
33+
cmdArgs := NewGitCmd("config").
34+
Arg("--local", "--add", "remote.origin.gh-resolved", repository).
35+
ToArgv()
36+
37+
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
38+
}
39+
40+
func (self *GitHubCommands) FetchRecentPRs() ([]*models.GithubPullRequest, error) {
41+
commandOutput, err := self.prList()
42+
if err != nil {
43+
return nil, err
44+
}
45+
46+
prs := []*models.GithubPullRequest{}
47+
err = json.Unmarshal([]byte(commandOutput), &prs)
48+
if err != nil {
49+
return nil, err
50+
}
51+
52+
return prs, nil
53+
}
54+
55+
func (self *GitHubCommands) prList() (string, error) {
56+
cmdArgs := []string{"gh", "pr", "list", "--limit", "500", "--state", "all", "--json", "state,url,number,headRefName,headRepositoryOwner"}
57+
58+
return self.cmd.New(cmdArgs).DontLog().RunWithOutput()
59+
}
60+
61+
// returns a map from branch name to pull request
62+
func GenerateGithubPullRequestMap(
63+
prs []*models.GithubPullRequest,
64+
branches []*models.Branch,
65+
remotes []*models.Remote,
66+
) map[string]*models.GithubPullRequest {
67+
res := map[string]*models.GithubPullRequest{}
68+
69+
if len(prs) == 0 {
70+
return res
71+
}
72+
73+
remotesToOwnersMap := getRemotesToOwnersMap(remotes)
74+
75+
if len(remotesToOwnersMap) == 0 {
76+
return res
77+
}
78+
79+
// A PR can be identified by two things: the owner e.g. 'jesseduffield' and the
80+
// branch name e.g. 'feature/my-feature'. The owner might be different
81+
// to the owner of the repo if the PR is from a fork of that repo.
82+
type prKey struct {
83+
owner string
84+
branchName string
85+
}
86+
87+
prByKey := map[prKey]models.GithubPullRequest{}
88+
89+
for _, pr := range prs {
90+
prByKey[prKey{owner: pr.UserName(), branchName: pr.BranchName()}] = *pr
91+
}
92+
93+
for _, branch := range branches {
94+
if !branch.IsTrackingRemote() {
95+
continue
96+
}
97+
98+
// TODO: support branches whose UpstreamRemote contains a full git
99+
// URL rather than just a remote name.
100+
owner, foundRemoteOwner := remotesToOwnersMap[branch.UpstreamRemote]
101+
if !foundRemoteOwner {
102+
continue
103+
}
104+
105+
pr, hasPr := prByKey[prKey{owner: owner, branchName: branch.UpstreamBranch}]
106+
107+
if !hasPr {
108+
continue
109+
}
110+
111+
res[branch.Name] = &pr
112+
}
113+
114+
return res
115+
}
116+
117+
func getRemotesToOwnersMap(remotes []*models.Remote) map[string]string {
118+
res := map[string]string{}
119+
for _, remote := range remotes {
120+
if len(remote.Urls) == 0 {
121+
continue
122+
}
123+
124+
res[remote.Name] = getRepoInfoFromURL(remote.Urls[0]).Owner
125+
}
126+
return res
127+
}
128+
129+
type RepoInformation struct {
130+
Owner string
131+
Repository string
132+
}
133+
134+
// TODO: move this into hosting_service.go
135+
func getRepoInfoFromURL(url string) RepoInformation {
136+
isHTTP := strings.HasPrefix(url, "http")
137+
138+
if isHTTP {
139+
splits := strings.Split(url, "/")
140+
owner := strings.Join(splits[3:len(splits)-1], "/")
141+
repo := strings.TrimSuffix(splits[len(splits)-1], ".git")
142+
143+
return RepoInformation{
144+
Owner: owner,
145+
Repository: repo,
146+
}
147+
}
148+
149+
tmpSplit := strings.Split(url, ":")
150+
splits := strings.Split(tmpSplit[1], "/")
151+
owner := strings.Join(splits[0:len(splits)-1], "/")
152+
repo := strings.TrimSuffix(splits[len(splits)-1], ".git")
153+
154+
return RepoInformation{
155+
Owner: owner,
156+
Repository: repo,
157+
}
158+
}
159+
160+
// return <installed>, <valid version>
161+
func (self *GitHubCommands) DetermineGitHubCliState() (bool, bool) {
162+
output, err := self.cmd.New([]string{"gh", "--version"}).DontLog().RunWithOutput()
163+
if err != nil {
164+
// assuming a failure here means that it's not installed
165+
return false, false
166+
}
167+
168+
if !isGhVersionValid(output) {
169+
return true, false
170+
}
171+
172+
return true, true
173+
}
174+
175+
func isGhVersionValid(versionStr string) bool {
176+
// output should be something like:
177+
// gh version 2.0.0 (2021-08-23)
178+
// https://github.com/cli/cli/releases/tag/v2.0.0
179+
re := regexp.MustCompile(`[^\d]+([\d\.]+)`)
180+
matches := re.FindStringSubmatch(versionStr)
181+
182+
if len(matches) == 0 {
183+
return false
184+
}
185+
186+
ghVersion := matches[1]
187+
majorVersion, err := strconv.Atoi(ghVersion[0:1])
188+
if err != nil {
189+
return false
190+
}
191+
if majorVersion < 2 {
192+
return false
193+
}
194+
195+
return true
196+
}
197+
198+
func (self *GitHubCommands) InGithubRepo() bool {
199+
remotes, err := self.repo.Remotes()
200+
if err != nil {
201+
self.Log.Error(err)
202+
return false
203+
}
204+
205+
if len(remotes) == 0 {
206+
return false
207+
}
208+
209+
firstRemote := remotes[0]
210+
if len(firstRemote.Config().URLs) == 0 {
211+
return false
212+
}
213+
214+
url := firstRemote.Config().URLs[0]
215+
return strings.Contains(url, "github.com")
216+
}

0 commit comments

Comments
 (0)