github.com/mistwind/reviewdog@v0.0.0-20230322024206-9cfa11856d58/service/gitlab/gitlab_mr_discussion.go (about)

     1  package gitlab
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"path/filepath"
     7  	"strconv"
     8  	"strings"
     9  	"sync"
    10  
    11  	"github.com/xanzy/go-gitlab"
    12  	"golang.org/x/sync/errgroup"
    13  
    14  	"github.com/mistwind/reviewdog"
    15  	"github.com/mistwind/reviewdog/proto/rdf"
    16  	"github.com/mistwind/reviewdog/service/commentutil"
    17  	"github.com/mistwind/reviewdog/service/serviceutil"
    18  )
    19  
    20  const (
    21  	invalidSuggestionPre  = "<details><summary>reviewdog suggestion error</summary>"
    22  	invalidSuggestionPost = "</details>"
    23  )
    24  
    25  // MergeRequestDiscussionCommenter is a comment and diff service for GitLab MergeRequest.
    26  //
    27  // API:
    28  //  https://docs.gitlab.com/ee/api/discussions.html#create-new-merge-request-discussion
    29  //  POST /projects/:id/merge_requests/:merge_request_iid/discussions
    30  type MergeRequestDiscussionCommenter struct {
    31  	cli      *gitlab.Client
    32  	pr       int
    33  	sha      string
    34  	projects string
    35  
    36  	muComments   sync.Mutex
    37  	postComments []*reviewdog.Comment
    38  
    39  	// wd is working directory relative to root of repository.
    40  	wd string
    41  }
    42  
    43  // NewGitLabMergeRequestDiscussionCommenter returns a new MergeRequestDiscussionCommenter service.
    44  // MergeRequestDiscussionCommenter service needs git command in $PATH.
    45  func NewGitLabMergeRequestDiscussionCommenter(cli *gitlab.Client, owner, repo string, pr int, sha string) (*MergeRequestDiscussionCommenter, error) {
    46  	workDir, err := serviceutil.GitRelWorkdir()
    47  	if err != nil {
    48  		return nil, fmt.Errorf("MergeRequestDiscussionCommenter needs 'git' command: %w", err)
    49  	}
    50  	return &MergeRequestDiscussionCommenter{
    51  		cli:      cli,
    52  		pr:       pr,
    53  		sha:      sha,
    54  		projects: owner + "/" + repo,
    55  		wd:       workDir,
    56  	}, nil
    57  }
    58  
    59  // Post accepts a comment and holds it. Flush method actually posts comments to
    60  // GitLab in parallel.
    61  func (g *MergeRequestDiscussionCommenter) Post(_ context.Context, c *reviewdog.Comment) error {
    62  	c.Result.Diagnostic.GetLocation().Path = filepath.ToSlash(
    63  		filepath.Join(g.wd, c.Result.Diagnostic.GetLocation().GetPath()))
    64  	g.muComments.Lock()
    65  	defer g.muComments.Unlock()
    66  	g.postComments = append(g.postComments, c)
    67  	return nil
    68  }
    69  
    70  // Flush posts comments which has not been posted yet.
    71  func (g *MergeRequestDiscussionCommenter) Flush(ctx context.Context) error {
    72  	g.muComments.Lock()
    73  	defer g.muComments.Unlock()
    74  	postedcs, err := g.createPostedComments()
    75  	if err != nil {
    76  		return fmt.Errorf("failed to create posted comments: %w", err)
    77  	}
    78  	return g.postCommentsForEach(ctx, postedcs)
    79  }
    80  
    81  func (g *MergeRequestDiscussionCommenter) createPostedComments() (commentutil.PostedComments, error) {
    82  	postedcs := make(commentutil.PostedComments)
    83  	discussions, err := listAllMergeRequestDiscussion(g.cli, g.projects, g.pr, &gitlab.ListMergeRequestDiscussionsOptions{PerPage: 100})
    84  	if err != nil {
    85  		return nil, fmt.Errorf("failed to list all merge request discussions: %w", err)
    86  	}
    87  	for _, d := range discussions {
    88  		for _, note := range d.Notes {
    89  			pos := note.Position
    90  			if pos == nil || pos.NewPath == "" || pos.NewLine == 0 || note.Body == "" {
    91  				continue
    92  			}
    93  			postedcs.AddPostedComment(pos.NewPath, pos.NewLine, note.Body)
    94  		}
    95  	}
    96  	return postedcs, nil
    97  }
    98  
    99  func (g *MergeRequestDiscussionCommenter) postCommentsForEach(ctx context.Context, postedcs commentutil.PostedComments) error {
   100  	mr, _, err := g.cli.MergeRequests.GetMergeRequest(g.projects, g.pr, nil, gitlab.WithContext(ctx))
   101  	if err != nil {
   102  		return fmt.Errorf("failed to get merge request: %w", err)
   103  	}
   104  	targetBranch, _, err := g.cli.Branches.GetBranch(mr.TargetProjectID, mr.TargetBranch, nil)
   105  	if err != nil {
   106  		return err
   107  	}
   108  
   109  	var eg errgroup.Group
   110  	for _, c := range g.postComments {
   111  		c := c
   112  		loc := c.Result.Diagnostic.GetLocation()
   113  		lnum := int(loc.GetRange().GetStart().GetLine())
   114  		body := commentutil.MarkdownComment(c)
   115  
   116  		if suggestion := buildSuggestions(c); suggestion != "" {
   117  			body = body + "\n\n" + suggestion
   118  		}
   119  
   120  		if !c.Result.InDiffFile || lnum == 0 || postedcs.IsPosted(c, lnum, body) {
   121  			continue
   122  		}
   123  		eg.Go(func() error {
   124  			pos := &gitlab.NotePosition{
   125  				StartSHA:     targetBranch.Commit.ID,
   126  				HeadSHA:      g.sha,
   127  				BaseSHA:      targetBranch.Commit.ID,
   128  				PositionType: "text",
   129  				NewPath:      loc.GetPath(),
   130  				NewLine:      lnum,
   131  			}
   132  			if c.Result.OldPath != "" && c.Result.OldLine != 0 {
   133  				pos.OldPath = c.Result.OldPath
   134  				pos.OldLine = c.Result.OldLine
   135  			}
   136  			discussion := &gitlab.CreateMergeRequestDiscussionOptions{
   137  				Body:     gitlab.String(body),
   138  				Position: pos,
   139  			}
   140  			_, _, err := g.cli.Discussions.CreateMergeRequestDiscussion(g.projects, g.pr, discussion)
   141  			if err != nil {
   142  				return fmt.Errorf("failed to create merge request discussion: %w", err)
   143  			}
   144  			return nil
   145  		})
   146  	}
   147  	return eg.Wait()
   148  }
   149  
   150  func listAllMergeRequestDiscussion(cli *gitlab.Client, projectID string, mergeRequest int, opts *gitlab.ListMergeRequestDiscussionsOptions) ([]*gitlab.Discussion, error) {
   151  	discussions, resp, err := cli.Discussions.ListMergeRequestDiscussions(projectID, mergeRequest, opts)
   152  	if err != nil {
   153  		return nil, err
   154  	}
   155  	if resp.NextPage == 0 {
   156  		return discussions, nil
   157  	}
   158  	newOpts := &gitlab.ListMergeRequestDiscussionsOptions{
   159  		Page:    resp.NextPage,
   160  		PerPage: opts.PerPage,
   161  	}
   162  	restDiscussions, err := listAllMergeRequestDiscussion(cli, projectID, mergeRequest, newOpts)
   163  	if err != nil {
   164  		return nil, err
   165  	}
   166  	return append(discussions, restDiscussions...), nil
   167  }
   168  
   169  // creates diff in markdown for suggested changes
   170  // Ref gitlab suggestion: https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html
   171  func buildSuggestions(c *reviewdog.Comment) string {
   172  	var sb strings.Builder
   173  	for _, s := range c.Result.Diagnostic.GetSuggestions() {
   174  		if s.Range == nil || s.Range.Start == nil || s.Range.End == nil {
   175  			continue
   176  		}
   177  
   178  		txt, err := buildSingleSuggestion(c, s)
   179  		if err != nil {
   180  			sb.WriteString(invalidSuggestionPre + err.Error() + invalidSuggestionPost + "\n")
   181  			continue
   182  		}
   183  		sb.WriteString(txt)
   184  		sb.WriteString("\n")
   185  	}
   186  
   187  	return sb.String()
   188  }
   189  
   190  func buildSingleSuggestion(c *reviewdog.Comment, s *rdf.Suggestion) (string, error) {
   191  	var sb strings.Builder
   192  
   193  	// we might need to use 4 or more backticks
   194  	//
   195  	// https://docs.gitlab.com/ee/user/project/merge_requests/reviews/suggestions.html#code-block-nested-in-suggestions
   196  	// > If you need to make a suggestion that involves a fenced code block, wrap your suggestion in four backticks instead of the usual three.
   197  	//
   198  	// The documentation doesn't explicitly say anything about cases more than 4 backticks,
   199  	// however it seems to be handled as intended.
   200  	txt := s.GetText()
   201  	backticks := commentutil.GetCodeFenceLength(txt)
   202  
   203  	lines := strconv.Itoa(int(s.Range.End.Line - s.Range.Start.Line))
   204  	sb.Grow(backticks + len("suggestion:-0+\n") + len(lines) + len(txt) + len("\n") + backticks)
   205  	commentutil.WriteCodeFence(&sb, backticks)
   206  	sb.WriteString("suggestion:-0+")
   207  	sb.WriteString(lines)
   208  	sb.WriteString("\n")
   209  	if txt != "" {
   210  		sb.WriteString(txt)
   211  		sb.WriteString("\n")
   212  	}
   213  	commentutil.WriteCodeFence(&sb, backticks)
   214  
   215  	return sb.String(), nil
   216  }