diff --git a/README.md b/README.md index 7b067c7..694a204 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,8 @@ open = ["enter", "l"] dashboard = ["d"] health = ["H"] edit = ["e"] +auto_merge = ["a"] +merge_now = ["M"] toggle_list = ["tab"] [keybindings.threads] @@ -256,6 +258,8 @@ history and metadata. | `H` | Open application health and diagnostics | | `d` | Open the current pull request dashboard | | `e` | Edit the current PR title, target branch, and description from its dashboard | +| `a` | Enable or disable auto-merge from the dashboard | +| `M` | Merge now when GitHub reports that all represented requirements are satisfied | | `/` | Fuzzy-search paths and filter with `status:`, `author:`, `updated:true` | | `F` | Clear active thread filters | | `n` / `N` | Next / previous thread with a new update | @@ -288,6 +292,15 @@ cancels. Replies and resolution changes require an explicit `y` confirmation. Write keys remain disabled for cached snapshots, during refreshes, and whenever GitHub does not grant the corresponding capability. +Auto-merge and immediate merge actions are available from the dashboard and +always require confirmation. The selected method is the repository's first +available method in `squash`, `merge`, then `rebase` preference order. Both +mutations include the currently displayed head commit OID, so a force-push or +new commit prevents a stale merge. “Merge now” is gated for drafts, conflicts, +required reviews, required checks, unresolved required conversations, closed +PRs, and branches that require a merge queue; GitHub performs the final +permission and mergeability validation. + The dashboard editor works with raw Markdown so template checklists can be updated directly. The active line is highlighted without inserting a layout-changing block character. It opens with the description focused; @@ -324,10 +337,11 @@ submission is blocked rather than overwriting the newer metadata. ## Current scope -The application can reply to review threads, resolve or unresolve them, and -update the PR title, target branch, and description. Comment reactions remain -read-only. Other write operations remain disabled. The dashboard shows the -capability gate, including why each action is unavailable. Read state +The application can reply to review threads, resolve or unresolve them, update +the PR title, target branch, and description, enable or disable auto-merge, and +merge an eligible PR immediately. Comment reactions remain read-only. Other +write operations remain disabled. The dashboard shows the capability gate, +including why each action is unavailable. Read state persists beside the configuration, and recent PR data is cached for offline fallback. Check contexts and annotations are paginated. GitHub features which depend on server-side context, such as unfurling issue references or displaying diff --git a/TODO.md b/TODO.md index 06d6c9b..e91b333 100644 --- a/TODO.md +++ b/TODO.md @@ -86,9 +86,9 @@ editing are already implemented. multiple suggestions, conflicts, dirty Git/Jujutsu workspaces, and remote application. - Add draft/ready-for-review and close/reopen actions. -- Consider merge, auto-merge, and merge-queue actions only after required - checks, review decision, permissions, stale-head protection, and destructive - confirmations are represented accurately. +- Add explicit merge-method selection and merge-queue enqueue/dequeue actions. + Auto-merge toggling and guarded immediate merge are implemented with + stale-head protection and destructive confirmation. - Define consistent optimistic-update and rollback behavior for every mutation. Preserve drafts and server responses when a post-mutation refresh fails. diff --git a/cache.go b/cache.go index 56f2f4b..6bc1ef5 100644 --- a/cache.go +++ b/cache.go @@ -164,6 +164,28 @@ func (c *CachedGitHubService) UpdatePullRequest( return writer.UpdatePullRequest(ctx, pullRequestID, update) } +func (c *CachedGitHubService) SetPullRequestAutoMerge( + ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool, +) (*AutoMergeRequest, error) { + writer, ok := c.remote.(GitHubMergeService) + if !ok { + return nil, errors.New("GitHub service does not support auto-merge") + } + return writer.SetPullRequestAutoMerge( + ctx, pullRequestID, expectedHeadOID, mergeMethod, enabled, + ) +} + +func (c *CachedGitHubService) MergePullRequest( + ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, +) (PullRequestMergeResult, error) { + writer, ok := c.remote.(GitHubMergeService) + if !ok { + return PullRequestMergeResult{}, errors.New("GitHub service does not support merging") + } + return writer.MergePullRequest(ctx, pullRequestID, expectedHeadOID, mergeMethod) +} + func (c *CachedGitHubService) ListBranches( ctx context.Context, owner, repo string, ) ([]RepositoryBranch, error) { diff --git a/github.go b/github.go index 59730d0..94761ce 100644 --- a/github.go +++ b/github.go @@ -30,6 +30,11 @@ type GitHubPullRequestWriteService interface { UpdatePullRequest(context.Context, string, PullRequestMetadata) (PullRequestMetadata, error) } +type GitHubMergeService interface { + SetPullRequestAutoMerge(context.Context, string, string, string, bool) (*AutoMergeRequest, error) + MergePullRequest(context.Context, string, string, string) (PullRequestMergeResult, error) +} + type GitHubBranchService interface { ListBranches(context.Context, string, string) ([]RepositoryBranch, error) } @@ -429,7 +434,7 @@ func nullableCursor(cursor string) any { const detailsQuery = ` query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { - url + url mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission defaultBranchRef { name } rulesets(first: 100, includeParents: true, targets: [BRANCH]) { @@ -440,11 +445,13 @@ query PullRequestDetails($owner: String!, $name: String!, $number: Int!) { } } pullRequest(number: $number) { - id number title url body isDraft createdAt updatedAt + id number title url body isDraft createdAt updatedAt state merged mergedAt mergeable mergeStateStatus reviewDecision + autoMergeRequest { mergeMethod enabledAt enabledBy { login } } mergeQueueEntry { state position enqueuedAt estimatedTimeToMerge } baseRefName headRefName headRefOid - viewerCanUpdate viewerCanReact viewerCanSubscribe viewerCanEnableAutoMerge + viewerCanUpdate viewerCanReact viewerCanSubscribe + viewerCanEnableAutoMerge viewerCanDisableAutoMerge baseRef { target { ... on Commit { oid } } branchProtectionRule { @@ -631,6 +638,29 @@ mutation UpdatePullRequest($input: UpdatePullRequestInput!) { } }` +const enableAutoMergeMutation = ` +mutation EnablePullRequestAutoMerge($input: EnablePullRequestAutoMergeInput!) { + enablePullRequestAutoMerge(input: $input) { + pullRequest { + autoMergeRequest { mergeMethod enabledAt enabledBy { login } } + } + } +}` + +const disableAutoMergeMutation = ` +mutation DisablePullRequestAutoMerge($input: DisablePullRequestAutoMergeInput!) { + disablePullRequestAutoMerge(input: $input) { + pullRequest { id autoMergeRequest { mergeMethod } } + } +}` + +const mergePullRequestMutation = ` +mutation MergePullRequest($input: MergePullRequestInput!) { + mergePullRequest(input: $input) { + pullRequest { merged mergedAt } + } +}` + type githubActor struct { Login string `json:"login"` Name string `json:"name"` @@ -759,14 +789,20 @@ type githubRuleset struct { } type githubPullRequestDetails struct { - ID, Title, URL, Body, Mergeable, MergeStateStatus string - ReviewDecision, BaseRefName, HeadRefName, HeadRefOID string - Number, Additions, Deletions, ChangedFiles int - IsDraft bool - CreatedAt, UpdatedAt time.Time - Author *githubActor - ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe, ViewerCanEnableAutoMerge bool - BaseRef *struct { + ID, Title, URL, Body, State, Mergeable, MergeStateStatus string + ReviewDecision, BaseRefName, HeadRefName, HeadRefOID string + Number, Additions, Deletions, ChangedFiles int + IsDraft, Merged bool + CreatedAt, UpdatedAt, MergedAt time.Time + Author *githubActor + ViewerCanUpdate, ViewerCanReact, ViewerCanSubscribe bool + ViewerCanEnableAutoMerge, ViewerCanDisableAutoMerge bool + AutoMergeRequest *struct { + MergeMethod string + EnabledAt time.Time + EnabledBy *githubActor + } + BaseRef *struct { Target *struct{ OID string } BranchProtectionRule *struct { RequiresApprovingReviews, RequiresStatusChecks bool @@ -1057,11 +1093,12 @@ func (c *GitHubClient) allCheckAnnotations( func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, number int) (PRDetails, error) { var data struct { Repository *struct { - URL string - ViewerPermission string `json:"viewerPermission"` - DefaultBranchRef *struct{ Name string } - Rulesets struct{ Nodes []githubRuleset } - PullRequest *githubPullRequestDetails `json:"pullRequest"` + URL string + ViewerPermission string `json:"viewerPermission"` + MergeCommitAllowed, SquashMergeAllowed, RebaseMergeAllowed bool + DefaultBranchRef *struct{ Name string } + Rulesets struct{ Nodes []githubRuleset } + PullRequest *githubPullRequestDetails `json:"pullRequest"` } `json:"repository"` } if err := c.query(ctx, detailsQuery, map[string]any{"owner": owner, "name": name, "number": number}, &data); err != nil { @@ -1138,6 +1175,7 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n }, Body: node.Body, CreatedAt: node.CreatedAt, BaseRef: node.BaseRefName, HeadRef: node.HeadRefName, HeadOID: node.HeadRefOID, Mergeable: node.Mergeable, MergeState: node.MergeStateStatus, + State: node.State, Merged: node.Merged, MergedAt: node.MergedAt, RepositoryURL: data.Repository.URL, Additions: node.Additions, Deletions: node.Deletions, ChangedFiles: node.ChangedFiles, CommitCount: node.Commits.TotalCount, CommentCount: node.Comments.TotalCount, @@ -1146,8 +1184,25 @@ func (c *GitHubClient) GetPullRequest(ctx context.Context, owner, name string, n Repository: data.Repository.ViewerPermission, CanUpdatePR: node.ViewerCanUpdate, CanReact: node.ViewerCanReact, CanSubscribe: node.ViewerCanSubscribe, CanEnableMerge: node.ViewerCanEnableAutoMerge, + CanDisableMerge: node.ViewerCanDisableAutoMerge, }, } + if data.Repository.SquashMergeAllowed { + details.AllowedMergeMethods = append(details.AllowedMergeMethods, "SQUASH") + } + if data.Repository.MergeCommitAllowed { + details.AllowedMergeMethods = append(details.AllowedMergeMethods, "MERGE") + } + if data.Repository.RebaseMergeAllowed { + details.AllowedMergeMethods = append(details.AllowedMergeMethods, "REBASE") + } + if node.AutoMergeRequest != nil { + details.AutoMerge = &AutoMergeRequest{ + MergeMethod: node.AutoMergeRequest.MergeMethod, + EnabledAt: node.AutoMergeRequest.EnabledAt, + EnabledBy: actorLogin(node.AutoMergeRequest.EnabledBy), + } + } if node.BaseRef != nil && node.BaseRef.Target != nil { details.BaseOID = node.BaseRef.Target.OID } @@ -1460,6 +1515,82 @@ func (c *GitHubClient) UpdatePullRequest( }, nil } +func (c *GitHubClient) SetPullRequestAutoMerge( + ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, enabled bool, +) (*AutoMergeRequest, error) { + if !enabled { + var data struct { + DisablePullRequestAutoMerge *struct{ PullRequest *struct{ ID string } } + } + if err := c.query(ctx, disableAutoMergeMutation, map[string]any{ + "input": map[string]any{"pullRequestId": pullRequestID}, + }, &data); err != nil { + return nil, err + } + if data.DisablePullRequestAutoMerge == nil || + data.DisablePullRequestAutoMerge.PullRequest == nil { + return nil, errors.New("GitHub returned no pull request after disabling auto-merge") + } + return nil, nil + } + var data struct { + EnablePullRequestAutoMerge *struct { + PullRequest *struct { + AutoMergeRequest *struct { + MergeMethod string + EnabledAt time.Time + EnabledBy *githubActor + } + } + } + } + input := map[string]any{ + "pullRequestId": pullRequestID, "expectedHeadOid": expectedHeadOID, + "mergeMethod": mergeMethod, + } + if err := c.query(ctx, enableAutoMergeMutation, map[string]any{"input": input}, &data); err != nil { + return nil, err + } + if data.EnablePullRequestAutoMerge == nil || + data.EnablePullRequestAutoMerge.PullRequest == nil || + data.EnablePullRequestAutoMerge.PullRequest.AutoMergeRequest == nil { + return nil, errors.New("GitHub returned no auto-merge request") + } + node := data.EnablePullRequestAutoMerge.PullRequest.AutoMergeRequest + return &AutoMergeRequest{ + MergeMethod: node.MergeMethod, EnabledAt: node.EnabledAt, + EnabledBy: actorLogin(node.EnabledBy), + }, nil +} + +func (c *GitHubClient) MergePullRequest( + ctx context.Context, pullRequestID, expectedHeadOID, mergeMethod string, +) (PullRequestMergeResult, error) { + var data struct { + MergePullRequest *struct { + PullRequest *struct { + Merged bool + MergedAt time.Time + } + } + } + if err := c.query(ctx, mergePullRequestMutation, map[string]any{ + "input": map[string]any{ + "pullRequestId": pullRequestID, "expectedHeadOid": expectedHeadOID, + "mergeMethod": mergeMethod, + }, + }, &data); err != nil { + return PullRequestMergeResult{}, err + } + if data.MergePullRequest == nil || data.MergePullRequest.PullRequest == nil || + !data.MergePullRequest.PullRequest.Merged { + return PullRequestMergeResult{}, errors.New("GitHub did not merge the pull request") + } + return PullRequestMergeResult{ + Merged: true, MergedAt: data.MergePullRequest.PullRequest.MergedAt, + }, nil +} + func convertReviewThread(thread githubReviewThread) ReviewThread { item := ReviewThread{ ID: thread.ID, Path: thread.Path, DiffSide: thread.DiffSide, diff --git a/github_test.go b/github_test.go index 4f5ba47..384eb7d 100644 --- a/github_test.go +++ b/github_test.go @@ -294,6 +294,67 @@ func TestUpdatePullRequestMutatesTitleBodyAndBaseBranch(t *testing.T) { } } +func TestMergeAndAutoMergeMutationsUseExpectedHeadOID(t *testing.T) { + requests := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + var request graphQLRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + input := request.Variables["input"].(map[string]any) + if input["pullRequestId"] != "pr-id" { + t.Fatalf("mutation input = %#v", input) + } + switch { + case strings.Contains(request.Query, "EnablePullRequestAutoMerge"): + if input["expectedHeadOid"] != "head" || input["mergeMethod"] != "SQUASH" { + t.Fatalf("enable input = %#v", input) + } + _, _ = w.Write([]byte(`{"data":{"enablePullRequestAutoMerge":{"pullRequest":{ + "autoMergeRequest":{"mergeMethod":"SQUASH","enabledAt":"2026-07-28T12:00:00Z", + "enabledBy":{"login":"me"}} + }}}}`)) + case strings.Contains(request.Query, "DisablePullRequestAutoMerge"): + _, _ = w.Write([]byte(`{"data":{"disablePullRequestAutoMerge":{ + "pullRequest":{"id":"pr-id","autoMergeRequest":null} + }}}`)) + case strings.Contains(request.Query, "MergePullRequest"): + if input["expectedHeadOid"] != "head" || input["mergeMethod"] != "SQUASH" { + t.Fatalf("merge input = %#v", input) + } + _, _ = w.Write([]byte(`{"data":{"mergePullRequest":{"pullRequest":{ + "merged":true,"mergedAt":"2026-07-28T12:01:00Z" + }}}}`)) + default: + t.Fatalf("unexpected mutation:\n%s", request.Query) + } + })) + defer server.Close() + + client := NewGitHubClient(server.URL, "secret") + auto, err := client.SetPullRequestAutoMerge( + context.Background(), "pr-id", "head", "SQUASH", true, + ) + if err != nil || auto == nil || auto.MergeMethod != "SQUASH" || auto.EnabledBy != "me" { + t.Fatalf("enable result = %#v, error = %v", auto, err) + } + if _, err := client.SetPullRequestAutoMerge( + context.Background(), "pr-id", "head", "SQUASH", false, + ); err != nil { + t.Fatal(err) + } + merged, err := client.MergePullRequest( + context.Background(), "pr-id", "head", "SQUASH", + ) + if err != nil || !merged.Merged || merged.MergedAt.IsZero() { + t.Fatalf("merge result = %#v, error = %v", merged, err) + } + if requests != 3 { + t.Fatalf("mutation requests = %d, want 3", requests) + } +} + func TestCheckContextsAndAnnotationsArePaginated(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var request graphQLRequest @@ -428,14 +489,18 @@ func TestGetPullRequestPaginatesThreadsCommentsConversationAndReviews(t *testing func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE","pullRequest":{ + _, _ = w.Write([]byte(`{"data":{"repository":{"viewerPermission":"WRITE", + "squashMergeAllowed":true,"mergeCommitAllowed":false,"rebaseMergeAllowed":true, + "pullRequest":{ "id":"pr","number":9,"title":"Fix","url":"u","body":"body","isDraft":false, "createdAt":"2025-12-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z", "mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","reviewDecision":"APPROVED", "additions":12,"deletions":4,"changedFiles":3, "baseRefName":"main","headRefName":"fix","headRefOid":"abcdef0123456789", "viewerCanUpdate":true,"viewerCanReact":true,"viewerCanSubscribe":true, - "viewerCanEnableAutoMerge":true, + "viewerCanEnableAutoMerge":false,"viewerCanDisableAutoMerge":true, + "autoMergeRequest":{"mergeMethod":"SQUASH","enabledAt":"2026-01-01T01:00:00Z", + "enabledBy":{"login":"zam"}}, "baseRef":{"branchProtectionRule":{"requiresApprovingReviews":true, "requiredApprovingReviewCount":2,"requiresStatusChecks":true, "requiresConversationResolution":true,"requiresCodeOwnerReviews":true}}, @@ -493,6 +558,11 @@ func TestGetPullRequestUsesOriginalLineAndMetadata(t *testing.T) { !got.Requirements.RequiresConversation || !got.Requirements.RequiresCodeOwnerReview { t.Fatalf("unexpected read capabilities: %#v", got) } + if got.AutoMerge == nil || got.AutoMerge.MergeMethod != "SQUASH" || + got.AutoMerge.EnabledBy != "zam" || !got.Permissions.CanDisableMerge || + strings.Join(got.AllowedMergeMethods, ",") != "SQUASH,REBASE" { + t.Fatalf("unexpected merge metadata: %#v", got) + } if len(got.Threads) != 1 || got.Threads[0].Line != 42 || got.Threads[0].StartLine != 40 || got.Threads[0].DiffSide != "RIGHT" || got.Threads[0].IsTruncated { t.Fatalf("unexpected thread: %#v", got.Threads) diff --git a/keybindings.go b/keybindings.go index c08dde5..50de233 100644 --- a/keybindings.go +++ b/keybindings.go @@ -40,6 +40,8 @@ type ViewKeyBindings struct { Dashboard []string `toml:"dashboard"` Health []string `toml:"health"` Edit []string `toml:"edit"` + AutoMerge []string `toml:"auto_merge"` + MergeNow []string `toml:"merge_now"` ToggleList []string `toml:"toggle_list"` } @@ -119,6 +121,7 @@ func defaultKeyBindings() KeyBindings { Views: ViewKeyBindings{ Open: []string{"enter", "l"}, Dashboard: []string{"d"}, Health: []string{"H"}, Edit: []string{"e"}, ToggleList: []string{"tab"}, + AutoMerge: []string{"a"}, MergeNow: []string{"M"}, }, Threads: ThreadKeyBindings{ Search: []string{"/"}, ClearFilter: []string{"F"}, @@ -294,6 +297,14 @@ func (k KeyBindings) canonicalMainKey(key string, current screen) string { return "enter" } } + if current == dashboardScreen { + switch { + case keyMatches(key, k.Views.AutoMerge): + return "a" + case keyMatches(key, k.Views.MergeNow): + return "M" + } + } if current == threadScreen { switch { case keyMatches(key, k.Navigation.Left): @@ -387,6 +398,7 @@ func validateKeyBindings(bindings KeyBindings) error { {"keybindings.views", map[string][]string{ "open": bindings.Views.Open, "dashboard": bindings.Views.Dashboard, "health": bindings.Views.Health, "edit": bindings.Views.Edit, + "auto_merge": bindings.Views.AutoMerge, "merge_now": bindings.Views.MergeNow, "toggle_list": bindings.Views.ToggleList, }}, {"keybindings.threads", map[string][]string{ @@ -478,6 +490,8 @@ func validateKeyBindingContexts(bindings KeyBindings) error { if err := validateKeyContext("dashboard", append(screenCommon, contextBinding{"open", views.Open}, contextBinding{"edit", views.Edit}, + contextBinding{"auto_merge", views.AutoMerge}, + contextBinding{"merge_now", views.MergeNow}, )...); err != nil { return err } diff --git a/main.go b/main.go index 2d6667f..4bba5c4 100644 --- a/main.go +++ b/main.go @@ -173,6 +173,8 @@ func exitf(format string, args ...any) { // Keep interface drift visible at compile time. var _ GitHubService = (*GitHubClient)(nil) +var _ GitHubMergeService = (*GitHubClient)(nil) +var _ GitHubMergeService = (*CachedGitHubService)(nil) var _ GitHubWriteService = (*GitHubClient)(nil) var _ GitHubWriteService = (*CachedGitHubService)(nil) var _ GitHubPullRequestWriteService = (*GitHubClient)(nil) diff --git a/tui.go b/tui.go index 5b27966..e2258cb 100644 --- a/tui.go +++ b/tui.go @@ -46,6 +46,10 @@ const ( writePREdit writePREditConfirm writePREditBusy + writeAutoMergeConfirm + writeAutoMergeBusy + writeMergeNowConfirm + writeMergeNowBusy ) type threadResolvedMsg struct { @@ -65,6 +69,17 @@ type pullRequestUpdatedMsg struct { err error } +type autoMergeUpdatedMsg struct { + request *AutoMergeRequest + enabled bool + err error +} + +type pullRequestMergedMsg struct { + result PullRequestMergeResult + err error +} + type prsLoadedMsg struct { prs []PullRequest err error @@ -128,6 +143,8 @@ type App struct { writeThreadID string replyDraft string resolveTarget bool + autoMergeTarget bool + mergeMethod string prEditField int prEditEditors [3]textEditor prEditOriginal PullRequestMetadata @@ -361,6 +378,110 @@ func (m *App) startResolveToggle() { writeResolveConfirm, thread.ID, !thread.IsResolved, nil } +func (m *App) startAutoMergeToggle() { + target := m.details.AutoMerge == nil + if reason := m.mergeActionUnavailable(target); reason != "" { + m.err = errors.New(reason) + return + } + m.autoMergeTarget = target + m.mergeMethod = preferredMergeMethod(m.details) + m.writeMode, m.err = writeAutoMergeConfirm, nil +} + +func (m *App) startMergeNow() { + if reason := m.mergeNowUnavailable(); reason != "" { + m.err = errors.New(reason) + return + } + m.mergeMethod = preferredMergeMethod(m.details) + m.writeMode, m.err = writeMergeNowConfirm, nil +} + +func preferredMergeMethod(pr PRDetails) string { + for _, preferred := range []string{"SQUASH", "MERGE", "REBASE"} { + for _, allowed := range pr.AllowedMergeMethods { + if allowed == preferred { + return preferred + } + } + } + return "MERGE" +} + +func (m App) mergeActionUnavailable(enabling bool) string { + if m.loading { + return "auto-merge unavailable while PR data is refreshing" + } + if m.details.FromCache { + return "auto-merge unavailable from an offline cached snapshot" + } + if _, ok := m.service.(GitHubMergeService); !ok { + return "configured GitHub service does not support auto-merge" + } + if m.details.Merged || m.details.State == "CLOSED" { + return "pull request is already closed" + } + if enabling { + if m.details.HeadOID == "" { + return "current pull request head commit is unavailable" + } + if !m.details.Permissions.CanEnableMerge { + return "GitHub did not grant permission to enable auto-merge" + } + if len(m.details.AllowedMergeMethods) == 0 { + return "repository does not expose an allowed merge method" + } + } else if !m.details.Permissions.CanDisableMerge { + return "GitHub did not grant permission to disable auto-merge" + } + return "" +} + +func (m App) mergeNowUnavailable() string { + if reason := m.mergeActionUnavailable(true); reason != "" && + !strings.Contains(reason, "enable auto-merge") { + return strings.ReplaceAll(reason, "auto-merge", "merge") + } + return mergeNowStateReason(m.details) +} + +func mergeNowStateReason(pr PRDetails) string { + if pr.Merged { + return "pull request is already merged" + } + if pr.State == "CLOSED" { + return "pull request is closed" + } + if pr.IsDraft { + return "draft pull requests cannot be merged" + } + if pr.Requirements.RequiresMergeQueue { + return "this branch requires the merge queue" + } + if pr.Mergeable != "MERGEABLE" { + if pr.Mergeable == "CONFLICTING" { + return "pull request has merge conflicts" + } + return "GitHub has not determined that the pull request is mergeable" + } + if pr.Requirements.RequiresApprovals && pr.ReviewDecision != "APPROVED" { + return "required approving reviews are not complete" + } + if pr.Requirements.RequiresStatusChecks && + pr.CheckState != "SUCCESS" && pr.CheckState != "EXPECTED" { + return "required status checks are not successful" + } + open, outdated, _ := threadStatusCounts(pr.Threads) + if pr.Requirements.RequiresConversation && open+outdated > 0 { + return "required review conversations are unresolved" + } + if len(pr.AllowedMergeMethods) == 0 { + return "repository does not expose an allowed merge method" + } + return "" +} + func (m App) writeActionUnavailable(action string, thread *ReviewThread) string { if m.loading { return "write action unavailable while PR data is refreshing" @@ -443,6 +564,22 @@ func (m App) updateWriteInput(key tea.KeyMsg) (tea.Model, tea.Cmd) { case "n", "esc": m.writeMode, m.writeThreadID = writeNone, "" } + case writeAutoMergeConfirm: + switch k { + case "y": + m.writeMode = writeAutoMergeBusy + return m, m.submitAutoMerge() + case "n", "esc": + m.writeMode = writeNone + } + case writeMergeNowConfirm: + switch k { + case "y": + m.writeMode = writeMergeNowBusy + return m, m.submitMergeNow() + case "n", "esc": + m.writeMode = writeNone + } case writePREdit, writePREditConfirm: return m.updatePREditInput(key) } @@ -471,6 +608,28 @@ func (m App) submitResolution() tea.Cmd { } } +func (m App) submitAutoMerge() tea.Cmd { + writer := m.service.(GitHubMergeService) + id, head, method, enabled := m.details.ID, m.details.HeadOID, m.mergeMethod, m.autoMergeTarget + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + request, err := writer.SetPullRequestAutoMerge(ctx, id, head, method, enabled) + return autoMergeUpdatedMsg{request: request, enabled: enabled, err: err} + } +} + +func (m App) submitMergeNow() tea.Cmd { + writer := m.service.(GitHubMergeService) + id, head, method := m.details.ID, m.details.HeadOID, m.mergeMethod + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + result, err := writer.MergePullRequest(ctx, id, head, method) + return pullRequestMergedMsg{result: result, err: err} + } +} + func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -713,6 +872,37 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.replyDraft, m.writeThreadID = "", "" m.err = nil m.lastRefresh = time.Now() + case autoMergeUpdatedMsg: + m.writeMode = writeNone + if msg.err != nil { + m.err = fmt.Errorf("change auto-merge: %w", msg.err) + m.recordHealth("auto-merge", healthError, msg.err.Error()) + return m, nil + } + m.err = nil + if msg.enabled { + m.details.AutoMerge = msg.request + m.details.Permissions.CanEnableMerge = false + m.details.Permissions.CanDisableMerge = true + } else { + m.details.AutoMerge = nil + m.details.Permissions.CanEnableMerge = true + m.details.Permissions.CanDisableMerge = false + } + m.lastRefresh = time.Now() + case pullRequestMergedMsg: + m.writeMode = writeNone + if msg.err != nil { + m.err = fmt.Errorf("merge pull request: %w", msg.err) + m.recordHealth("merge pull request", healthError, msg.err.Error()) + return m, nil + } + m.err = nil + m.details.Merged = msg.result.Merged + m.details.MergedAt = msg.result.MergedAt + m.details.State = "MERGED" + m.details.AutoMerge = nil + m.lastRefresh = time.Now() case pullRequestUpdatedMsg: if msg.err != nil { m.writeMode = writePREdit @@ -863,6 +1053,14 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.screen == dashboardScreen { return m, m.startPREdit() } + case "a": + if m.screen == dashboardScreen { + m.startAutoMergeToggle() + } + case "M": + if m.screen == dashboardScreen { + m.startMergeNow() + } case "F": if m.screen == threadScreen { m.searchQuery = "" @@ -1574,6 +1772,46 @@ func (m App) viewWritePopup() string { action = "Unresolving" } lines = []string{titleStyle.Render(action + " thread…"), "", dimStyle.Render(location)} + case writeAutoMergeConfirm: + action := "Enable" + detail := " using " + strings.ToLower(m.mergeMethod) + if !m.autoMergeTarget { + action, detail = "Disable", "" + } + lines = []string{ + titleStyle.Render(fmt.Sprintf("%s auto-merge for %s #%d%s?", + action, m.details.RepoWithOwner, m.details.Number, detail)), + "", + warnStyle.Render(fmt.Sprintf( + "%s confirm • %s cancel", + primaryKeyLabel(m.keybindings.General.Confirm), + primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), + )), + } + case writeAutoMergeBusy: + action := "Enabling" + if !m.autoMergeTarget { + action = "Disabling" + } + lines = []string{titleStyle.Render(action + " auto-merge…")} + case writeMergeNowConfirm: + lines = []string{ + titleStyle.Render(fmt.Sprintf( + "Merge %s #%d into %s now using %s?", + m.details.RepoWithOwner, m.details.Number, m.details.BaseRef, + strings.ToLower(m.mergeMethod), + )), + "", + badStyle.Render("This action cannot be undone from gh-threads."), + "", + warnStyle.Render(fmt.Sprintf( + "%s merge now • %s cancel", + primaryKeyLabel(m.keybindings.General.Confirm), + primaryCombinedKeyLabel(m.keybindings.General.Reject, m.keybindings.Input.Cancel), + )), + } + case writeMergeNowBusy: + lines = []string{titleStyle.Render("Merging pull request…")} case writePREditConfirm: lines = m.prEditConfirmationLines(width - 2) case writePREditBusy: @@ -1719,6 +1957,8 @@ func (m App) helpBindings() []helpBinding { {combinedKeyLabel(m.keybindings.Navigation.First, m.keybindings.Navigation.Last), "Top / bottom"}, {combinedKeyLabel(m.keybindings.Navigation.PageDown, m.keybindings.Navigation.PageUp), "Page down / up"}, {keyLabel(m.keybindings.Views.Edit), "Edit title, target branch, and description"}, + {keyLabel(m.keybindings.Views.AutoMerge), "Enable or disable auto-merge"}, + {keyLabel(m.keybindings.Views.MergeNow), "Merge the pull request now when all requirements are met"}, {keyLabel(m.keybindings.Views.Open), "Open review threads"}, {keyLabel(m.keybindings.General.Back), backAction}, {keyLabel(m.keybindings.General.Refresh), "Refresh now"}, @@ -2244,6 +2484,7 @@ func (m App) dashboardLines() []string { dashboardMetadata("review", reviewAndMergeState(pr)), dashboardMetadata("checks", coloredState(pr.CheckState)), dashboardMetadata("merge state", firstNonEmpty(strings.ToLower(pr.MergeState), "unknown")), + dashboardMetadata("auto-merge", autoMergeStateText(pr)), ) lines = append(lines, dashboardMetadataLines("conflicts", conflictStateText(pr), width)...) lines = append(lines, @@ -2540,7 +2781,7 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability { return []writeCapability{ {name: "reply", reason: reason}, {name: "resolve", reason: reason}, {name: "react", reason: reason}, {name: "update pull request", reason: reason}, - {name: "auto-merge", reason: reason}, + {name: "auto-merge", reason: reason}, {name: "merge now", reason: reason}, } } threadReason := "select a review thread" @@ -2556,12 +2797,20 @@ func writeCapabilities(pr PRDetails, thread *ReviewThread) []writeCapability { } threadReason = "GitHub did not grant permission for this thread" } + autoMergeAllowed := !pr.Merged && pr.State != "CLOSED" && + (pr.Permissions.CanEnableMerge || pr.Permissions.CanDisableMerge) + autoMergeReason := "auto-merge is unavailable for this PR" + if pr.Merged || pr.State == "CLOSED" { + autoMergeReason = "pull request is already closed" + } return []writeCapability{ capability("reply", canReply, threadReason, true), capability(resolveName, canResolve, threadReason, true), capability("react", pr.Permissions.CanReact, "GitHub did not grant reaction permission", false), capability("update pull request", pr.Permissions.CanUpdatePR, "GitHub did not grant update permission", true), - capability("auto-merge", pr.Permissions.CanEnableMerge, "auto-merge is unavailable for this PR", false), + capability("auto-merge", autoMergeAllowed, autoMergeReason, true), + capability("merge now", mergeNowStateReason(pr) == "", + firstNonEmpty(mergeNowStateReason(pr), "available"), true), } } @@ -2610,7 +2859,10 @@ func viewerPermissionsText(permissions ViewerPermissions) string { items = append(items, "reply") } if permissions.CanEnableMerge { - items = append(items, "auto-merge") + items = append(items, "enable auto-merge") + } + if permissions.CanDisableMerge { + items = append(items, "disable auto-merge") } return strings.Join(items, ", ") } @@ -3245,6 +3497,9 @@ func renderPane(lines []string, width, height int, active bool) string { } func reviewAndMergeState(pr PRDetails) string { + if pr.Merged { + return okStyle.Render("merged") + } switch pr.ReviewDecision { case "APPROVED": review := okStyle.Render("review: approved") @@ -3292,6 +3547,20 @@ func conflictStateText(pr PRDetails) string { } } +func autoMergeStateText(pr PRDetails) string { + if pr.Merged { + return okStyle.Render("merged") + } + if pr.AutoMerge == nil { + return dimStyle.Render("disabled") + } + text := okStyle.Render("enabled") + " " + strings.ToLower(pr.AutoMerge.MergeMethod) + if pr.AutoMerge.EnabledBy != "" { + text += " by " + authorStyle(pr.AutoMerge.EnabledBy).Render("@"+pr.AutoMerge.EnabledBy) + } + return text +} + func firstLine(value string) string { if line, _, found := strings.Cut(value, "\n"); found { return line diff --git a/tui_test.go b/tui_test.go index a821960..ce9cfbb 100644 --- a/tui_test.go +++ b/tui_test.go @@ -33,6 +33,29 @@ type recordingPRService struct { branchErr error } +type recordingMergeService struct { + recordingService + id, head, method string + enabled bool +} + +func (s *recordingMergeService) SetPullRequestAutoMerge( + _ context.Context, id, head, method string, enabled bool, +) (*AutoMergeRequest, error) { + s.id, s.head, s.method, s.enabled = id, head, method, enabled + if !enabled { + return nil, nil + } + return &AutoMergeRequest{MergeMethod: method, EnabledBy: "me", EnabledAt: time.Now()}, nil +} + +func (s *recordingMergeService) MergePullRequest( + _ context.Context, id, head, method string, +) (PullRequestMergeResult, error) { + s.id, s.head, s.method = id, head, method + return PullRequestMergeResult{Merged: true, MergedAt: time.Now()}, nil +} + func (s *recordingPRService) UpdatePullRequest( _ context.Context, id string, @@ -552,6 +575,70 @@ func TestWriteCapabilityGateExplainsCachedAndPermissionStates(t *testing.T) { } } +func TestDashboardCanToggleAutoMergeAndMergeReadyPR(t *testing.T) { + service := &recordingMergeService{} + m := NewApp(service, "o", "r", false, 50, time.Minute) + m.screen, m.loading, m.width, m.height = dashboardScreen, false, 100, 30 + m.details = PRDetails{ + PullRequest: PullRequest{ + ID: "pr", Owner: "o", Repository: "r", RepoWithOwner: "o/r", + Number: 1, Title: "Ready", + }, + HeadOID: "head", BaseRef: "main", Mergeable: "MERGEABLE", + ReviewDecision: "APPROVED", CheckState: "SUCCESS", + AllowedMergeMethods: []string{"SQUASH", "MERGE"}, + Permissions: ViewerPermissions{CanEnableMerge: true}, + } + + updated, _ := m.Update(runeKey("a")) + m = updated.(App) + if m.writeMode != writeAutoMergeConfirm || m.mergeMethod != "SQUASH" { + t.Fatalf("auto-merge confirmation = mode %d method %q", m.writeMode, m.mergeMethod) + } + updated, command := m.Update(runeKey("y")) + m = updated.(App) + if command == nil || m.writeMode != writeAutoMergeBusy { + t.Fatalf("auto-merge submit = mode %d command %v", m.writeMode, command) + } + updated, _ = m.Update(command()) + m = updated.(App) + if m.details.AutoMerge == nil || !service.enabled || + service.id != "pr" || service.head != "head" || service.method != "SQUASH" { + t.Fatalf("auto-merge result = details %#v service %#v", m.details.AutoMerge, service) + } + + updated, _ = m.Update(runeKey("M")) + m = updated.(App) + if m.writeMode != writeMergeNowConfirm { + t.Fatalf("merge confirmation mode = %d", m.writeMode) + } + updated, command = m.Update(runeKey("y")) + m = updated.(App) + updated, _ = m.Update(command()) + m = updated.(App) + if !m.details.Merged || m.details.State != "MERGED" { + t.Fatalf("merged details = %#v", m.details) + } +} + +func TestMergeNowIsGatedByCurrentRequirements(t *testing.T) { + service := &recordingMergeService{} + m := NewApp(service, "o", "r", false, 50, time.Minute) + m.screen, m.loading = dashboardScreen, false + m.details = PRDetails{ + PullRequest: PullRequest{ID: "pr"}, + HeadOID: "head", Mergeable: "MERGEABLE", CheckState: "FAILURE", + ReviewDecision: "APPROVED", AllowedMergeMethods: []string{"SQUASH"}, + Requirements: MergeRequirements{RequiresStatusChecks: true}, + } + updated, command := m.Update(runeKey("M")) + m = updated.(App) + if command != nil || m.writeMode != writeNone || m.err == nil || + !strings.Contains(m.err.Error(), "status checks") { + t.Fatalf("failed-check merge gate = mode %d command %v error %v", m.writeMode, command, m.err) + } +} + func TestDashboardEditorUpdatesTitleBodyAndBaseBranch(t *testing.T) { service := &recordingPRService{} m := NewApp(service, "o", "r", false, 50, time.Second) diff --git a/types.go b/types.go index 942b94d..9aa1f9b 100644 --- a/types.go +++ b/types.go @@ -21,41 +21,46 @@ type PullRequest struct { type PRDetails struct { PullRequest - Body string - CreatedAt time.Time - BaseRef string - HeadRef string - Mergeable string - MergeState string - ConflictFiles []string - ConflictFileError string - Assignees []string - Reviewers []Reviewer - Labels []string - Milestone string - Additions int - Deletions int - ChangedFiles int - CommitCount int - CommentCount int - HeadOID string - BaseOID string - RepositoryURL string - CheckState string - Checks []Check - ReviewDecision string - Conversation []PRComment - Reviews []ReviewSummary - Permissions ViewerPermissions - Requirements MergeRequirements - Threads []ReviewThread - ThreadsTruncated bool - Timeline []TimelineEvent - DataIssues []DataIssue - Rulesets []Ruleset - MergeQueue *MergeQueue - FromCache bool - CachedAt time.Time + Body string + CreatedAt time.Time + BaseRef string + HeadRef string + Mergeable string + MergeState string + State string + Merged bool + MergedAt time.Time + AutoMerge *AutoMergeRequest + AllowedMergeMethods []string + ConflictFiles []string + ConflictFileError string + Assignees []string + Reviewers []Reviewer + Labels []string + Milestone string + Additions int + Deletions int + ChangedFiles int + CommitCount int + CommentCount int + HeadOID string + BaseOID string + RepositoryURL string + CheckState string + Checks []Check + ReviewDecision string + Conversation []PRComment + Reviews []ReviewSummary + Permissions ViewerPermissions + Requirements MergeRequirements + Threads []ReviewThread + ThreadsTruncated bool + Timeline []TimelineEvent + DataIssues []DataIssue + Rulesets []Ruleset + MergeQueue *MergeQueue + FromCache bool + CachedAt time.Time } type DataIssue struct { @@ -82,6 +87,17 @@ type PullRequestMetadata struct { UpdatedAt time.Time } +type AutoMergeRequest struct { + MergeMethod string + EnabledBy string + EnabledAt time.Time +} + +type PullRequestMergeResult struct { + Merged bool + MergedAt time.Time +} + type RepositoryBranch struct { Name string UpdatedAt time.Time @@ -158,6 +174,7 @@ type ViewerPermissions struct { CanReact bool CanSubscribe bool CanEnableMerge bool + CanDisableMerge bool } type MergeRequirements struct {