Add pull requests to lazygit's model and refresh them

Co-authored-by: Stefan Haller <stefan@haller-berlin.de>
This commit is contained in:
Jesse Duffield 2026-03-25 10:27:58 +01:00 committed by Stefan Haller
parent 1c89398288
commit d33fa5bb05
6 changed files with 185 additions and 23 deletions

View file

@ -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()

View file

@ -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
}

View file

@ -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, ""),

View file

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

View file

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

View file

@ -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: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b",
RenameBranch: "Rename branch",
BranchUpstreamOptionsTitle: "Upstream options",