commands/mr/mrutils/mrutils.go (329 lines of code) (raw):
package mrutils
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"gitlab.com/gitlab-org/cli/pkg/dbg"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
gitlab "gitlab.com/gitlab-org/api/client-go"
"gitlab.com/gitlab-org/cli/api"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/internal/glrepo"
"gitlab.com/gitlab-org/cli/pkg/prompt"
"gitlab.com/gitlab-org/cli/pkg/tableprinter"
"golang.org/x/sync/errgroup"
)
type MRCheckErrOptions struct {
// Draft: check and return err if merge request is a DRAFT
Draft bool
// Closed: check and return err if merge request is closed
Closed bool
// Merged: check and return err if merge request is already merged
Merged bool
// Opened: check and return err if merge request is already opened
Opened bool
// Conflict: check and return err if there are merge conflicts
Conflict bool
// PipelineStatus: check and return err pipeline did not succeed and it is required before merging
PipelineStatus bool
// MergePermitted: check and return err if user is not authorized to merge
MergePermitted bool
// Subscribed: check and return err if user is already subscribed to MR
Subscribed bool
// Unsubscribed: check and return err if user is already unsubscribed to MR
Unsubscribed bool
// MergePrivilege: check and return err if user is not authorized to merge
MergePrivilege bool
}
type mrOptions struct {
baseRepo glrepo.Interface
branch string
state string
promptEnabled bool
}
// MRCheckErrors checks and return merge request errors specified in MRCheckErrOptions{}
func MRCheckErrors(mr *gitlab.MergeRequest, err MRCheckErrOptions) error {
if mr.Draft && err.Draft {
return fmt.Errorf("this merge request is still a draft. Run `glab mr update %d --ready` to mark it as ready for review.", mr.IID)
}
dbg.Debug("MergeWhenPipelineSucceeds:", strconv.FormatBool(mr.MergeWhenPipelineSucceeds))
dbg.Debug("DetailedMergeStatus:", mr.DetailedMergeStatus)
if mr.DetailedMergeStatus == "ci_must_pass" {
return fmt.Errorf("this merge request requires a passing pipeline before merging.")
}
if mr.MergeWhenPipelineSucceeds && err.PipelineStatus && mr.Pipeline != nil {
if mr.Pipeline.Status != "success" {
return fmt.Errorf("the pipeline for this merge request has failed. The pipeline must succeed before merging.")
}
}
if mr.State == "merged" && err.Merged {
return fmt.Errorf("this merge request has already been merged.")
}
if mr.State == "closed" && err.Closed {
return fmt.Errorf("this merge request has been closed.")
}
if mr.State == "opened" && err.Opened {
return fmt.Errorf("this merge request is already open.")
}
if mr.Subscribed && err.Subscribed {
return fmt.Errorf("you are already subscribed to this merge request.")
}
if !mr.Subscribed && err.Unsubscribed {
return fmt.Errorf("you are not subscribed to this merge request.")
}
if err.MergePrivilege && !mr.User.CanMerge {
return fmt.Errorf("you do not have permission to merge this merge request.")
}
if err.Conflict && mr.HasConflicts {
return fmt.Errorf("merge conflicts exist. Resolve the conflicts and try again, or merge locally.")
}
return nil
}
func DisplayMR(c *iostreams.ColorPalette, mr *gitlab.BasicMergeRequest, isTTY bool) string {
mrID := MRState(c, mr)
if isTTY {
return fmt.Sprintf("%s %s (%s)\n %s\n", mrID, mr.Title, mr.SourceBranch, mr.WebURL)
} else {
return mr.WebURL
}
}
func MRState(c *iostreams.ColorPalette, m *gitlab.BasicMergeRequest) string {
if m.State == "opened" {
return c.Green(fmt.Sprintf("!%d", m.IID))
} else if m.State == "merged" {
return c.Magenta(fmt.Sprintf("!%d", m.IID))
} else {
return c.Red(fmt.Sprintf("!%d", m.IID))
}
}
func DisplayAllMRs(streams *iostreams.IOStreams, mrs []*gitlab.BasicMergeRequest) string {
c := streams.Color()
table := tableprinter.NewTablePrinter()
table.SetIsTTY(streams.IsOutputTTY())
for _, m := range mrs {
table.AddCell(streams.Hyperlink(MRState(c, m), m.WebURL))
table.AddCell(m.References.Full)
table.AddCell(m.Title)
table.AddCell(c.Cyan(fmt.Sprintf("(%s) ← (%s)", m.TargetBranch, m.SourceBranch)))
table.EndRow()
}
return table.Render()
}
// MRFromArgs is wrapper around MRFromArgsWithOpts without any custom options
func MRFromArgs(f *cmdutils.Factory, args []string, state string) (*gitlab.MergeRequest, glrepo.Interface, error) {
return MRFromArgsWithOpts(f, args, &gitlab.GetMergeRequestsOptions{}, state)
}
// MRFromArgsWithOpts gets MR with custom request options passed down to it
func MRFromArgsWithOpts(
f *cmdutils.Factory,
args []string,
opts *gitlab.GetMergeRequestsOptions,
state string,
) (*gitlab.MergeRequest, glrepo.Interface, error) {
var mrID int
var mr *gitlab.MergeRequest
apiClient, err := f.HttpClient()
if err != nil {
return nil, nil, err
}
baseRepo, err := f.BaseRepo()
if err != nil {
return nil, nil, err
}
var branch string
if len(args) > 0 {
mrID, err = strconv.Atoi(strings.TrimPrefix(args[0], "!"))
if err != nil {
branch = args[0]
} else if mrID == 0 { // to check for cases where the user explicitly specified mrID to be zero
return nil, nil, fmt.Errorf("invalid merge request ID provided.")
}
}
if branch == "" && mrID == 0 {
branch, err = f.Branch()
if err != nil {
return nil, nil, err
}
}
if mrID == 0 {
basicMR, err := getMRForBranch(apiClient, mrOptions{baseRepo, branch, state, f.IO.PromptEnabled()})
if err != nil {
return nil, nil, err
}
mrID = basicMR.IID
}
mr, err = api.GetMR(apiClient, baseRepo.FullName(), mrID, opts)
if err != nil {
return nil, nil, fmt.Errorf("failed to get merge request %d: %w", mrID, err)
}
return mr, baseRepo, nil
}
func MRsFromArgs(f *cmdutils.Factory, args []string, state string) ([]*gitlab.MergeRequest, glrepo.Interface, error) {
if len(args) <= 1 {
var arrIDs []string
if len(args) == 1 {
arrIDs = strings.Split(args[0], ",")
}
if len(arrIDs) <= 1 {
// If there are no args then try to auto-detect from the branch name
mr, baseRepo, err := MRFromArgs(f, args, state)
if err != nil {
return nil, nil, err
}
return []*gitlab.MergeRequest{mr}, baseRepo, nil
}
args = arrIDs
}
baseRepo, err := f.BaseRepo()
if err != nil {
return nil, nil, err
}
errGroup, _ := errgroup.WithContext(context.Background())
mrs := make([]*gitlab.MergeRequest, len(args))
for i, arg := range args {
i, arg := i, arg
errGroup.Go(func() error {
// fetching multiple MRs does not return many major params in the payload
// so we fetch again using the single mr endpoint
mr, _, err := MRFromArgs(f, []string{arg}, state)
if err != nil {
return err
}
mrs[i] = mr
return nil
})
}
if err := errGroup.Wait(); err != nil {
return nil, nil, err
}
return mrs, baseRepo, nil
}
func resolveOwnerAndBranch(potentialBranch string) (string, string) {
split := strings.Split(potentialBranch, ":")
userUsedOwnerColonBranchFormat := len(split) != 1
if userUsedOwnerColonBranchFormat {
owner, branch := split[0], split[1]
return owner, branch
}
owner, branch := "", split[0]
return owner, branch
}
var getMRForBranch = func(apiClient *gitlab.Client, mrOpts mrOptions) (*gitlab.BasicMergeRequest, error) {
owner, currentBranch := resolveOwnerAndBranch(mrOpts.branch)
opts := gitlab.ListProjectMergeRequestsOptions{
SourceBranch: gitlab.Ptr(currentBranch),
}
userAskedForSpecificState := mrOpts.state != "" && mrOpts.state != "any"
if userAskedForSpecificState {
opts.State = gitlab.Ptr(mrOpts.state)
}
mrs, err := api.ListMRs(apiClient, mrOpts.baseRepo.FullName(), &opts)
if err != nil {
return nil, fmt.Errorf("failed to get open merge request for %q: %w", currentBranch, err)
}
if len(mrs) == 0 {
return nil, fmt.Errorf("no open merge request available for %q", currentBranch)
}
userAskedForSpecificOwner := owner != ""
if userAskedForSpecificOwner {
for i := range mrs {
mr := mrs[i]
matchFound := mr.Author.Username == owner
if matchFound {
return mr, nil
}
}
return nil, fmt.Errorf("no open merge request available for %q owned by @%s", currentBranch, owner)
}
// This is done after the 'OWNER:' check because we don't want to give the wrong MR
// to someone that **explicitly** asked for a OWNER.
if len(mrs) == 1 {
return mrs[0], nil
}
// No 'OWNER:' prompt the user to pick a merge request
mrMap := map[string]*gitlab.BasicMergeRequest{}
var mrNames []string
for i := range mrs {
t := fmt.Sprintf("!%d (%s) by @%s", mrs[i].IID, currentBranch, mrs[i].Author.Username)
mrMap[t] = mrs[i]
mrNames = append(mrNames, t)
}
pickedMR := mrNames[0]
if !mrOpts.promptEnabled {
// NO_PROMPT environment variable is set. Skip prompt and return error when multiple merge requests exist for branch.
err = fmt.Errorf("merge request ID number required. Possible matches:\n\n%s", strings.Join(mrNames, "\n"))
} else {
err = prompt.Select(&pickedMR,
"mr",
"Multiple merge requests exist for this branch. Select one:",
mrNames,
)
}
if err != nil {
return nil, fmt.Errorf("you must select a merge request: %w", err)
}
return mrMap[pickedMR], nil
}
func RebaseMR(ios *iostreams.IOStreams, apiClient *gitlab.Client, repo glrepo.Interface, mr *gitlab.MergeRequest, rebaseOpts *gitlab.RebaseMergeRequestOptions) error {
ios.StartSpinner("Sending rebase request...")
err := api.RebaseMR(apiClient, repo.FullName(), mr.IID, rebaseOpts)
if err != nil {
return err
}
ios.StopSpinner("")
opts := &gitlab.GetMergeRequestsOptions{}
opts.IncludeRebaseInProgress = gitlab.Ptr(true)
ios.StartSpinner("Checking rebase status...")
errorMSG := ""
i := 0
for {
mr, err := api.GetMR(apiClient, repo.FullName(), mr.IID, opts)
if err != nil {
errorMSG = err.Error()
break
}
if i == 0 {
ios.StopSpinner("")
ios.StartSpinner("Rebase in progress...")
}
if !mr.RebaseInProgress {
if mr.MergeError != "" && mr.MergeError != "null" {
errorMSG = mr.MergeError
}
break
}
i++
}
ios.StopSpinner("")
if errorMSG != "" {
return errors.New(errorMSG)
}
fmt.Fprintln(ios.StdOut, ios.Color().GreenCheck(), "Rebase successful!")
return nil
}
// PrintMRApprovalState renders an output to summarize the approval state of a merge request
func PrintMRApprovalState(ios *iostreams.IOStreams, mrApprovals *gitlab.MergeRequestApprovalState) {
const approvedIcon = "👍"
c := ios.Color()
if mrApprovals.ApprovalRulesOverwritten {
fmt.Fprintln(ios.StdOut, c.Yellow("Approval rules overwritten."))
}
for _, rule := range mrApprovals.Rules {
table := tableprinter.NewTablePrinter()
if rule.Approved {
fmt.Fprintln(ios.StdOut, c.Green(fmt.Sprintf("Rule %q sufficient approvals (%d/%d required):", rule.Name, len(rule.ApprovedBy), rule.ApprovalsRequired)))
} else {
fmt.Fprintln(ios.StdOut, c.Yellow(fmt.Sprintf("Rule %q insufficient approvals (%d/%d required):", rule.Name, len(rule.ApprovedBy), rule.ApprovalsRequired)))
}
eligibleApprovers := rule.EligibleApprovers
approvedBy := map[string]*gitlab.BasicUser{}
for _, by := range rule.ApprovedBy {
approvedBy[by.Username] = by
}
table.AddRow("Name", "Username", "Approved")
for _, eligibleApprover := range eligibleApprovers {
approved := "-"
source := ""
if _, exists := approvedBy[eligibleApprover.Username]; exists {
approved = approvedIcon
}
if rule.SourceRule != nil {
source = rule.SourceRule.RuleType
}
table.AddRow(eligibleApprover.Name, eligibleApprover.Username, approved, source)
delete(approvedBy, eligibleApprover.Username)
}
// sort all usernames to ensure consistent output
approverNames := make([]string, 0, len(approvedBy))
for name := range approvedBy {
approverNames = append(approverNames, name)
}
sort.Strings(approverNames)
for _, name := range approverNames {
approver := approvedBy[name]
table.AddRow(approver.Name, approver.Username, approvedIcon, "")
}
fmt.Fprintln(ios.StdOut, table)
}
}