Show a Github PR's combined checks state in branches list (and main view for selected branch) (#5874)

For a while now we have been showing Github icons in the branches panel
to indicate which branches have an associated pull request. This PR
changes this so that open PRs (which used to show a green Github icon)
now show a colored symbol indicating the combined state of the Github
checks (if any are configured for this repo):

<img width="1046" height="418" alt="image"
src="https://github.com/user-attachments/assets/3e9074a6-0ae4-423a-90d9-306863daa46c"
/>

This lets you see at a glance which of your PRs are ready to merge.
(Note however that this feature knows nothing about which of the checks
are _required_ for merging, so a branch showing a failure icon may still
be ready to merge if the failing checks are not required ones.)

It may be a little confusing for new users to see two green checkmarks
for a branch, one to the left of the name and one to the right; they
mean very different things. Still, seeing the Github checks state in the
branches list is so useful that I think it's a good compromise;
hopefully users will learn quickly enough what all the icons mean.

When selecting a branch that has a pull request, the main view already
showed some information about the PR (its title, pr number, and the
state (open/closed/merged). This PR adds an aggregated pull request
checks field to this, indicating the overall checks state
(passed/failed/pending/...). It has a hyperlink taking you directly to
the checks tab on Github.

<img width="1538" height="246" alt="CleanShot 2026-07-30 at 12 32 25@2x"
src="https://github.com/user-attachments/assets/68aa24da-d78a-45d7-8812-db8b509bbbc8"
/>

The update frequency of the checks badge is tied to the auto-fetch
cadence for now (once every minute by default); you can fetch manually
to force an update, and some user actions (including the focus-in
refresh) update the PR states too, but anything more fancy (e.g.
fetching just the checks state of the selected branch) would have been
more work, and I want to see if this is good enough.
This commit is contained in:
Stefan Haller 2026-08-02 19:08:25 +02:00 committed by GitHub
commit 669c445eb6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 302 additions and 43 deletions

View file

@ -85,12 +85,25 @@ type PullRequestNode struct {
HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
State string `json:"state"`
IsDraft bool `json:"isDraft"`
HeadRef GithubRef `json:"headRef"`
}
type GithubRepositoryOwner struct {
Login string `json:"login"`
}
type GithubRef struct {
Target GithubGitObject `json:"target"`
}
type GithubGitObject struct {
StatusCheckRollup GithubStatusCheckRollup `json:"statusCheckRollup"`
}
type GithubStatusCheckRollup struct {
State string `json:"state"`
}
type graphQLRequest struct {
Query string `json:"query"`
Variables map[string]string `json:"variables"`
@ -121,6 +134,15 @@ func fetchPullRequestsQuery(branches []string, owner string, repo string) (strin
number
url
isDraft
headRef {
target {
... on Commit {
statusCheckRollup {
state
}
}
}
}
headRepositoryOwner {
login
}
@ -231,9 +253,12 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string,
return nil, err
}
return parsePullRequestsResponse(respBytes)
}
func parsePullRequestsResponse(respBytes []byte) ([]*models.GithubPullRequest, error) {
var result Response
err = json.Unmarshal(respBytes, &result)
if err != nil {
if err := json.Unmarshal(respBytes, &result); err != nil {
return nil, err
}
@ -246,6 +271,7 @@ func (self *GitHubCommands) fetchRecentPRsAux(endpoint string, repoOwner string,
Number: node.Number,
Title: node.Title,
State: lo.Ternary(node.IsDraft && node.State != "CLOSED", "DRAFT", node.State),
ChecksState: node.HeadRef.Target.StatusCheckRollup.State,
Url: node.Url,
HeadRepositoryOwner: models.GithubRepositoryOwner{
Login: node.HeadRepositoryOwner.Login,

View file

@ -76,6 +76,104 @@ func TestGraphQLEndpoint(t *testing.T) {
}
}
func TestFetchPullRequestsQueryFetchesOnlyAggregateCheckState(t *testing.T) {
query, variables := fetchPullRequestsQuery([]string{"feature"}, "owner", "repo")
assert.Contains(t, query, "headRef {")
assert.Contains(t, query, "... on Commit {")
assert.Contains(t, query, "statusCheckRollup {")
assert.NotContains(t, query, "contexts")
assert.Equal(t, map[string]string{
"owner": "owner",
"repo": "repo",
"branch1": "feature",
}, variables)
}
func TestParsePullRequestsResponse(t *testing.T) {
t.Run("flattens aliases and normalizes drafts", func(t *testing.T) {
response := []byte(`{
"data": {
"repository": {
"a1": {
"edges": [
{
"node": {
"title": "Add feature",
"headRefName": "feature",
"number": 42,
"url": "https://github.com/jesseduffield/lazygit/pull/42",
"headRepositoryOwner": {"login": "contributor"},
"state": "OPEN",
"isDraft": false,
"headRef": {
"target": {
"statusCheckRollup": {"state": "SUCCESS"}
}
}
}
}
]
},
"a2": {
"edges": [
{
"node": {
"title": "Draft feature",
"headRefName": "draft-feature",
"number": 43,
"url": "https://github.com/jesseduffield/lazygit/pull/43",
"headRepositoryOwner": {"login": "contributor"},
"state": "OPEN",
"isDraft": true,
"headRef": null
}
}
]
}
}
}
}`)
prs, err := parsePullRequestsResponse(response)
assert.NoError(t, err)
assert.ElementsMatch(t, []*models.GithubPullRequest{
{
HeadRefName: "feature",
Number: 42,
Title: "Add feature",
State: "OPEN",
ChecksState: "SUCCESS",
Url: "https://github.com/jesseduffield/lazygit/pull/42",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"},
},
{
HeadRefName: "draft-feature",
Number: 43,
Title: "Draft feature",
State: "DRAFT",
Url: "https://github.com/jesseduffield/lazygit/pull/43",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "contributor"},
},
}, prs)
})
t.Run("returns an empty slice for an empty result", func(t *testing.T) {
prs, err := parsePullRequestsResponse([]byte(`{"data":{"repository":{}}}`))
assert.NoError(t, err)
assert.Empty(t, prs)
})
t.Run("rejects malformed JSON", func(t *testing.T) {
prs, err := parsePullRequestsResponse([]byte(`{"data":`))
assert.Error(t, err)
assert.Nil(t, prs)
})
}
func TestGenerateGithubPullRequestMap(t *testing.T) {
cases := []struct {
name string
@ -99,6 +197,7 @@ func TestGenerateGithubPullRequestMap(t *testing.T) {
Number: 42,
Title: "Add feature",
State: "OPEN",
ChecksState: "PENDING",
Url: "https://github.com/jesseduffield/lazygit/pull/42",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},
@ -122,6 +221,7 @@ func TestGenerateGithubPullRequestMap(t *testing.T) {
Number: 42,
Title: "Add feature",
State: "OPEN",
ChecksState: "PENDING",
Url: "https://github.com/jesseduffield/lazygit/pull/42",
HeadRepositoryOwner: models.GithubRepositoryOwner{Login: "jesseduffield"},
},

View file

@ -5,6 +5,7 @@ type GithubPullRequest struct {
Number int `json:"number"`
Title string `json:"title"`
State string `json:"state"` // "MERGED", "OPEN", "CLOSED", "DRAFT"
ChecksState string `json:"checksState"`
Url string `json:"url"`
HeadRepositoryOwner GithubRepositoryOwner `json:"headRepositoryOwner"`
}

View file

@ -850,6 +850,7 @@ type CachedPullRequest struct {
Number int `yaml:"number"`
Title string `yaml:"title"`
State string `yaml:"state"`
ChecksState string `yaml:"checksState,omitempty"`
Url string `yaml:"url"`
HeadRepositoryOwner string `yaml:"headRepositoryOwner"`
}

View file

@ -5,15 +5,12 @@ import (
"fmt"
"strings"
"github.com/gookit/color"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/context"
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
@ -214,13 +211,7 @@ func (self *BranchesController) GetOnRenderToMain() func() {
pr, ok := self.c.Model().PullRequestsMap[branch.Name]
if ok && presentation.ShouldShowPrForBranch(pr, branch.Name, self.c.UserConfig()) {
icon := lo.Ternary(icons.IsIconEnabled(), icons.IconForRemoteUrl(pr.Url)+" ", "")
ptyTask.Prefix = style.PrintHyperlink(fmt.Sprintf("%s%s %s %s\n",
icon,
coloredStateText(pr.State),
pr.Title,
style.FgCyan.Sprintf("#%d", pr.Number)),
pr.Url)
ptyTask.Prefix = presentation.FormatPullRequestHeader(pr, self.c.Tr)
ptyTask.Prefix += strings.Repeat("─", self.c.Contexts().Normal.GetView().InnerWidth()) + "\n"
}
}
@ -236,37 +227,6 @@ func (self *BranchesController) GetOnRenderToMain() func() {
}
}
func stateText(state string) string {
var icon, label string
switch state {
case "OPEN":
icon, label = " ", "Open"
case "CLOSED":
icon, label = " ", "Closed"
case "MERGED":
icon, label = " ", "Merged"
case "DRAFT":
icon, label = " ", "Draft"
default:
return ""
}
if icons.IsIconEnabled() {
return icon + label
}
return label
}
func coloredStateText(state string) string {
if icons.IsIconEnabled() {
return fmt.Sprintf("%s%s%s",
presentation.WithPrColor(state, "", false),
presentation.WithPrColor(state, color.RGB(0xFF, 0xFF, 0xFF, false).Sprint(stateText(state)), true),
presentation.WithPrColor(state, "", false))
}
return presentation.WithPrColor(state, stateText(state), false)
}
func (self *BranchesController) viewUpstreamOptions(selectedBranch *models.Branch) error {
upstream := lo.Ternary(selectedBranch.RemoteBranchStoredLocally(),
selectedBranch.ShortUpstreamRefName(),

View file

@ -1770,6 +1770,7 @@ func (self *RefreshHelper) savePullRequestsToCache(prs []*models.GithubPullReque
Number: pr.Number,
Title: pr.Title,
State: pr.State,
ChecksState: pr.ChecksState,
Url: pr.Url,
HeadRepositoryOwner: pr.HeadRepositoryOwner.Login,
}

View file

@ -674,6 +674,7 @@ func (gui *Gui) loadCachedPullRequests() []*models.GithubPullRequest {
Number: cached.Number,
Title: cached.Title,
State: cached.State,
ChecksState: cached.ChecksState,
Url: cached.Url,
HeadRepositoryOwner: models.GithubRepositoryOwner{
Login: cached.HeadRepositoryOwner,

View file

@ -150,6 +150,12 @@ func getBranchDisplayStrings(
prIcon = "●"
}
coloredPrIcon = WithPrColor(pr.State, prIcon, false)
if pr.State == "OPEN" {
icon, _, textStyle := checksStatePresentation(pr.ChecksState, tr)
if icon != "" {
coloredPrIcon = textStyle.Sprint(icon)
}
}
}
res = append(res, coloredPrIcon)
@ -287,6 +293,79 @@ func WithPrColor(state string, text string, isBg bool) string {
}
}
func FormatPullRequestHeader(pr *models.GithubPullRequest, tr *i18n.TranslationSet) string {
icon := lo.Ternary(icons.IsIconEnabled(), icons.IconForRemoteUrl(pr.Url)+" ", "")
stateText := coloredPullRequestStateText(pr.State)
checksStateText := coloredChecksStateText(pr.ChecksState, tr)
numberText := style.FgCyan.Sprintf("#%d", pr.Number)
// The checks status links to the checks tab, so it needs to be its own
// hyperlink separate from the rest of the header.
parts := []string{style.PrintHyperlink(icon+stateText, pr.Url)}
if checksStateText != "" {
parts = append(parts, style.PrintHyperlink(checksStateText, strings.TrimSuffix(pr.Url, "/")+"/checks"))
}
parts = append(parts, style.PrintHyperlink(fmt.Sprintf("%s %s\n", pr.Title, numberText), pr.Url))
return strings.Join(parts, " ")
}
func pullRequestStateText(state string) string {
var icon, label string
switch state {
case "OPEN":
icon, label = " ", "Open"
case "CLOSED":
icon, label = " ", "Closed"
case "MERGED":
icon, label = " ", "Merged"
case "DRAFT":
icon, label = " ", "Draft"
default:
return ""
}
if icons.IsIconEnabled() {
return icon + label
}
return label
}
func coloredPullRequestStateText(state string) string {
if icons.IsIconEnabled() {
return fmt.Sprintf("%s%s%s",
WithPrColor(state, "", false),
WithPrColor(state, color.RGB(0xFF, 0xFF, 0xFF, false).Sprint(pullRequestStateText(state)), true),
WithPrColor(state, "", false))
}
return WithPrColor(state, pullRequestStateText(state), false)
}
func checksStatePresentation(state string, tr *i18n.TranslationSet) (string, string, style.TextStyle) {
switch state {
case "SUCCESS":
return "✓", tr.PullRequestChecksPassing, style.FgGreen
case "PENDING":
return "●", tr.PullRequestChecksPending, style.FgYellow
case "FAILURE":
return "✗", tr.PullRequestChecksFailing, style.FgRed
case "ERROR":
return "!", tr.PullRequestChecksError, style.FgRed
case "EXPECTED":
return "○", tr.PullRequestChecksExpected, style.FgDefault
default:
return "", "", style.Nothing
}
}
func coloredChecksStateText(state string, tr *i18n.TranslationSet) string {
icon, text, textStyle := checksStatePresentation(state, tr)
if text != "" {
return textStyle.Sprintf("%s %s", icon, text)
}
return ""
}
func ShouldShowPrForBranch(pr *models.GithubPullRequest, branchName string, userConfig *config.UserConfig) bool {
if !lo.Contains(userConfig.Git.MainBranches, branchName) {
return true

View file

@ -10,7 +10,9 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/common"
"github.com/jesseduffield/lazygit/pkg/gui/presentation/icons"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/jesseduffield/lazygit/pkg/i18n"
"github.com/samber/lo"
"github.com/stretchr/testify/assert"
"github.com/xo/terminfo"
@ -22,6 +24,84 @@ func makeAtomic(v int32) *atomic.Int32 {
return &result
}
func TestFormatPullRequestHeader(t *testing.T) {
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
defer color.ForceSetColorLevel(oldColorLevel)
icons.SetNerdFontsVersion("")
pr := &models.GithubPullRequest{
Title: "Improve checks",
Number: 5871,
State: "OPEN",
ChecksState: "SUCCESS",
Url: "https://github.com/jesseduffield/lazygit/pull/5871",
}
numberText := style.FgCyan.Sprint("#5871")
tr := i18n.EnglishTranslationSet()
t.Run("links checks separately from the rest of the header", func(t *testing.T) {
actual := FormatPullRequestHeader(pr, tr)
expected := style.PrintHyperlink("Open", pr.Url) +
" " +
style.PrintHyperlink("✓ Passing", pr.Url+"/checks") +
" " +
style.PrintHyperlink("Improve checks "+numberText+"\n", pr.Url)
assert.Equal(t, expected, actual)
})
t.Run("leaves the separator unlinked when checks are unavailable", func(t *testing.T) {
prWithoutChecks := *pr
prWithoutChecks.ChecksState = ""
actual := FormatPullRequestHeader(&prWithoutChecks, tr)
expected := style.PrintHyperlink("Open", pr.Url) +
" " +
style.PrintHyperlink("Improve checks "+numberText+"\n", pr.Url)
assert.Equal(t, expected, actual)
})
t.Run("avoids a double slash in the checks URL", func(t *testing.T) {
prWithTrailingSlash := *pr
prWithTrailingSlash.Url += "/"
actual := FormatPullRequestHeader(&prWithTrailingSlash, tr)
assert.Contains(t, actual, "https://github.com/jesseduffield/lazygit/pull/5871/checks")
assert.NotContains(t, actual, "pull/5871//checks")
})
}
func TestChecksStatePresentation(t *testing.T) {
tr := i18n.EnglishTranslationSet()
testCases := []struct {
name string
state string
expectedIcon string
expectedText string
expectedStyle style.TextStyle
}{
{name: "success", state: "SUCCESS", expectedIcon: "✓", expectedText: "Passing", expectedStyle: style.FgGreen},
{name: "pending", state: "PENDING", expectedIcon: "●", expectedText: "Pending", expectedStyle: style.FgYellow},
{name: "failure", state: "FAILURE", expectedIcon: "✗", expectedText: "Failing", expectedStyle: style.FgRed},
{name: "error", state: "ERROR", expectedIcon: "!", expectedText: "Error", expectedStyle: style.FgRed},
{name: "expected", state: "EXPECTED", expectedIcon: "○", expectedText: "Expected", expectedStyle: style.FgDefault},
{name: "empty", state: "", expectedIcon: "", expectedText: "", expectedStyle: style.Nothing},
{name: "unknown", state: "FUTURE_STATE", expectedIcon: "", expectedText: "", expectedStyle: style.Nothing},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
icon, text, textStyle := checksStatePresentation(testCase.state, tr)
assert.Equal(t, testCase.expectedIcon, icon)
assert.Equal(t, testCase.expectedText, text)
assert.Equal(t, testCase.expectedStyle, textStyle)
})
}
}
func Test_getBranchDisplayStrings(t *testing.T) {
scenarios := []struct {
branch *models.Branch

View file

@ -367,6 +367,11 @@ type TranslationSet struct {
FwdNoLocalUpstream string
FwdCommitsToPush string
PullRequestNoUpstream string
PullRequestChecksPassing string
PullRequestChecksPending string
PullRequestChecksFailing string
PullRequestChecksError string
PullRequestChecksExpected string
ErrorOccurred string
ConflictLabel string
PendingRebaseTodosSectionHeader string
@ -1519,6 +1524,11 @@ func EnglishTranslationSet() *TranslationSet {
FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally",
FwdCommitsToPush: "Cannot fast-forward a branch with commits to push",
PullRequestNoUpstream: "Cannot open a pull request for a branch with no upstream",
PullRequestChecksPassing: "Passing",
PullRequestChecksPending: "Pending",
PullRequestChecksFailing: "Failing",
PullRequestChecksError: "Error",
PullRequestChecksExpected: "Expected",
ErrorOccurred: "An error occurred! Please create an issue at",
ConflictLabel: "CONFLICT",
PendingRebaseTodosSectionHeader: "Pending rebase todos",