diff --git a/pkg/gui/background.go b/pkg/gui/background.go index e1720cd63..11f0dde5b 100644 --- a/pkg/gui/background.go +++ b/pkg/gui/background.go @@ -155,7 +155,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.SYNC}) + self.gui.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS, types.PULL_REQUESTS}, Mode: types.SYNC}) if err == nil { err = self.gui.helpers.BranchesHelper.AutoForwardBranches() diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index f75065e44..71b03fb1d 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -1,6 +1,7 @@ package helpers import ( + "fmt" "strings" "sync" "time" @@ -13,6 +14,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts" "github.com/jesseduffield/lazygit/pkg/gui/presentation" + "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" @@ -91,6 +93,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { types.STATUS, types.BISECT_INFO, types.STAGING, + types.PULL_REQUESTS, }) } else { scopeSet = set.NewFromSlice(options.Scope) @@ -117,6 +120,7 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } } + branchesAndRemotesWg := sync.WaitGroup{} includeWorktreesWithBranches := false if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) || scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { // whenever we change commits, we should update branches because the upstream/downstream @@ -126,9 +130,17 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { - refresh("reflog and branches", func() { self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) }) + branchesAndRemotesWg.Add(1) + refresh("reflog and branches", func() { + self.refreshReflogAndBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex) + branchesAndRemotesWg.Done() + }) } else { - refresh("branches", func() { self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true) }) + branchesAndRemotesWg.Add(1) + refresh("branches", func() { + self.refreshBranches(includeWorktreesWithBranches, options.KeepBranchSelectionIndex, true) + branchesAndRemotesWg.Done() + }) refresh("reflog", func() { _ = self.refreshReflogCommits() }) } } else if scopeSet.Includes(types.REBASE_COMMITS) { @@ -164,7 +176,18 @@ func (self *RefreshHelper) Refresh(options types.RefreshOptions) { } if scopeSet.Includes(types.REMOTES) { - refresh("remotes", func() { _ = self.refreshRemotes() }) + branchesAndRemotesWg.Add(1) + refresh("remotes", func() { + _ = self.refreshRemotes() + branchesAndRemotesWg.Done() + }) + } + + if scopeSet.Includes(types.PULL_REQUESTS) { + refresh("pull requests", func() { + branchesAndRemotesWg.Wait() + self.refreshGithubPullRequests() + }) } if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { @@ -225,6 +248,7 @@ func getScopeNames(scopes []types.RefreshableView) []string { types.PATCH_BUILDING: "patchBuilding", types.MERGE_CONFLICTS: "mergeConflicts", types.COMMIT_FILES: "commitFiles", + types.PULL_REQUESTS: "pullRequests", } return lo.Map(scopes, func(scope types.RefreshableView, _ int) string { @@ -480,6 +504,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool, keepBranchSele prevSelectedBranch := self.c.Contexts().Branches.GetSelected() self.c.Model().Branches = branches + self.rebuildPullRequestsMap() if refreshWorktrees { self.loadWorktrees() @@ -661,6 +686,13 @@ func (self *RefreshHelper) refreshRemotes() error { self.c.Model().Remotes = remotes + hadPrs := len(self.c.Model().PullRequestsMap) != 0 + self.rebuildPullRequestsMap() + if !hadPrs && len(self.c.Model().PullRequestsMap) != 0 { + // if we didn't have PRs in the map before but now we do, we need to redraw the branches view + self.refreshView(self.c.Contexts().Branches) + } + // we need to ensure our selected remote branches aren't now outdated if prevSelectedRemote != nil && self.c.Model().RemoteBranches != nil { // find remote now @@ -760,3 +792,123 @@ func (self *RefreshHelper) refreshView(context types.Context) { return nil }) } + +func (self *RefreshHelper) refreshGithubPullRequests() { + self.c.Mutexes().RefreshingPullRequestsMutex.Lock() + defer self.c.Mutexes().RefreshingPullRequestsMutex.Unlock() + + if !self.c.Git().GitHub.InGithubRepo(self.c.Model().Remotes) { + self.c.Model().PullRequests = nil + self.c.Model().PullRequestsMap = nil + return + } + + authToken := self.c.Git().GitHub.GetAuthToken() + if authToken == "" { + self.c.Model().PullRequests = nil + self.c.Model().PullRequestsMap = nil + return + } + + baseRemote := self.getGithubBaseRemote() + if baseRemote == nil { + self.promptForBaseGithubRepo(authToken) + return + } + + if err := self.setGithubPullRequests(authToken, baseRemote); err != nil { + self.c.LogAction(fmt.Sprintf("Error fetching pull requests from GitHub: %s", err.Error())) + } +} + +func (self *RefreshHelper) getGithubBaseRemote() *models.Remote { + remotes := self.c.Model().Remotes + + findRemoteByName := func(name string) *models.Remote { + remote, _ := lo.Find(remotes, func(remote *models.Remote) bool { + return remote.Name == name + }) + return remote + } + + if configuredRemote := self.c.Git().GitHub.ConfiguredBaseRemoteName(); configuredRemote != "" { + return findRemoteByName(configuredRemote) + } + + if len(remotes) == 1 { + return remotes[0] + } + + for _, remoteName := range []string{"upstream", "origin"} { + if remote := findRemoteByName(remoteName); remote != nil { + return remote + } + } + + return nil +} + +func (self *RefreshHelper) promptForBaseGithubRepo(authToken string) { + menuItems := lo.FilterMap(self.c.Model().Remotes, func(remote *models.Remote, _ int) (*types.MenuItem, bool) { + if len(remote.Urls) == 0 { + return nil, false + } + repoName, err := self.c.Git().HostingService.GetRepoNameFromRemoteURL(remote.Urls[0]) + if err != nil { + return nil, false + } + + return &types.MenuItem{ + LabelColumns: []string{remote.Name, style.FgCyan.Sprint(repoName)}, + OnPress: func() error { + return self.c.WithWaitingStatus(self.c.Tr.FetchingPullRequests, func(gocui.Task) error { + if err := self.c.Git().GitHub.SetConfiguredBaseRemoteName(remote.Name); err != nil { + self.c.Log.Error(err) + } + + if err := self.setGithubPullRequests(authToken, remote); err != nil { + self.c.LogAction(fmt.Sprintf("Error fetching pull requests from GitHub: %s", err.Error())) + } + return nil + }) + }, + }, true + }) + + _ = self.c.Menu(types.CreateMenuOptions{ + Title: self.c.Tr.SelectRemoteRepository, + Items: menuItems, + }) +} + +func (self *RefreshHelper) rebuildPullRequestsMap() { + self.c.Model().PullRequestsMap = git_commands.GenerateGithubPullRequestMap( + self.c.Model().PullRequests, + self.c.Model().Branches, + self.c.Model().Remotes, + ) +} + +func (self *RefreshHelper) setGithubPullRequests(authToken string, baseRemote *models.Remote) error { + if len(self.c.Model().Branches) == 0 { + return nil + } + + branches := lo.Filter(self.c.Model().Branches, func(branch *models.Branch, _ int) bool { + return branch.IsTrackingRemote() + }) + branchNames := lo.Map(branches, func(branch *models.Branch, _ int) string { + return branch.UpstreamBranch + }) + + prs, err := self.c.Git().GitHub.FetchRecentPRs(branchNames, baseRemote, authToken) + if err != nil { + return err + } + + self.c.Model().PullRequests = prs + self.rebuildPullRequestsMap() + + self.c.PostRefreshUpdate(self.c.Contexts().Branches) + return nil +} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 3d20642cd..219683511 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -601,6 +601,8 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs) types.Context { Authors: map[string]*models.Author{}, MainBranches: git_commands.NewMainBranches(gui.c.Common, gui.os.Cmd), HashPool: &utils.StringPool{}, + PullRequests: nil, + PullRequestsMap: make(map[string]*models.GithubPullRequest), }, Modes: &types.Modes{ Filtering: filtering.New(startArgs.FilterPath, ""), diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 4c892508d..b9e7cc9c8 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -287,15 +287,17 @@ func (self *MenuItem) ID() string { } type Model struct { - CommitFiles []*models.CommitFile - Files []*models.File - Submodules []*models.SubmoduleConfig - Branches []*models.Branch - Commits []*models.Commit - StashEntries []*models.StashEntry - SubCommits []*models.Commit - Remotes []*models.Remote - Worktrees []*models.Worktree + CommitFiles []*models.CommitFile + Files []*models.File + Submodules []*models.SubmoduleConfig + Branches []*models.Branch + Commits []*models.Commit + StashEntries []*models.StashEntry + SubCommits []*models.Commit + Remotes []*models.Remote + Worktrees []*models.Worktree + PullRequests []*models.GithubPullRequest + PullRequestsMap map[string]*models.GithubPullRequest // FilteredReflogCommits are the ones that appear in the reflog panel. // When in filtering mode we only include the ones that match the given path @@ -326,15 +328,16 @@ type Model struct { } type Mutexes struct { - RefreshingFilesMutex deadlock.Mutex - RefreshingBranchesMutex deadlock.Mutex - RefreshingStatusMutex deadlock.Mutex - LocalCommitsMutex deadlock.Mutex - SubCommitsMutex deadlock.Mutex - AuthorsMutex deadlock.Mutex - SubprocessMutex deadlock.Mutex - PopupMutex deadlock.Mutex - PtyMutex deadlock.Mutex + RefreshingFilesMutex deadlock.Mutex + RefreshingBranchesMutex deadlock.Mutex + RefreshingStatusMutex deadlock.Mutex + RefreshingPullRequestsMutex deadlock.Mutex + LocalCommitsMutex deadlock.Mutex + SubCommitsMutex deadlock.Mutex + AuthorsMutex deadlock.Mutex + SubprocessMutex deadlock.Mutex + PopupMutex deadlock.Mutex + PtyMutex deadlock.Mutex } // A long-running operation associated with an item. For example, we'll show diff --git a/pkg/gui/types/refresh.go b/pkg/gui/types/refresh.go index c20a5f54a..8092ee36e 100644 --- a/pkg/gui/types/refresh.go +++ b/pkg/gui/types/refresh.go @@ -20,8 +20,9 @@ const ( PATCH_BUILDING MERGE_CONFLICTS COMMIT_FILES - // not actually a view. Will refactor this later + // not actually views. Will refactor this later BISECT_INFO + PULL_REQUESTS ) type RefreshMode int diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 63ed43c5f..b55214d79 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -609,6 +609,8 @@ type TranslationSet struct { CyclePagersDisabledReason string StartSearch string StartFilter string + SelectRemoteRepository string + FetchingPullRequests string Keybindings string KeybindingsLegend string KeybindingsMenuSectionLocal string @@ -1730,6 +1732,8 @@ func EnglishTranslationSet() *TranslationSet { CyclePagersDisabledReason: "No other pagers configured", StartSearch: "Search the current view by text", StartFilter: "Filter the current view by text", + SelectRemoteRepository: "Select base repository for pull requests", + FetchingPullRequests: "Fetching pull requests", KeybindingsLegend: "Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b", RenameBranch: "Rename branch", BranchUpstreamOptionsTitle: "Upstream options",