Add auto-merge setting and merge now functionality
This commit is contained in:
275
tui.go
275
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
|
||||
|
||||
Reference in New Issue
Block a user