github.com/zaquestion/lab@v0.25.1/cmd/mr_create.go (about)

     1  package cmd
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"os"
     7  	"regexp"
     8  	"runtime"
     9  	"strconv"
    10  	"strings"
    11  	"text/template"
    12  
    13  	"github.com/MakeNowJust/heredoc/v2"
    14  	"github.com/rsteube/carapace"
    15  	"github.com/spf13/cobra"
    16  	gitlab "github.com/xanzy/go-gitlab"
    17  	"github.com/zaquestion/lab/internal/action"
    18  	"github.com/zaquestion/lab/internal/git"
    19  	lab "github.com/zaquestion/lab/internal/gitlab"
    20  )
    21  
    22  // mrCmd represents the mr command
    23  var mrCreateCmd = &cobra.Command{
    24  	Use:     "create [target_remote [target_branch]]",
    25  	Aliases: []string{"new"},
    26  	Short:   "Creates a merge request.",
    27  	Args:    cobra.MaximumNArgs(2),
    28  	Example: heredoc.Doc(`
    29  		lab mr create target_remote
    30  		lab mr create target_remote target_branch --allow-collaboration
    31  		lab mr create upstream main --source my_fork:feature-3
    32  		lab mr create a_remote -a johndoe -a janedoe
    33  		lab mr create my_remote -c
    34  		lab mr create my_remote --draft
    35  		lab mr create my_remote -F a_file.txt
    36  		lab mr create my_remote -F a_file.txt --force-linebreak
    37  		lab mr create my_remote -f a_file.txt
    38  		lab mr create my_remote -l bug -l confirmed
    39  		lab mr create my_remote -m "A title message"
    40  		lab mr create my_remote -m "A MR title" -m "A MR description"
    41  		lab mr create my_remote --milestone "Fall"
    42  		lab mr create my_remote -d
    43  		lab mr create my_remote -r johndoe -r janedoe
    44  		lab mr create my_remote -s`),
    45  	PersistentPreRun: labPersistentPreRun,
    46  	Run:              runMRCreate,
    47  }
    48  
    49  func init() {
    50  	mrCreateCmd.Flags().StringArrayP("message", "m", []string{}, "use the given <msg>; multiple -m are concatenated as separate paragraphs")
    51  	mrCreateCmd.Flags().StringSliceP("assignee", "a", []string{}, "set assignee by username; can be specified multiple times for multiple assignees")
    52  	mrCreateCmd.Flags().StringSliceP("reviewer", "r", []string{}, "set reviewer by username; can be specified multiple times for multiple reviewers")
    53  	mrCreateCmd.Flags().StringSliceP("label", "l", []string{}, "add label <label>; can be specified multiple times for multiple labels")
    54  	mrCreateCmd.Flags().BoolP("remove-source-branch", "d", false, "remove source branch from remote after merge")
    55  	mrCreateCmd.Flags().BoolP("squash", "s", false, "squash commits when merging")
    56  	mrCreateCmd.Flags().Bool("allow-collaboration", false, "allow commits from other members")
    57  	mrCreateCmd.Flags().String("milestone", "", "set milestone by milestone title or ID")
    58  	mrCreateCmd.Flags().StringP("file", "F", "", "use the given file as the Title and Description")
    59  	mrCreateCmd.Flags().StringP("file-edit", "f", "", "use the given file as the Title and Description and open the editor")
    60  	mrCreateCmd.Flags().Bool("no-edit", false, "use the selected commit message without opening the editor")
    61  	mrCreateCmd.Flags().Bool("force-linebreak", false, "append 2 spaces to the end of each line to force markdown linebreaks")
    62  	mrCreateCmd.Flags().BoolP("cover-letter", "c", false, "comment changelog and diffstat")
    63  	mrCreateCmd.Flags().Bool("draft", false, "mark the merge request as draft")
    64  	mrCreateCmd.Flags().String("source", "", "specify the source remote and branch in the form of remote:branch")
    65  	mergeRequestCmd.Flags().AddFlagSet(mrCreateCmd.Flags())
    66  
    67  	mrCmd.AddCommand(mrCreateCmd)
    68  
    69  	carapace.Gen(mrCreateCmd).FlagCompletion(carapace.ActionMap{
    70  		"label": carapace.ActionMultiParts(",", func(c carapace.Context) carapace.Action {
    71  			project, _, err := parseArgsRemoteAndProject(c.Args)
    72  			if err != nil {
    73  				return carapace.ActionMessage(err.Error())
    74  			}
    75  			return action.Labels(project).Invoke(c).Filter(c.Parts).ToA()
    76  		}),
    77  		"milestone": carapace.ActionCallback(func(c carapace.Context) carapace.Action {
    78  			project, _, err := parseArgsRemoteAndProject(c.Args)
    79  			if err != nil {
    80  				return carapace.ActionMessage(err.Error())
    81  			}
    82  			return action.Milestones(project, action.MilestoneOpts{Active: true})
    83  		}),
    84  	})
    85  
    86  	carapace.Gen(mrCreateCmd).PositionalCompletion(
    87  		action.Remotes(),
    88  		action.RemoteBranches(0),
    89  	)
    90  }
    91  
    92  func verifyRemoteBranch(projID string, branch string) error {
    93  	if _, err := lab.GetCommit(projID, branch); err != nil {
    94  		return fmt.Errorf("%s is not a valid reference", branch)
    95  	}
    96  	return nil
    97  }
    98  
    99  func runMRCreate(cmd *cobra.Command, args []string) {
   100  	msgs, err := cmd.Flags().GetStringArray("message")
   101  	if err != nil {
   102  		log.Fatal(err)
   103  	}
   104  	assignees, err := cmd.Flags().GetStringSlice("assignee")
   105  	if err != nil {
   106  		log.Fatal(err)
   107  	}
   108  	reviewers, err := cmd.Flags().GetStringSlice("reviewer")
   109  	if err != nil {
   110  		log.Fatal(err)
   111  	}
   112  
   113  	filename, err := cmd.Flags().GetString("file")
   114  	if err != nil {
   115  		log.Fatal(err)
   116  	}
   117  
   118  	ofilename, err := cmd.Flags().GetString("file-edit")
   119  	if err != nil {
   120  		log.Fatal(err)
   121  	}
   122  
   123  	if ofilename != "" && filename != "" {
   124  		log.Fatalf("Cannot specify both -F and -f options.")
   125  	}
   126  
   127  	noEdit, err := cmd.Flags().GetBool("no-edit")
   128  	if err != nil {
   129  		log.Fatal(err)
   130  	}
   131  
   132  	coverLetterFormat, err := cmd.Flags().GetBool("cover-letter")
   133  	if err != nil {
   134  		log.Fatal(err)
   135  	}
   136  
   137  	localBranch, err := git.CurrentBranch()
   138  	if err != nil {
   139  		log.Fatal(err)
   140  	}
   141  
   142  	sourceRemote, err := determineSourceRemote(localBranch)
   143  	if err != nil {
   144  		log.Fatal(err)
   145  	}
   146  
   147  	// Get the pushed branch name
   148  	sourceBranch, _ := git.UpstreamBranch(localBranch)
   149  	if sourceBranch == "" {
   150  		// Fall back to local branch name
   151  		sourceBranch = localBranch
   152  	}
   153  
   154  	sourceTarget, err := cmd.Flags().GetString("source")
   155  	if err != nil {
   156  		log.Fatal(err)
   157  	}
   158  
   159  	if sourceTarget != "" {
   160  		sourceParts := strings.Split(sourceTarget, ":")
   161  		if len(sourceParts) < 2 ||
   162  			strings.TrimSpace(sourceParts[0]) == "" ||
   163  			strings.TrimSpace(sourceParts[1]) == "" {
   164  			log.Fatalf("source remote must have format remote:remote_branch")
   165  		}
   166  
   167  		sourceRemote = sourceParts[0]
   168  		sourceBranch = sourceParts[1]
   169  
   170  		_, err := git.IsRemote(sourceRemote)
   171  		if err != nil {
   172  			log.Fatal(err)
   173  		}
   174  	}
   175  
   176  	sourceProjectName, err := git.PathWithNamespace(sourceRemote)
   177  	if err != nil {
   178  		log.Fatal(err)
   179  	}
   180  
   181  	// verify the source branch in remote project
   182  	err = verifyRemoteBranch(sourceProjectName, sourceBranch)
   183  	if err != nil {
   184  		log.Fatalf("%s:%s\n", sourceRemote, err)
   185  	}
   186  
   187  	targetRemote := defaultRemote
   188  	if len(args) > 0 {
   189  		targetRemote = args[0]
   190  		ok, err := git.IsRemote(targetRemote)
   191  		if err != nil || !ok {
   192  			log.Fatalf("%s is not a valid remote\n", targetRemote)
   193  		}
   194  	}
   195  	targetProjectName, err := git.PathWithNamespace(targetRemote)
   196  	if err != nil {
   197  		log.Fatal(err)
   198  	}
   199  	targetProject, err := lab.FindProject(targetProjectName)
   200  	if err != nil {
   201  		if err == lab.ErrProjectNotFound {
   202  			log.Fatalf("GitLab project (%s) not found, verify you have access to the requested resource", targetProjectName)
   203  		}
   204  		log.Fatal(err)
   205  	}
   206  
   207  	targetBranch := targetProject.DefaultBranch
   208  	if len(args) > 1 && targetBranch != args[1] {
   209  		targetBranch = args[1]
   210  		// verify the target branch in remote project
   211  		err = verifyRemoteBranch(targetProjectName, targetBranch)
   212  		if err != nil {
   213  			log.Fatalf("%s:%s\n", targetRemote, err)
   214  		}
   215  	}
   216  
   217  	labelTerms, err := cmd.Flags().GetStringSlice("label")
   218  	if err != nil {
   219  		log.Fatal(err)
   220  	}
   221  	labels, err := mapLabels(targetProjectName, labelTerms)
   222  	if err != nil {
   223  		log.Fatal(err)
   224  	}
   225  
   226  	milestoneArg, _ := cmd.Flags().GetString("milestone")
   227  	milestoneID, _ := strconv.Atoi(milestoneArg)
   228  
   229  	var milestone *int
   230  	if milestoneID > 0 {
   231  		milestone = &milestoneID
   232  	} else if milestoneArg != "" {
   233  		ms, err := lab.MilestoneGet(targetProjectName, milestoneArg)
   234  		if err != nil {
   235  			log.Fatal(err)
   236  		}
   237  		milestone = &ms.ID
   238  	} else {
   239  		milestone = nil
   240  	}
   241  
   242  	var title, body string
   243  
   244  	if filename != "" {
   245  		var openEditor bool
   246  
   247  		if ofilename != "" {
   248  			filename = ofilename
   249  			openEditor = true
   250  		}
   251  
   252  		if _, err := os.Stat(filename); os.IsNotExist(err) {
   253  			log.Fatalf("file %s cannot be found", filename)
   254  		}
   255  
   256  		if len(msgs) > 0 || coverLetterFormat {
   257  			log.Fatal("option -F cannot be combined with -m/-c")
   258  		}
   259  
   260  		title, body, err = editDescription("", "", nil, filename)
   261  		if err != nil {
   262  			log.Fatal(err)
   263  		}
   264  
   265  		if openEditor {
   266  			msg, err := mrText(sourceRemote, sourceBranch, targetRemote,
   267  				targetBranch, coverLetterFormat, false)
   268  			if err != nil {
   269  				log.Fatal(err)
   270  			}
   271  
   272  			msg = title + body + msg
   273  
   274  			title, body, err = git.Edit("MERGEREQ", msg)
   275  			if err != nil {
   276  				log.Fatal(err)
   277  			}
   278  		}
   279  	} else if len(msgs) > 0 {
   280  		if coverLetterFormat {
   281  			log.Fatal("option -m cannot be combined with -c/-F")
   282  		}
   283  
   284  		title, body = msgs[0], strings.Join(msgs[1:], "\n\n")
   285  	} else {
   286  		msg, err := mrText(sourceRemote, sourceBranch, targetRemote,
   287  			targetBranch, coverLetterFormat, true)
   288  		if err != nil {
   289  			log.Fatal(err)
   290  		}
   291  
   292  		openEditor := !noEdit
   293  		if openEditor {
   294  			title, body, err = git.Edit("MERGEREQ", msg)
   295  			if err != nil {
   296  				_, f, l, _ := runtime.Caller(0)
   297  				log.Fatal(f+":"+strconv.Itoa(l)+" ", err)
   298  			}
   299  		} else {
   300  			title, body, err = git.ParseTitleBody(msg)
   301  			if title == "" {
   302  				// The only way to get here is if the auto-generated MR
   303  				// description has no title or body, which happens when the
   304  				// MR is made of multiple commits instead of a single one
   305  				// where the commit log is used as the MR description.
   306  				log.Fatal("use of --no-edit with multiple commits not allowed")
   307  			}
   308  		}
   309  	}
   310  
   311  	if title == "" {
   312  		log.Fatal("empty MR message")
   313  	}
   314  
   315  	linebreak, _ := cmd.Flags().GetBool("force-linebreak")
   316  	if linebreak {
   317  		body = textToMarkdown(body)
   318  	}
   319  
   320  	draft, _ := cmd.Flags().GetBool("draft")
   321  	if draft {
   322  		// GitLab 14.0 will remove WIP support in favor of Draft
   323  		isWIP := hasPrefix(title, "wip:") ||
   324  			hasPrefix(title, "[wip]")
   325  		if isWIP {
   326  			log.Fatal("the use of \"WIP\" terminology is deprecated, use \"Draft\" instead")
   327  		}
   328  
   329  		isDraft := hasPrefix(title, "draft:") ||
   330  			hasPrefix(title, "[draft]") ||
   331  			hasPrefix(title, "(draft)")
   332  		if !isDraft {
   333  			title = "Draft: " + title
   334  		}
   335  	}
   336  
   337  	removeSourceBranch, _ := cmd.Flags().GetBool("remove-source-branch")
   338  	squash, _ := cmd.Flags().GetBool("squash")
   339  	allowCollaboration, _ := cmd.Flags().GetBool("allow-collaboration")
   340  
   341  	mrURL, err := lab.MRCreate(sourceProjectName, &gitlab.CreateMergeRequestOptions{
   342  		SourceBranch:       &sourceBranch,
   343  		TargetBranch:       gitlab.String(targetBranch),
   344  		TargetProjectID:    &targetProject.ID,
   345  		Title:              &title,
   346  		Description:        &body,
   347  		AssigneeIDs:        getUserIDs(assignees),
   348  		ReviewerIDs:        getUserIDs(reviewers),
   349  		RemoveSourceBranch: &removeSourceBranch,
   350  		Squash:             &squash,
   351  		AllowCollaboration: &allowCollaboration,
   352  		Labels:             labels,
   353  		MilestoneID:        milestone,
   354  	})
   355  	if err != nil {
   356  		// FIXME: not exiting fatal here to allow code coverage to
   357  		// generate during Test_mrCreate. In the meantime API failures
   358  		// will exit 0
   359  		fmt.Fprintln(os.Stderr, err)
   360  	} else {
   361  		fmt.Println(mrURL + "/diffs")
   362  	}
   363  }
   364  
   365  func mrText(sourceRemote, sourceBranch, targetRemote, targetBranch string, coverLetterFormat bool, generateCommitMsg bool) (string, error) {
   366  	target := fmt.Sprintf("%s/%s", targetRemote, targetBranch)
   367  	source := fmt.Sprintf("%s/%s", sourceRemote, sourceBranch)
   368  	commitMsg := ""
   369  
   370  	numCommits := git.NumberCommits(target, source)
   371  	if numCommits == 1 && generateCommitMsg {
   372  		var err error
   373  		commitMsg, err = git.LastCommitMessage(source)
   374  		if err != nil {
   375  			return "", err
   376  		}
   377  	}
   378  	if numCommits == 0 {
   379  		return "", fmt.Errorf("the resulting MR from %s to %s has 0 commits", target, source)
   380  	}
   381  
   382  	tmpl := heredoc.Doc(`
   383  		{{if .InitMsg}}{{.InitMsg}}{{end}}
   384  
   385  		{{if .Tmpl}}{{.Tmpl}}{{end}}
   386  		{{.CommentChar}} Requesting a merge into {{.Target}} from {{.Source}} ({{.NumCommits}} commits)
   387  		{{.CommentChar}}
   388  		{{.CommentChar}} Write a message for this merge request. The first block
   389  		{{.CommentChar}} of text is the title and the rest is the description.{{if .CommitLogs}}
   390  		{{.CommentChar}}
   391  		{{.CommentChar}} Changes:
   392  		{{.CommentChar}}
   393  		{{.CommitLogs}}{{end}}
   394  	`)
   395  
   396  	mrTmpl := lab.LoadGitLabTmpl(lab.TmplMR)
   397  
   398  	commitLogs, err := git.Log(target, source)
   399  	if err != nil {
   400  		return "", err
   401  	}
   402  
   403  	commitLogs = strings.TrimSpace(commitLogs)
   404  	commentChar := git.CommentChar()
   405  
   406  	if !coverLetterFormat {
   407  		startRegexp := regexp.MustCompilePOSIX("^")
   408  		commitLogs = startRegexp.ReplaceAllString(commitLogs, fmt.Sprintf("%s ", commentChar))
   409  	} else {
   410  		commitLogs = "\n" + strings.TrimSpace(commitLogs)
   411  	}
   412  
   413  	t, err := template.New("tmpl").Parse(tmpl)
   414  	if err != nil {
   415  		return "", err
   416  	}
   417  
   418  	msg := &struct {
   419  		InitMsg     string
   420  		Tmpl        string
   421  		CommentChar string
   422  		Target      string
   423  		Source      string
   424  		CommitLogs  string
   425  		NumCommits  int
   426  	}{
   427  		InitMsg:     commitMsg,
   428  		Tmpl:        mrTmpl,
   429  		CommentChar: commentChar,
   430  		Target:      targetRemote + ":" + targetBranch,
   431  		Source:      sourceRemote + ":" + sourceBranch,
   432  		CommitLogs:  commitLogs,
   433  		NumCommits:  numCommits,
   434  	}
   435  
   436  	var b bytes.Buffer
   437  	err = t.Execute(&b, msg)
   438  	if err != nil {
   439  		return "", err
   440  	}
   441  
   442  	return b.String(), nil
   443  }