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