github.com/zaquestion/lab@v0.25.1/cmd/note_common.go (about) 1 package cmd 2 3 import ( 4 "bufio" 5 "bytes" 6 "fmt" 7 "io/ioutil" 8 "os" 9 "runtime" 10 "strconv" 11 "strings" 12 "text/template" 13 14 "github.com/MakeNowJust/heredoc/v2" 15 "github.com/spf13/cobra" 16 gitlab "github.com/xanzy/go-gitlab" 17 "github.com/zaquestion/lab/internal/git" 18 lab "github.com/zaquestion/lab/internal/gitlab" 19 ) 20 21 func noteRunFn(cmd *cobra.Command, args []string) { 22 isMR := false 23 if cmd.Parent().Name() == "mr" { 24 isMR = true 25 } 26 27 reply, branchArgs, err := filterCommentArg(args) 28 if err != nil { 29 log.Fatal(err) 30 } 31 32 var ( 33 rn string 34 idNum int = 0 35 ) 36 37 if isMR { 38 s, mrNum, _ := parseArgsWithGitBranchMR(branchArgs) 39 if mrNum == 0 { 40 fmt.Println("Error: Cannot determine MR id.") 41 os.Exit(1) 42 } 43 idNum = int(mrNum) 44 rn = s 45 } else { 46 s, issueNum, _ := parseArgsRemoteAndID(branchArgs) 47 if issueNum == 0 { 48 fmt.Println("Error: Cannot determine issue id.") 49 os.Exit(1) 50 } 51 idNum = int(issueNum) 52 rn = s 53 } 54 55 msgs, err := cmd.Flags().GetStringArray("message") 56 if err != nil { 57 log.Fatal(err) 58 } 59 60 filename, err := cmd.Flags().GetString("file") 61 if err != nil { 62 log.Fatal(err) 63 } 64 65 linebreak, err := cmd.Flags().GetBool("force-linebreak") 66 if err != nil { 67 log.Fatal(err) 68 } 69 70 commit, err := cmd.Flags().GetString("commit") 71 if err != nil { 72 log.Fatal(err) 73 } 74 75 if reply != 0 { 76 resolve, err := cmd.Flags().GetBool("resolve") 77 if err != nil { 78 log.Fatal(err) 79 } 80 // 'lab mr resolve' always overrides options 81 if cmd.CalledAs() == "resolve" { 82 resolve = true 83 } 84 85 quote, err := cmd.Flags().GetBool("quote") 86 if err != nil { 87 log.Fatal(err) 88 } 89 90 replyNote(rn, isMR, int(idNum), reply, quote, false, filename, linebreak, resolve, msgs) 91 return 92 } 93 94 createNote(rn, isMR, int(idNum), msgs, filename, linebreak, commit, true) 95 } 96 97 func createCommitNote(rn string, mrID int, sha string, newFile string, oldFile string, linetype string, oldline int, newline int, comment string, block bool) { 98 line := oldline 99 if oldline == -1 { 100 line = newline 101 } 102 103 if block { 104 webURL, err := lab.CreateMergeRequestCommitDiscussion(rn, mrID, sha, newFile, oldFile, line, linetype, comment) 105 if err != nil { 106 log.Fatal(err) 107 } 108 fmt.Println(webURL) 109 return 110 } 111 112 webURL, err := lab.CreateCommitComment(rn, sha, newFile, oldFile, line, linetype, comment) 113 if err != nil { 114 log.Fatal(err) 115 } 116 fmt.Println(webURL) 117 } 118 119 func getCommitBody(project string, commit string) (body string) { 120 //body is going to be the commit diff 121 ds, err := lab.GetCommitDiff(project, commit) 122 if err != nil { 123 fmt.Printf(" Could not get diff for commit %s.\n", commit) 124 log.Fatal(err) 125 } 126 127 if len(ds) == 0 { 128 log.Fatal(" No diff found for %s.", commit) 129 } 130 131 for _, d := range ds { 132 body = body + fmt.Sprintf("| newfile: %s oldfile: %s\n", d.NewPath, d.OldPath) 133 body = body + displayDiff(d.Diff, 0, 0, true) 134 } 135 136 return body 137 } 138 139 func createCommitComments(project string, mrID int, commit string, body string, block bool) { 140 // Go through the body line-by-line and find lines that do not 141 // begin with |. These lines are comments that have been made 142 // on the patch. The lines that begin with | contain patch 143 // tracking information (new line & old line number pairs, 144 // and file information) 145 scanner := bufio.NewScanner(strings.NewReader(body)) 146 lastDiffLine := "" 147 comments := "" 148 newfile := "" 149 oldfile := "" 150 diffCut := 1 151 152 for scanner.Scan() { 153 line := scanner.Text() 154 155 if !strings.HasPrefix(line, "| ") { 156 comments += "\n" + line 157 continue 158 } 159 160 if comments != "" && lastDiffLine == "" { 161 log.Fatal("Cannot comment on first line of commit (context unknown).") 162 } 163 164 if comments != "" { 165 linetype := "" 166 oldLineNum := -1 167 newLineNum := -1 168 169 // parse lastDiffLine 170 f := strings.Fields(strings.TrimSpace(lastDiffLine)) 171 172 // The output can be, for example: 173 // | # # [no +/-] < context comment 174 // | # # + < newline comment 175 // | # # - < oldline comment 176 // | # - < oldline comment 177 // | # + < newline comment 178 179 // f[0] is always a "|" 180 // f[1] will always be a number 181 val1, _ := strconv.Atoi(f[1]) 182 val2, err := strconv.Atoi(f[2]) 183 184 if err == nil { // f[2] is a number 185 if len(f) <= 3 { // f[3] does not exist 186 oldLineNum = val1 187 newLineNum = val2 188 linetype = "context" 189 } else { 190 newLineNum = val2 191 switch { 192 case strings.HasPrefix(f[3], "+"): 193 linetype = "new" 194 case strings.HasPrefix(f[3], "-"): 195 linetype = "old" 196 default: 197 linetype = "context" 198 } 199 } 200 } else { // f[2] is not a number 201 switch { 202 case strings.HasPrefix(f[2], "+"): 203 newLineNum = val1 204 linetype = "new" 205 case strings.HasPrefix(f[2], "-"): 206 oldLineNum = val1 207 linetype = "old" 208 default: 209 panic("unknown string in diff") 210 } 211 } 212 213 createCommitNote(project, mrID, commit, newfile, oldfile, linetype, oldLineNum, newLineNum, comments, block) 214 comments = "" 215 } 216 217 f := strings.Fields(strings.TrimSpace(line)) 218 if f[1] == "@@" { 219 // In GitLab diff output, the leading "@" symbol indicates where 220 // the metadata ends. This is true even if passing over a digit 221 // boundary (ie going from line 99 to 100). This location can 222 // be used to truncate the lines to only include the metadata. 223 diffCut = strings.Index(line, "@") + 1 224 lastDiffLine = "" 225 continue 226 } 227 228 if f[1] == "newfile:" { 229 // read filename 230 f := strings.Split(line, " ") 231 newfile = f[2] 232 if len(f) < 5 { 233 oldfile = "" 234 } else { 235 oldfile = f[4] 236 } 237 continue 238 } 239 240 if len(line) > diffCut { 241 lastDiffLine = line[0:diffCut] 242 } else { 243 lastDiffLine = line 244 } 245 } 246 } 247 248 func noteGetState(rn string, isMR bool, idNum int) (state string) { 249 if isMR { 250 mr, err := lab.MRGet(rn, idNum) 251 if err != nil { 252 log.Fatal(err) 253 } 254 255 state = map[string]string{ 256 "opened": "OPEN", 257 "closed": "CLOSED", 258 "merged": "MERGED", 259 }[mr.State] 260 } else { 261 issue, err := lab.IssueGet(rn, idNum) 262 if err != nil { 263 log.Fatal(err) 264 } 265 266 state = map[string]string{ 267 "opened": "OPEN", 268 "closed": "CLOSED", 269 }[issue.State] 270 } 271 272 return state 273 } 274 275 func createNote(rn string, isMR bool, idNum int, msgs []string, filename string, linebreak bool, commit string, hasNote bool) { 276 // hasNote is used by action that take advantage of Gitlab 'quick-action' notes, which do not create a noteURL 277 var err error 278 279 body := "" 280 if filename != "" { 281 content, err := ioutil.ReadFile(filename) 282 if err != nil { 283 log.Fatal(err) 284 } 285 body = string(content) 286 if hasNote && len(msgs) > 0 { 287 body += msgs[0] 288 } 289 } else { 290 state := noteGetState(rn, isMR, idNum) 291 292 if isMR && commit != "" { 293 body = getCommitBody(rn, commit) 294 } 295 296 body, err = noteMsg(msgs, isMR, idNum, state, commit, body) 297 if err != nil { 298 _, f, l, _ := runtime.Caller(0) 299 log.Fatal(f+":"+strconv.Itoa(l)+" ", err) 300 } 301 } 302 303 if body == "" { 304 log.Fatal("aborting note due to empty note msg") 305 } 306 307 if linebreak && commit == "" { 308 body = textToMarkdown(body) 309 } 310 311 var ( 312 noteURL string 313 ) 314 315 if isMR { 316 if commit != "" { 317 createCommitComments(rn, int(idNum), commit, body, false) 318 } else { 319 noteURL, err = lab.MRCreateNote(rn, idNum, &gitlab.CreateMergeRequestNoteOptions{ 320 Body: &body, 321 }) 322 } 323 } else { 324 noteURL, err = lab.IssueCreateNote(rn, idNum, &gitlab.CreateIssueNoteOptions{ 325 Body: &body, 326 }) 327 } 328 if err != nil { 329 log.Fatal(err) 330 } 331 if hasNote { 332 fmt.Println(noteURL) 333 } 334 } 335 336 func noteMsg(msgs []string, isMR bool, idNum int, state string, commit string, body string) (string, error) { 337 if len(msgs) > 0 { 338 return strings.Join(msgs[0:], "\n\n"), nil 339 } 340 341 tmpl := noteGetTemplate(isMR, commit) 342 text, err := noteText(idNum, state, commit, body, tmpl) 343 if err != nil { 344 return "", err 345 } 346 347 if isMR { 348 return git.EditFile("MR_NOTE", text) 349 } 350 return git.EditFile("ISSUE_NOTE", text) 351 } 352 353 func noteGetTemplate(isMR bool, commit string) string { 354 if !isMR { 355 return heredoc.Doc(` 356 {{.InitMsg}} 357 {{.CommentChar}} This comment is being applied to {{.State}} Issue {{.IDnum}}. 358 {{.CommentChar}} Comment lines beginning with '{{.CommentChar}}' are discarded.`) 359 } 360 if isMR && commit == "" { 361 return heredoc.Doc(` 362 {{.InitMsg}} 363 {{.CommentChar}} This comment is being applied to {{.State}} Merge Request {{.IDnum}}. 364 {{.CommentChar}} Comment lines beginning with '{{.CommentChar}}' are discarded.`) 365 } 366 return heredoc.Doc(` 367 {{.InitMsg}} 368 {{.CommentChar}} This comment is being applied to {{.State}} Merge Request {{.IDnum}} commit {{.Commit}}. 369 {{.CommentChar}} Do not delete patch tracking lines that begin with '|'. 370 {{.CommentChar}} Comment lines beginning with '{{.CommentChar}}' are discarded.`) 371 } 372 373 func noteText(idNum int, state string, commit string, body string, tmpl string) (string, error) { 374 initMsg := body 375 commentChar := git.CommentChar() 376 377 if commit != "" { 378 if len(commit) > 11 { 379 commit = commit[:12] 380 } 381 } 382 383 t, err := template.New("tmpl").Parse(tmpl) 384 if err != nil { 385 return "", err 386 } 387 388 msg := &struct { 389 InitMsg string 390 CommentChar string 391 State string 392 IDnum int 393 Commit string 394 }{ 395 InitMsg: initMsg, 396 CommentChar: commentChar, 397 State: state, 398 IDnum: idNum, 399 Commit: commit, 400 } 401 402 var b bytes.Buffer 403 err = t.Execute(&b, msg) 404 if err != nil { 405 return "", err 406 } 407 408 return b.String(), nil 409 } 410 411 func replyNote(rn string, isMR bool, idNum int, reply int, quote bool, update bool, filename string, linebreak bool, resolve bool, msgs []string) { 412 413 var ( 414 discussions []*gitlab.Discussion 415 err error 416 NoteURL string 417 ) 418 419 if isMR { 420 discussions, err = lab.MRListDiscussions(rn, idNum) 421 } else { 422 discussions, err = lab.IssueListDiscussions(rn, idNum) 423 } 424 if err != nil { 425 log.Fatal(err) 426 } 427 428 state := noteGetState(rn, isMR, idNum) 429 430 for _, discussion := range discussions { 431 for _, note := range discussion.Notes { 432 433 if note.System { 434 if note.ID == reply { 435 fmt.Println("ERROR: Cannot reply to note", note.ID) 436 } 437 continue 438 } 439 440 if note.ID != reply { 441 continue 442 } 443 444 body := "" 445 if len(msgs) != 0 { 446 body, err = noteMsg(msgs, isMR, idNum, state, "", body) 447 if err != nil { 448 _, f, l, _ := runtime.Caller(0) 449 log.Fatal(f+":"+strconv.Itoa(l)+" ", err) 450 } 451 } else if filename != "" { 452 content, err := ioutil.ReadFile(filename) 453 if err != nil { 454 log.Fatal(err) 455 } 456 body = string(content) 457 } else { 458 noteBody := "" 459 if quote { 460 noteBody = note.Body 461 noteBody = strings.Replace(noteBody, "\n", "\n>", -1) 462 if !update { 463 noteBody = ">" + noteBody + "\n" 464 } 465 } 466 body, err = noteMsg([]string{}, isMR, idNum, state, "", noteBody) 467 if err != nil { 468 _, f, l, _ := runtime.Caller(0) 469 log.Fatal(f+":"+strconv.Itoa(l)+" ", err) 470 } 471 } 472 473 if body == "" && !resolve { 474 log.Fatal("aborting note due to empty note msg") 475 } 476 477 if linebreak { 478 body = textToMarkdown(body) 479 } 480 481 if update { 482 if isMR { 483 NoteURL, err = lab.UpdateMRDiscussionNote(rn, idNum, discussion.ID, note.ID, body) 484 } else { 485 NoteURL, err = lab.UpdateIssueDiscussionNote(rn, idNum, discussion.ID, note.ID, body) 486 } 487 } else { 488 if isMR { 489 if body != "" { 490 NoteURL, err = lab.AddMRDiscussionNote(rn, idNum, discussion.ID, body) 491 } 492 if resolve { 493 NoteURL, err = lab.ResolveMRDiscussion(rn, idNum, discussion.ID, reply) 494 } 495 } else { 496 NoteURL, err = lab.AddIssueDiscussionNote(rn, idNum, discussion.ID, body) 497 } 498 } 499 if err != nil { 500 log.Fatal(err) 501 } 502 fmt.Println(NoteURL) 503 } 504 } 505 }