github.com/zaquestion/lab@v0.25.1/cmd/mr_edit.go (about) 1 package cmd 2 3 import ( 4 "fmt" 5 "os" 6 "strings" 7 8 "github.com/MakeNowJust/heredoc/v2" 9 "github.com/pkg/errors" 10 "github.com/rsteube/carapace" 11 "github.com/spf13/cobra" 12 "github.com/spf13/pflag" 13 gitlab "github.com/xanzy/go-gitlab" 14 "github.com/zaquestion/lab/internal/action" 15 lab "github.com/zaquestion/lab/internal/gitlab" 16 ) 17 18 var mrEditCmd = &cobra.Command{ 19 Use: "edit [remote] <id>[:<comment_id>]", 20 Aliases: []string{"update"}, 21 Short: "Edit or update an MR", 22 Example: heredoc.Doc(` 23 lab mr edit 2 24 lab mr edit 2:5684032 25 lab mr edit 3 remote -m "new title" 26 lab mr edit 5 upstream -m "new title" -m "new desc" 27 lab mr edit 7 -l new_label --unlabel old_label 28 lab mr edit 11 upstream -a johndoe -a janedoe 29 lab mr edit 17 upstream --unassign johndoe 30 lab mr edit 13 upstream --milestone "summer" 31 lab mr edit 19 origin --target-brnch other_brnch 32 lab mr edit 23 upstream -F test_file 33 lab mr edit 29 upstream -F test_file --force-linebreak 34 lab mr edit 31 upstream --draft 35 lab mr edit 37 upstream --ready 36 lab mr edit 41 upstream -r johndoe -r janedoe 37 lab mr edit 43 upstream --unreview johndoe 38 lab mr edit --delete-note 2:5684032`), 39 PersistentPreRun: labPersistentPreRun, 40 Run: func(cmd *cobra.Command, args []string) { 41 commentNum, branchArgs, err := filterCommentArg(args) 42 if err != nil { 43 log.Fatal(err) 44 } 45 46 rn, id, err := parseArgsWithGitBranchMR(branchArgs) 47 if err != nil { 48 log.Fatal(err) 49 } 50 mrNum := int(id) 51 52 if mrNum == 0 { 53 fmt.Println("Error: Cannot determine MR id.") 54 os.Exit(1) 55 } 56 57 mr, err := lab.MRGet(rn, mrNum) 58 if err != nil { 59 log.Fatal(err) 60 } 61 62 deleteNote, err := cmd.Flags().GetBool("delete-note") 63 if err != nil { 64 log.Fatal(err) 65 } 66 if deleteNote { 67 discussions, err := lab.MRListDiscussions(rn, int(mrNum)) 68 if err != nil { 69 log.Fatal(err) 70 } 71 72 discussion := "" 73 findDiscussionID: 74 for _, d := range discussions { 75 for _, n := range d.Notes { 76 if n.ID == commentNum { 77 discussion = d.ID 78 break findDiscussionID 79 } 80 } 81 } 82 83 // delete the note 84 err = lab.MRDeleteNote(rn, mrNum, discussion, commentNum) 85 if err != nil { 86 log.Fatal(err) 87 } 88 return 89 } 90 91 linebreak, err := cmd.Flags().GetBool("force-linebreak") 92 if err != nil { 93 log.Fatal(err) 94 } 95 96 // Edit a comment on the MR 97 if commentNum != 0 { 98 replyNote(rn, true, mrNum, commentNum, true, true, "", linebreak, false, nil) 99 return 100 } 101 102 var labelsChanged bool 103 // get the labels to add 104 addLabelTerms, err := cmd.Flags().GetStringSlice("label") 105 if err != nil { 106 log.Fatal(err) 107 } 108 addLabels, err := mapLabels(rn, addLabelTerms) 109 if err != nil { 110 log.Fatal(err) 111 } 112 if len(addLabels) > 0 { 113 labelsChanged = true 114 } 115 116 // get the labels to remove 117 rmLabelTerms, err := cmd.Flags().GetStringSlice("unlabel") 118 if err != nil { 119 log.Fatal(err) 120 } 121 rmLabels, err := mapLabels(rn, rmLabelTerms) 122 if err != nil { 123 log.Fatal(err) 124 } 125 if len(rmLabels) > 0 { 126 labelsChanged = true 127 } 128 129 // get the assignees to add 130 assignees, err := cmd.Flags().GetStringSlice("assign") 131 if err != nil { 132 log.Fatal(err) 133 } 134 135 // get the assignees to remove 136 unassignees, err := cmd.Flags().GetStringSlice("unassign") 137 if err != nil { 138 log.Fatal(err) 139 } 140 141 // get the reviewers to add 142 reviewers, err := cmd.Flags().GetStringSlice("review") 143 if err != nil { 144 log.Fatal(err) 145 } 146 147 // get the reviewers to remove 148 unreviewers, err := cmd.Flags().GetStringSlice("unreview") 149 if err != nil { 150 log.Fatal(err) 151 } 152 153 filename, err := cmd.Flags().GetString("file") 154 if err != nil { 155 log.Fatal(err) 156 } 157 158 draft, err := cmd.Flags().GetBool("draft") 159 if err != nil { 160 log.Fatal(err) 161 } 162 163 ready, err := cmd.Flags().GetBool("ready") 164 if err != nil { 165 log.Fatal(err) 166 } 167 168 if draft && ready { 169 log.Fatal("--draft and --ready cannot be used together") 170 } 171 172 currentAssignees := mrGetCurrentAssignees(mr) 173 assigneeIDs, assigneesChanged, err := getUpdateUsers(currentAssignees, assignees, unassignees) 174 if err != nil { 175 log.Fatal(err) 176 } 177 178 currentReviewers := mrGetCurrentReviewers(mr) 179 reviewerIDs, reviewersChanged, err := getUpdateUsers(currentReviewers, reviewers, unreviewers) 180 if err != nil { 181 log.Fatal(err) 182 } 183 184 milestoneName, err := cmd.Flags().GetString("milestone") 185 if err != nil { 186 log.Fatal(err) 187 } 188 updateMilestone := cmd.Flags().Lookup("milestone").Changed 189 milestoneID := -1 190 191 if milestoneName != "" { 192 ms, err := lab.MilestoneGet(rn, milestoneName) 193 if err != nil { 194 log.Fatal(err) 195 } 196 milestoneID = ms.ID 197 } 198 199 targetBranchName, err := cmd.Flags().GetString("target-branch") 200 if err != nil { 201 log.Fatal(err) 202 } 203 204 targetBranchChanged := false 205 if targetBranchName != "" { 206 targetBranchName, err = getBranchName(rn, targetBranchName) 207 if err != nil { 208 log.Fatal(err) 209 } 210 211 if targetBranchName != mr.TargetBranch { 212 targetBranchChanged = true 213 } 214 } 215 216 // get all of the "message" flags 217 msgs, err := cmd.Flags().GetStringSlice("message") 218 if err != nil { 219 log.Fatal(err) 220 } 221 222 title := mr.Title 223 body := mr.Description 224 225 if len(msgs) > 0 && filename != "" { 226 log.Fatal("option -F cannot be combined with -m") 227 } 228 229 // We only consider opening the editor to edit the title and body on 230 // -m, -F, when --force-linebreak is used alone, or when no other flag 231 // is passed. However, it's common to set --force-linebreak through the 232 // config file, so we need to check if it's being set through the CLI 233 // or config file. 234 var openEditor bool 235 if len(msgs) > 0 || filename != "" || cmd.Flags().NFlag() == 0 { 236 openEditor = true 237 } else if linebreak && cmd.Flags().NFlag() == 1 { 238 cmd.Flags().Visit(func(f *pflag.Flag) { 239 if f.Name == "force-linebreak" { 240 openEditor = true 241 return 242 } 243 }) 244 } 245 246 if openEditor { 247 title, body, err = editDescription(mr.Title, mr.Description, msgs, filename) 248 if err != nil { 249 log.Fatal(err) 250 } 251 if title == "" { 252 log.Fatal("aborting: empty mr title") 253 } 254 255 if linebreak { 256 body = textToMarkdown(body) 257 } 258 } 259 260 isWIP := hasPrefix(title, "wip:") || 261 hasPrefix(title, "[wip]") 262 isDraft := hasPrefix(title, "draft:") || 263 hasPrefix(title, "[draft]") || 264 hasPrefix(title, "(draft)") 265 266 if ready { 267 if isWIP { 268 if title[0] == '[' { 269 title = strings.TrimPrefix(title, title[0:5]) 270 } else { 271 title = strings.TrimPrefix(title, title[0:4]) 272 } 273 } else if isDraft { 274 if title[0] == '(' || title[0] == '[' { 275 title = strings.TrimPrefix(title, title[0:7]) 276 } else { 277 title = strings.TrimPrefix(title, title[0:6]) 278 } 279 } 280 } 281 282 if draft { 283 if isWIP { 284 log.Fatal("the use of \"WIP\" terminology is deprecated, use \"Draft\" instead") 285 } 286 287 if !isDraft { 288 title = "Draft: " + title 289 } 290 } 291 292 abortUpdate := (title == mr.Title && body == mr.Description && 293 !labelsChanged && !assigneesChanged && !updateMilestone && 294 !targetBranchChanged && !reviewersChanged) 295 if abortUpdate { 296 log.Fatal("aborting: no changes") 297 } 298 299 opts := &gitlab.UpdateMergeRequestOptions{ 300 Title: &title, 301 Description: &body, 302 } 303 304 if labelsChanged { 305 // empty arrays are just ignored 306 opts.AddLabels = addLabels 307 opts.RemoveLabels = rmLabels 308 } 309 310 if assigneesChanged { 311 opts.AssigneeIDs = assigneeIDs 312 } 313 314 if reviewersChanged { 315 opts.ReviewerIDs = reviewerIDs 316 } 317 318 if updateMilestone { 319 opts.MilestoneID = &milestoneID 320 } 321 322 if targetBranchChanged { 323 opts.TargetBranch = &targetBranchName 324 } 325 326 mrURL, err := lab.MRUpdate(rn, int(mrNum), opts) 327 if err != nil { 328 log.Fatal(err) 329 } 330 fmt.Println(mrURL) 331 }, 332 } 333 334 // mrGetCurrentAssignees returns a string slice of the current assignees' 335 // usernames 336 func mrGetCurrentAssignees(mr *gitlab.MergeRequest) []string { 337 currentAssignees := make([]string, len(mr.Assignees)) 338 if len(mr.Assignees) > 0 && mr.Assignees[0].Username != "" { 339 for i, a := range mr.Assignees { 340 currentAssignees[i] = a.Username 341 } 342 } 343 return currentAssignees 344 } 345 346 // mrGetCurrentReviewers returns a string slice of the current reviewers' 347 // usernames 348 func mrGetCurrentReviewers(mr *gitlab.MergeRequest) []string { 349 currentReviewers := make([]string, len(mr.Reviewers)) 350 if len(mr.Reviewers) > 0 && mr.Reviewers[0].Username != "" { 351 for i, a := range mr.Reviewers { 352 currentReviewers[i] = a.Username 353 } 354 } 355 return currentReviewers 356 } 357 358 // getBranchName considers the possible ambiguity of different branch names 359 func getBranchName(project, branch string) (string, error) { 360 opts := &gitlab.ListBranchesOptions{ 361 Search: &branch, 362 } 363 364 projectBranches, err := lab.BranchList(project, opts) 365 if err != nil { 366 return "", err 367 } 368 369 // Branch API accepts a search parameter, so we may get the answer 370 // right away, however, the search term may match as a substring, so we 371 // also need to check for multiple branch names and their ambiguity 372 var match string 373 374 switch len(projectBranches) { 375 case 0: 376 return "", errors.Errorf("Branch '%s' not found\n", branch) 377 case 1: 378 match = projectBranches[0].Name 379 default: 380 branchNames := make([]string, len(projectBranches)) 381 for _, branch := range projectBranches { 382 branchNames = append(branchNames, branch.Name) 383 } 384 385 // Handle term ambiguity for multiple matched branch names 386 matches, err := matchTerms([]string{branch}, branchNames) 387 if err != nil { 388 return "", errors.Errorf("Branch %s\n", err.Error()) 389 } 390 391 // we only asked for a single term 392 match = matches[0] 393 } 394 395 return match, nil 396 } 397 398 func init() { 399 mrEditCmd.Flags().StringSliceP("message", "m", []string{}, "use the given <msg>; multiple -m are concatenated as separate paragraphs") 400 mrEditCmd.Flags().StringSliceP("label", "l", []string{}, "add the given label(s) to the merge request") 401 mrEditCmd.Flags().StringSliceP("unlabel", "", []string{}, "remove the given label(s) from the merge request") 402 mrEditCmd.Flags().StringSliceP("assign", "a", []string{}, "add an assignee by username") 403 mrEditCmd.Flags().StringSliceP("unassign", "", []string{}, "remove an assignee by username") 404 mrEditCmd.Flags().String("milestone", "", "set milestone") 405 mrEditCmd.Flags().StringP("target-branch", "t", "", "edit MR target branch") 406 mrEditCmd.Flags().StringP("file", "F", "", "use the given file as the description") 407 mrEditCmd.Flags().Bool("force-linebreak", false, "append 2 spaces to the end of each line to force markdown linebreaks") 408 mrEditCmd.Flags().Bool("draft", false, "mark the merge request as draft") 409 mrEditCmd.Flags().Bool("ready", false, "mark the merge request as ready") 410 mrEditCmd.Flags().StringSliceP("review", "r", []string{}, "add an reviewer by username") 411 mrEditCmd.Flags().StringSliceP("unreview", "", []string{}, "remove an reviewer by username") 412 mrEditCmd.Flags().Bool("delete-note", false, "delete the given note; must be provided in <mrID>:<noteID> format") 413 mrEditCmd.Flags().SortFlags = false 414 415 mrCmd.AddCommand(mrEditCmd) 416 417 carapace.Gen(mrEditCmd).FlagCompletion(carapace.ActionMap{ 418 "label": carapace.ActionMultiParts(",", func(c carapace.Context) carapace.Action { 419 project, _, err := parseArgsRemoteAndProject(c.Args) 420 if err != nil { 421 return carapace.ActionMessage(err.Error()) 422 } 423 return action.Labels(project).Invoke(c).Filter(c.Parts).ToA() 424 425 }), 426 "milestone": carapace.ActionCallback(func(c carapace.Context) carapace.Action { 427 project, _, err := parseArgsRemoteAndProject(c.Args) 428 if err != nil { 429 return carapace.ActionMessage(err.Error()) 430 } 431 return action.Milestones(project, action.MilestoneOpts{Active: true}) 432 }), 433 }) 434 435 carapace.Gen(mrEditCmd).PositionalCompletion( 436 action.Remotes(), 437 action.MergeRequests(mrList), 438 ) 439 }