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  }