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

     1  package cmd
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"io/ioutil"
     7  	"runtime"
     8  	"strconv"
     9  	"strings"
    10  
    11  	"github.com/MakeNowJust/heredoc/v2"
    12  	"github.com/rsteube/carapace"
    13  	"github.com/spf13/cobra"
    14  	gitlab "github.com/xanzy/go-gitlab"
    15  	"github.com/zaquestion/lab/internal/action"
    16  	"github.com/zaquestion/lab/internal/git"
    17  	lab "github.com/zaquestion/lab/internal/gitlab"
    18  )
    19  
    20  var mrCreateDiscussionCmd = &cobra.Command{
    21  	Use:     "discussion [remote] [<MR ID or branch>]",
    22  	Short:   "Start a discussion on an MR on GitLab",
    23  	Aliases: []string{"block", "thread"},
    24  	Example: heredoc.Doc(`
    25  		lab mr discussion
    26  		lab mr discussion origin
    27  		lab mr discussion my_remote -m "discussion comment"
    28  		lab mr discussion upstream -F my_comment.txt
    29  		lab mr discussion --commit abcdef123456
    30  		lab mr discussion my-topic-branch
    31  		lab mr discussion origin 123
    32  		lab mr discussion origin my-topic-branch
    33  		lab mr discussion --commit abcdef123456 --position=main.c:+100,100`),
    34  	PersistentPreRun: labPersistentPreRun,
    35  	Run: func(cmd *cobra.Command, args []string) {
    36  		rn, mrNum, err := parseArgsWithGitBranchMR(args)
    37  		if err != nil {
    38  			log.Fatal(err)
    39  		}
    40  
    41  		msgs, err := cmd.Flags().GetStringSlice("message")
    42  		if err != nil {
    43  			log.Fatal(err)
    44  		}
    45  
    46  		filename, err := cmd.Flags().GetString("file")
    47  		if err != nil {
    48  			log.Fatal(err)
    49  		}
    50  
    51  		commit, err := cmd.Flags().GetString("commit")
    52  		if err != nil {
    53  			log.Fatal(err)
    54  		}
    55  
    56  		position, err := cmd.Flags().GetString("position")
    57  		if err != nil {
    58  			log.Fatal(err)
    59  		}
    60  		var posFile string
    61  		var posLineType byte
    62  		var posLineNumberNew, posLineNumberOld uint64
    63  		if position != "" {
    64  			colonOffset := strings.LastIndex(position, ":")
    65  			positionUserError := "argument to --position must match <file>:[+- ]<old_line>,<new_line>"
    66  			if colonOffset == -1 {
    67  				log.Fatal(positionUserError + `: missing ":"`)
    68  			}
    69  			posFile = position[:colonOffset]
    70  			lineTypeOffset := colonOffset + 1
    71  			if lineTypeOffset == len(position) {
    72  				log.Fatal(positionUserError + `: expected one of "+- ", found end of string`)
    73  			}
    74  			posLineType = position[lineTypeOffset]
    75  			if bytes.IndexByte([]byte("+- "), posLineType) == -1 {
    76  				log.Fatal(positionUserError + fmt.Sprintf(`: expected one of "+- ", found %q`, posLineType))
    77  			}
    78  			oldLineOffset := colonOffset + 2
    79  			if oldLineOffset == len(position) {
    80  				log.Fatal(positionUserError + ": missing line numbers")
    81  			}
    82  			commaOffset := strings.LastIndex(position, ",")
    83  			if commaOffset == -1 || commaOffset < colonOffset {
    84  				log.Fatal(positionUserError + `: missing "," to separate line numbers`)
    85  			}
    86  			posLineNumberOld, err = strconv.ParseUint(position[oldLineOffset:commaOffset], 10, 32)
    87  			if err != nil {
    88  				log.Fatal(positionUserError + ":error parsing <old_line>: " + err.Error())
    89  			}
    90  			newNumberOffset := commaOffset + 1
    91  			posLineNumberNew, err = strconv.ParseUint(position[newNumberOffset:], 10, 32)
    92  			if err != nil {
    93  				log.Fatal(positionUserError + ":error parsing <new_line>: " + err.Error())
    94  			}
    95  		}
    96  
    97  		state := noteGetState(rn, true, int(mrNum))
    98  
    99  		body := ""
   100  		if filename != "" {
   101  			content, err := ioutil.ReadFile(filename)
   102  			if err != nil {
   103  				log.Fatal(err)
   104  			}
   105  			body = string(content)
   106  		} else if position != "" || commit == "" {
   107  			// TODO If we are commenting on a specific position in the diff, we should include some context in the template.
   108  			body, err = mrDiscussionMsg(int(mrNum), state, commit, msgs, "\n")
   109  			if err != nil {
   110  				_, f, l, _ := runtime.Caller(0)
   111  				log.Fatal(f+":"+strconv.Itoa(l)+" ", err)
   112  			}
   113  		} else {
   114  			body = getCommitBody(rn, commit)
   115  			body, err = mrDiscussionMsg(int(mrNum), state, commit, nil, body)
   116  			if err != nil {
   117  				_, f, l, _ := runtime.Caller(0)
   118  				log.Fatal(f+":"+strconv.Itoa(l)+" ", err)
   119  			}
   120  			createCommitComments(rn, int(mrNum), commit, body, true)
   121  			return
   122  		}
   123  
   124  		var notePos *gitlab.NotePosition
   125  		if position != "" {
   126  			if commit == "" {
   127  				// We currently only support "--position" when commenting on individual commits within an MR.
   128  				// GitLab also allows to comment on the sum of all changes of an MR.
   129  				// To do so, we'd need to fill the NotePosition below with the target branch as "base SHA" and the source branch as "head SHA".
   130  				// However, commenting on individual commits within an MR is superior since it gives more information to the MR author.
   131  				// Additionally, commenting on the sum of all changes is only useful when the changes come with a messy history.
   132  				// We shouldn't encourage that  - GitLab already does ;).
   133  				log.Fatal("--position currently requires --commit")
   134  			}
   135  			parentSHA, err := git.RevParse(commit + "~")
   136  			if err != nil {
   137  				log.Fatal(err)
   138  			}
   139  			// WORKAROUND For added (-) and deleted (+) lines we only need one line number parameter, but for context lines we need both. https://gitlab.com/gitlab-org/gitlab/-/issues/325161
   140  			newLine := posLineNumberNew
   141  			if posLineType == '-' {
   142  				newLine = 0
   143  			}
   144  			oldLine := posLineNumberOld
   145  			if posLineType == '+' {
   146  				oldLine = 0
   147  			}
   148  			notePos = &gitlab.NotePosition{
   149  				BaseSHA:      parentSHA,
   150  				StartSHA:     parentSHA,
   151  				HeadSHA:      commit,
   152  				PositionType: "text",
   153  				NewPath:      posFile,
   154  				NewLine:      int(newLine),
   155  				OldPath:      posFile,
   156  				OldLine:      int(oldLine),
   157  			}
   158  		}
   159  
   160  		if body == "" {
   161  			log.Fatal("aborting discussion due to empty discussion msg")
   162  		}
   163  		var commitID *string
   164  		if commit != "" {
   165  			commitID = &commit
   166  		}
   167  
   168  		discussionURL, err := lab.MRCreateDiscussion(rn, int(mrNum), &gitlab.CreateMergeRequestDiscussionOptions{
   169  			Body:     &body,
   170  			CommitID: commitID,
   171  			Position: notePos,
   172  		})
   173  		if err != nil {
   174  			log.Fatal(err)
   175  		}
   176  		fmt.Println(discussionURL)
   177  	},
   178  }
   179  
   180  func mrDiscussionMsg(mrNum int, state string, commit string, msgs []string, body string) (string, error) {
   181  	if len(msgs) > 0 {
   182  		return strings.Join(msgs[0:], "\n\n"), nil
   183  	}
   184  
   185  	tmpl := mrDiscussionGetTemplate(commit)
   186  	text, err := noteText(mrNum, state, commit, body, tmpl)
   187  	if err != nil {
   188  		return "", err
   189  	}
   190  	return git.EditFile("MR_DISCUSSION", text)
   191  }
   192  
   193  func mrDiscussionGetTemplate(commit string) string {
   194  	if commit == "" {
   195  		return heredoc.Doc(`
   196  		{{.InitMsg}}
   197  		{{.CommentChar}} This thread is being started on {{.State}} Merge Request {{.IDnum}}.
   198  		{{.CommentChar}} Comment lines beginning with '{{.CommentChar}}' are discarded.`)
   199  	}
   200  	return heredoc.Doc(`
   201  		{{.InitMsg}}
   202  		{{.CommentChar}} This thread is being started on {{.State}} Merge Request {{.IDnum}} commit {{.Commit}}.
   203  		{{.CommentChar}} Do not delete patch tracking lines that begin with '|'.
   204  		{{.CommentChar}} Comment lines beginning with '{{.CommentChar}}' are discarded.`)
   205  }
   206  
   207  func init() {
   208  	mrCreateDiscussionCmd.Flags().StringSliceP("message", "m", []string{}, "use the given <msg>; multiple -m are concatenated as separate paragraphs")
   209  	mrCreateDiscussionCmd.Flags().StringP("file", "F", "", "use the given file as the message")
   210  	mrCreateDiscussionCmd.Flags().StringP("commit", "c", "", "start a thread on a commit")
   211  
   212  	mrCreateDiscussionCmd.Flags().StringP("position", "", "", heredoc.Doc(`
   213  		start a thread on a specific line of the diff
   214  		argument must be of the form <file>":"["+" | "-" | " "]<old_line>","<new_line>
   215  		that is, the file name, followed by the line type - one of "+" (added line),
   216  		"-" (deleted line) or a space character (context line) - followed by
   217  		the line number in the old version of the file, a ",", and finally
   218  		the line number in the new version of the file. If the line type is "+", then
   219  		<old_line> is ignored. If the line type is "-", then <new_line> is ignored.
   220  
   221  		Here's an example diff that explains how to determine the old/new line numbers:
   222  	    
   223  			--- a/README.md		old	new
   224  			+++ b/README.md
   225  			@@ -100,3 +100,4 @@
   226  			 pre-context line	100	100
   227  			-deleted line		101	101
   228  			+added line 1		101	102
   229  			+added line 2		101	103
   230  			 post-context line	102	104
   231  
   232  		# Comment on "deleted line":
   233  		lab mr discussion --commit=commit-id --position=README.md:-101,101
   234  		# Comment on "added line 2":
   235  		lab mr discussion --commit=commit-id --position=README.md:+101,103
   236  		# Comment on the "post-context line":
   237  		lab mr discussion --commit=commit-id --position=README.md:\ 102,104`))
   238  
   239  	mrCmd.AddCommand(mrCreateDiscussionCmd)
   240  	carapace.Gen(mrCreateDiscussionCmd).PositionalCompletion(
   241  		action.Remotes(),
   242  		action.MergeRequests(mrList),
   243  	)
   244  }