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 }