Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
81c239d
feat: add support for dynamic task finished text
robberwick Jan 31, 2026
09fb928
feat: add merge-method fields to Repository, node ID and AutoMergeReq…
robberwick Jan 31, 2026
9fbabed
feat: add AutoMergeIcon constant and visual indicator for auto-merge …
robberwick Feb 28, 2026
e6b8d5e
feat: add MergePullRequest GraphQL mutation and mergeMethodForRepo he…
robberwick Feb 28, 2026
c9fda6a
feat: replace gh pr merge subprocess with MergePullRequest GraphQL mu…
robberwick Feb 28, 2026
4577d88
feat: harden MergePullRequest with mutex, FF_MOCK_DATA support, and e…
robberwick Feb 28, 2026
3213d3c
feat: handle AutoMergeEnabled in prssection and UpdatePRMsg in repose…
robberwick Feb 28, 2026
156cf59
fix: add IsDraft guard to MergePR and remove unused IsQueued field
robberwick Feb 28, 2026
bc7c533
test: add shared GraphQL mock client test helper package
robberwick Feb 28, 2026
e82449b
test: add unit tests for mergeMethodForRepo and MergePullRequest
robberwick Feb 28, 2026
fe92d51
test: add integration tests for MergePR task, extractPullRequestData,…
robberwick Feb 28, 2026
b77eed2
refactor: move AutoMergeEnabled from API model to UI component wrapper
robberwick Mar 7, 2026
3d1d464
fix: remove IsInMergeQueue from PRMergeStatus and drop tautological a…
robberwick Mar 14, 2026
a24d794
fix: protect FetchPullRequests and FetchPullRequest lazy-init with cl…
robberwick Mar 14, 2026
fe8aebd
feat: propagate AutoMergeEnabled flag to PullRequestData and reposection
robberwick Mar 14, 2026
7a8054d
feat: show auto-merge icon in branch section and guard nil PR in Rend…
robberwick Mar 14, 2026
4904d34
test: refactor TestUpdatePRMsg_AutoMergeEnabled_SetsFlag to use m.Upd…
robberwick Mar 14, 2026
e20fa6f
docs: clarify UNKNOWN and default fallback behaviour in MergePullRequest
robberwick Mar 14, 2026
5e77928
test: add missing assert import to tasks/pr_test.go
robberwick Mar 14, 2026
72522d2
fix: exclude AutoMergeEnabled from GraphQL query with graphql:"-" tag
robberwick Mar 14, 2026
297cdd7
fix: move AutoMergeEnabled off data.PullRequestData onto branch.Branch
robberwick Mar 14, 2026
87ca472
fix: guard against nil markdownStyle in GetMarkdownRenderer
robberwick Mar 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions internal/data/graphql_test_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package data

import (
"net/http"
"testing"

gh "github.com/cli/go-gh/v2/pkg/api"
graphqltest "github.com/dlvhdr/gh-dash/v4/internal/testhelpers/graphql"
)

// newMockGraphQLClient creates a GraphQL client backed by the given handler.
// It is a package-local wrapper around graphqltest.NewMockGraphQLClient so
// that internal/data tests can call it without importing the test-helper
// package name explicitly.
func newMockGraphQLClient(t *testing.T, handler http.Handler) *gh.GraphQLClient {
t.Helper()
return graphqltest.NewMockGraphQLClient(t, handler)
}
182 changes: 182 additions & 0 deletions internal/data/prapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"sync"
"time"

"charm.land/log/v2"
Expand All @@ -17,6 +18,14 @@ import (
"github.com/dlvhdr/gh-dash/v4/internal/tui/theme"
)

type AutoMergeRequest struct {
EnabledAt time.Time `graphql:"enabledAt"`
MergeMethod string `graphql:"mergeMethod"`
EnabledBy struct {
Login string `graphql:"login"`
} `graphql:"enabledBy"`
}

