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 }