github.com/vipcoin-gold/reviewdog@v1.0.2/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/vipcoin-gold/go-gitlab" 12 "golang.org/x/sync/errgroup" 13 14 "github.com/vipcoin-gold/reviewdog" 15 "github.com/vipcoin-gold/reviewdog/proto/rdf" 16 "github.com/vipcoin-gold/reviewdog/service/commentutil" 17 "github.com/vipcoin-gold/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.NotePosition{ 126 StartSHA: targetBranch.Commit.ID, 127 HeadSHA: g.sha, 128 BaseSHA: targetBranch.Commit.ID, 129 PositionType: "text", 130 NewPath: loc.GetPath(), 131 NewLine: lnum, 132 } 133 if c.Result.OldPath != "" && c.Result.OldLine != 0 { 134 pos.OldPath = c.Result.OldPath 135 pos.OldLine = c.Result.OldLine 136 } 137 discussion := &gitlab.CreateMergeRequestDiscussionOptions{ 138 Body: gitlab.String(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 }