type SuggestedReviewer struct {
IsAuthor bool
IsCommenter bool
Expand Down Expand Up @@ -64,6 +73,7 @@ type EnrichedPullRequestData struct {
}

type PullRequestData struct {
ID string
Number int
Title string
Body string
Expand Down Expand Up @@ -96,6 +106,7 @@ type PullRequestData struct {
Files ChangedFiles `graphql:"files(first: 5)"`
IsDraft bool
IsInMergeQueue bool
AutoMergeRequest *AutoMergeRequest
Commits Commits `graphql:"commits(last: 1)"`
Labels PRLabels `graphql:"labels(first: 6)"`
MergeStateStatus MergeStateStatus `graphql:"mergeStateStatus"`
Expand Down Expand Up @@ -465,6 +476,7 @@ type PullRequestsResponse struct {
}

var (
clientMu sync.Mutex
client *gh.GraphQLClient
cachedClient *gh.GraphQLClient
)
Expand All @@ -488,6 +500,7 @@ func IsEnrichmentCacheCleared() bool {

func FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequestsResponse, error) {
var err error
clientMu.Lock()
if client == nil {
if config.IsFeatureEnabled(config.FF_MOCK_DATA) {
log.Info("using mock data", "server", "https://localhost:3000")
Expand All @@ -501,6 +514,7 @@ func FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequest
client, err = gh.DefaultGraphQLClient()
}
}
clientMu.Unlock()

if err != nil {
return PullRequestsResponse{}, err
Expand Down Expand Up @@ -545,12 +559,15 @@ func FetchPullRequests(query string, limit int, pageInfo *PageInfo) (PullRequest

func FetchPullRequest(prUrl string) (EnrichedPullRequestData, error) {
var err error
clientMu.Lock()
if client == nil {
client, err = gh.DefaultGraphQLClient()
if err != nil {
clientMu.Unlock()
return EnrichedPullRequestData{}, err
}
}
clientMu.Unlock()

var queryResult struct {
Resource struct {
Expand All @@ -573,3 +590,168 @@ func FetchPullRequest(prUrl string) (EnrichedPullRequestData, error) {

return queryResult.Resource.PullRequest, nil
}

// PRMergeStatus represents the outcome of a merge action.
type PRMergeStatus struct {
State string // OPEN, CLOSED, MERGED
HasAutoMerge bool
}

// mergeMethodForRepo returns the GraphQL merge method string to use based on
// which methods the repository allows, in priority order: MERGE > SQUASH > REBASE.
//
// NOTE: Future enhancement — when multiple merge methods are allowed, consider:
// - Prompting the user to select from the available options before merging
// - Splitting MergePR into separate MergePR / SquashPR / RebasePR task functions
// so the user can invoke them with explicit keybindings
func mergeMethodForRepo(repo Repository) (graphql.String, error) {
switch {
case repo.AllowMergeCommit:
return "MERGE", nil
case repo.AllowSquashMerge:
return "SQUASH", nil
case repo.AllowRebaseMerge:
return "REBASE", nil
default:
return "", fmt.Errorf("repository has no allowed merge methods")
}
}

// enableAutoMerge calls the enablePullRequestAutoMerge GraphQL mutation and
// returns the resulting PRMergeStatus.
func enableAutoMerge(prNodeID string, mergeMethod graphql.String) (PRMergeStatus, error) {
var mutation struct {
EnablePullRequestAutoMerge struct {
PullRequest struct {
State string `graphql:"state"`
AutoMergeRequest *struct {
EnabledAt time.Time `graphql:"enabledAt"`
} `graphql:"autoMergeRequest"`
} `graphql:"pullRequest"`
} `graphql:"enablePullRequestAutoMerge(input: $input)"`
}
type EnablePullRequestAutoMergeInput struct {
PullRequestID string `json:"pullRequestId"`
MergeMethod graphql.String `json:"mergeMethod"`
}
variables := map[string]any{
"input": EnablePullRequestAutoMergeInput{
PullRequestID: prNodeID,
MergeMethod: mergeMethod,
},
}
if err := client.Mutate("EnablePullRequestAutoMerge", &mutation, variables); err != nil {
return PRMergeStatus{}, err
}
hasAutoMerge := mutation.EnablePullRequestAutoMerge.PullRequest.AutoMergeRequest != nil
log.Info("Auto-merge enabled for PR", "nodeID", prNodeID, "hasAutoMerge", hasAutoMerge)
return PRMergeStatus{
State: mutation.EnablePullRequestAutoMerge.PullRequest.State,
HasAutoMerge: hasAutoMerge,
}, nil
}

// MergePullRequest performs the appropriate merge action via a single GraphQL
// mutation, returning the outcome directly from the mutation response without
// a follow-up query.
//
// The mutation chosen depends on the PR's current mergeStateStatus
// https://docs.github.com/en/graphql/reference/enums#mergestatestatus
// - CLEAN, UNSTABLE, or HAS_HOOKS → mergePullRequest
// - BLOCKED → enablePullRequestAutoMerge
// - BEHIND → enablePullRequestAutoMerge
// - DIRTY → error
// - UNKNOWN → log warning, attempt enablePullRequestAutoMerge
//
// Note: draft PRs are not handled here; callers should check IsDraft before
// invoking this function.
func MergePullRequest(prNodeID string, mergeStateStatus MergeStateStatus, repo Repository) (PRMergeStatus, error) {
clientMu.Lock()
if client == nil {
var err error
if config.IsFeatureEnabled(config.FF_MOCK_DATA) {
log.Info("using mock data", "server", "https://localhost:3000")
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
client, err = gh.NewGraphQLClient(gh.ClientOptions{Host: "localhost:3000", AuthToken: "fake-token"})
} else {
client, err = gh.DefaultGraphQLClient()
}
if err != nil {
clientMu.Unlock()
return PRMergeStatus{}, err
}
}
clientMu.Unlock()

log.Debug("Performing PR merge via GraphQL", "nodeID", prNodeID, "mergeStateStatus", mergeStateStatus)

switch mergeStateStatus {
case "CLEAN", "UNSTABLE", "HAS_HOOKS":
mergeMethod, err := mergeMethodForRepo(repo)
if err != nil {
return PRMergeStatus{}, err
}

var mutation struct {
MergePullRequest struct {
PullRequest struct {
State string `graphql:"state"`
} `graphql:"pullRequest"`
} `graphql:"mergePullRequest(input: $input)"`
}
type MergePullRequestInput struct {
PullRequestID string `json:"pullRequestId"`
MergeMethod graphql.String `json:"mergeMethod"`
}
variables := map[string]any{
"input": MergePullRequestInput{
PullRequestID: prNodeID,
MergeMethod: mergeMethod,
},
}
if err := client.Mutate("MergePullRequest", &mutation, variables); err != nil {
return PRMergeStatus{}, err
}
log.Info("PR merged directly", "nodeID", prNodeID, "state", mutation.MergePullRequest.PullRequest.State)
return PRMergeStatus{State: mutation.MergePullRequest.PullRequest.State}, nil

case "DIRTY":
return PRMergeStatus{}, fmt.Errorf("PR has merge conflicts, please resolve locally")

case "BLOCKED", "BEHIND":
mergeMethod, err := mergeMethodForRepo(repo)
if err != nil {
return PRMergeStatus{}, err
}
return enableAutoMerge(prNodeID, mergeMethod)

case "UNKNOWN":
// UNKNOWN is returned by GitHub when the merge state cannot be
// determined (e.g. checks are still pending or the state is
// temporarily unavailable). We optimistically attempt to enable
// auto-merge so that the PR merges automatically once the
// blocking condition clears. The warning is intentionally
// log-only; surfacing it to the user is left as a future
// enhancement (see: https://github.com/dlvhdr/gh-dash/discussions/546).
log.Warn("Unknown merge state status, attempting auto-merge", "status", mergeStateStatus)
mergeMethod, err := mergeMethodForRepo(repo)
if err != nil {
return PRMergeStatus{}, err
}
return enableAutoMerge(prNodeID, mergeMethod)

default:
// Any future MergeStateStatus values not listed above fall here.
// We apply the same optimistic enableAutoMerge strategy rather
// than hard-failing, so that newly-introduced GitHub statuses
// don't silently break the merge workflow. As with UNKNOWN,
// the warning is log-only for now; a user-visible notification
// is a future enhancement.
log.Warn("Unrecognised merge state status, attempting auto-merge", "status", mergeStateStatus)
mergeMethod, err := mergeMethodForRepo(repo)
if err != nil {
return PRMergeStatus{}, err
}
return enableAutoMerge(prNodeID, mergeMethod)
}
}
Loading