Add auto-merge setting and merge now functionality

This commit is contained in:
2026-07-28 17:42:49 +02:00
parent 7511311297
commit 42dcceaf4b
10 changed files with 689 additions and 63 deletions

275
tui.go
View File

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