github.com/Mistwind/reviewdog@v0.0.0-20230317041057-48e69b6d9e86/service/github/github.go (about) 1 package github 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "path/filepath" 8 "strings" 9 "sync" 10 11 "github.com/google/go-github/v39/github" 12 13 "github.com/reviewdog/reviewdog" 14 "github.com/reviewdog/reviewdog/cienv" 15 "github.com/reviewdog/reviewdog/proto/rdf" 16 "github.com/reviewdog/reviewdog/service/commentutil" 17 "github.com/reviewdog/reviewdog/service/github/githubutils" 18 "github.com/reviewdog/reviewdog/service/serviceutil" 19 ) 20 21 var _ reviewdog.CommentService = &PullRequest{} 22 var _ reviewdog.DiffService = &PullRequest{} 23 24 const maxCommentsPerRequest = 30 25 26 const ( 27 invalidSuggestionPre = "<details><summary>reviewdog suggestion error</summary>" 28 invalidSuggestionPost = "</details>" 29 ) 30 31 // PullRequest is a comment and diff service for GitHub PullRequest. 32 // 33 // API: 34 // https://developer.github.com/v3/pulls/comments/#create-a-comment 35 // POST /repos/:owner/:repo/pulls/:number/comments 36 type PullRequest struct { 37 cli *github.Client 38 owner string 39 repo string 40 pr int 41 sha string 42 43 muComments sync.Mutex 44 postComments []*reviewdog.Comment 45 46 postedcs commentutil.PostedComments 47 48 // wd is working directory relative to root of repository. 49 wd string 50 } 51 52 // NewGitHubPullRequest returns a new PullRequest service. 53 // PullRequest service needs git command in $PATH. 54 func NewGitHubPullRequest(cli *github.Client, owner, repo string, pr int, sha string) (*PullRequest, error) { 55 workDir, err := serviceutil.GitRelWorkdir() 56 if err != nil { 57 return nil, fmt.Errorf("PullRequest needs 'git' command: %w", err) 58 } 59 return &PullRequest{ 60 cli: cli, 61 owner: owner, 62 repo: repo, 63 pr: pr, 64 sha: sha, 65 wd: workDir, 66 }, nil 67 } 68 69 // Post accepts a comment and holds it. Flush method actually posts comments to 70 // GitHub in parallel. 71 func (g *PullRequest) Post(_ context.Context, c *reviewdog.Comment) error { 72 c.Result.Diagnostic.GetLocation().Path = filepath.ToSlash(filepath.Join(g.wd, 73 c.Result.Diagnostic.GetLocation().GetPath())) 74 g.muComments.Lock() 75 defer g.muComments.Unlock() 76 g.postComments = append(g.postComments, c) 77 return nil 78 } 79 80 // Flush posts comments which has not been posted yet. 81 func (g *PullRequest) Flush(ctx context.Context) error { 82 g.muComments.Lock() 83 defer g.muComments.Unlock() 84 85 if err := g.setPostedComment(ctx); err != nil { 86 return err 87 } 88 return g.postAsReviewComment(ctx) 89 } 90 91 func (g *PullRequest) postAsReviewComment(ctx context.Context) error { 92 comments := make([]*github.DraftReviewComment, 0, len(g.postComments)) 93 remaining := make([]*reviewdog.Comment, 0) 94 for _, c := range g.postComments { 95 if !c.Result.InDiffContext { 96 // GitHub Review API cannot report results outside diff. If it's running 97 // in GitHub Actions, fallback to GitHub Actions log as report . 98 if cienv.IsInGitHubAction() { 99 githubutils.ReportAsGitHubActionsLog(c.ToolName, "warning", c.Result.Diagnostic) 100 } 101 continue 102 } 103 body := buildBody(c) 104 if g.postedcs.IsPosted(c, githubCommentLine(c), body) { 105 continue 106 } 107 // Only posts maxCommentsPerRequest comments per 1 request to avoid spammy 108 // review comments. An example GitHub error if we don't limit the # of 109 // review comments. 110 // 111 // > 403 You have triggered an abuse detection mechanism and have been 112 // > temporarily blocked from content creation. Please retry your request 113 // > again later. 114 // https://developer.github.com/v3/#abuse-rate-limits 115 if len(comments) >= maxCommentsPerRequest { 116 remaining = append(remaining, c) 117 continue 118 } 119 comments = append(comments, buildDraftReviewComment(c, body)) 120 } 121 122 if len(comments) == 0 { 123 return nil 124 } 125 126 review := &github.PullRequestReviewRequest{ 127 CommitID: &g.sha, 128 Event: github.String("COMMENT"), 129 Comments: comments, 130 Body: github.String(g.remainingCommentsSummary(remaining)), 131 } 132 _, _, err := g.cli.PullRequests.CreateReview(ctx, g.owner, g.repo, g.pr, review) 133 return err 134 } 135 136 // Document: https://docs.github.com/en/rest/reference/pulls#create-a-review-comment-for-a-pull-request 137 func buildDraftReviewComment(c *reviewdog.Comment, body string) *github.DraftReviewComment { 138 loc := c.Result.Diagnostic.GetLocation() 139 startLine, endLine := githubCommentLineRange(c) 140 r := &github.DraftReviewComment{ 141 Path: github.String(loc.GetPath()), 142 Side: github.String("RIGHT"), 143 Body: github.String(body), 144 Line: github.Int(endLine), 145 } 146 // GitHub API: Start line must precede the end line. 147 if startLine < endLine { 148 r.StartSide = github.String("RIGHT") 149 r.StartLine = github.Int(startLine) 150 } 151 return r 152 } 153 154 // line represents end line if it's a multiline comment in GitHub, otherwise 155 // it's start line. 156 // Document: https://docs.github.com/en/rest/reference/pulls#create-a-review-comment-for-a-pull-request 157 func githubCommentLine(c *reviewdog.Comment) int { 158 _, end := githubCommentLineRange(c) 159 return end 160 } 161 162 func githubCommentLineRange(c *reviewdog.Comment) (start int, end int) { 163 // Prefer first suggestion line range to diagnostic location if available so 164 // that reviewdog can post code suggestion as well when the line ranges are 165 // different between the diagnostic location and its suggestion. 166 if c.Result.FirstSuggestionInDiffContext && len(c.Result.Diagnostic.GetSuggestions()) > 0 { 167 s := c.Result.Diagnostic.GetSuggestions()[0] 168 startLine := s.GetRange().GetStart().GetLine() 169 endLine := s.GetRange().GetEnd().GetLine() 170 if endLine == 0 { 171 endLine = startLine 172 } 173 return int(startLine), int(endLine) 174 } 175 loc := c.Result.Diagnostic.GetLocation() 176 startLine := loc.GetRange().GetStart().GetLine() 177 endLine := loc.GetRange().GetEnd().GetLine() 178 if endLine == 0 { 179 endLine = startLine 180 } 181 return int(startLine), int(endLine) 182 } 183 184 func (g *PullRequest) remainingCommentsSummary(remaining []*reviewdog.Comment) string { 185 if len(remaining) == 0 { 186 return "" 187 } 188 perTool := make(map[string][]*reviewdog.Comment) 189 for _, c := range remaining { 190 perTool[c.ToolName] = append(perTool[c.ToolName], c) 191 } 192 var sb strings.Builder 193 sb.WriteString("Remaining comments which cannot be posted as a review comment to avoid GitHub Rate Limit\n") 194 sb.WriteString("\n") 195 for tool, comments := range perTool { 196 sb.WriteString("<details>\n") 197 sb.WriteString(fmt.Sprintf("<summary>%s</summary>\n", tool)) 198 sb.WriteString("\n") 199 for _, c := range comments { 200 sb.WriteString(githubutils.LinkedMarkdownDiagnostic(g.owner, g.repo, g.sha, c.Result.Diagnostic)) 201 sb.WriteString("\n") 202 } 203 sb.WriteString("</details>\n") 204 } 205 return sb.String() 206 } 207 208 func (g *PullRequest) setPostedComment(ctx context.Context) error { 209 g.postedcs = make(commentutil.PostedComments) 210 cs, err := g.comment(ctx) 211 if err != nil { 212 return err 213 } 214 for _, c := range cs { 215 if c.Line == nil || c.Path == nil || c.Body == nil { 216 continue 217 } 218 g.postedcs.AddPostedComment(c.GetPath(), c.GetLine(), c.GetBody()) 219 } 220 return nil 221 } 222 223 // Diff returns a diff of PullRequest. 224 func (g *PullRequest) Diff(ctx context.Context) ([]byte, error) { 225 opt := github.RawOptions{Type: github.Diff} 226 d, _, err := g.cli.PullRequests.GetRaw(ctx, g.owner, g.repo, g.pr, opt) 227 if err != nil { 228 return nil, err 229 } 230 return []byte(d), nil 231 } 232 233 // Strip returns 1 as a strip of git diff. 234 func (g *PullRequest) Strip() int { 235 return 1 236 } 237 238 func (g *PullRequest) comment(ctx context.Context) ([]*github.PullRequestComment, error) { 239 // https://developer.github.com/v3/guides/traversing-with-pagination/ 240 opts := &github.PullRequestListCommentsOptions{ 241 ListOptions: github.ListOptions{ 242 PerPage: 100, 243 }, 244 } 245 comments, err := listAllPullRequestsComments(ctx, g.cli, g.owner, g.repo, g.pr, opts) 246 if err != nil { 247 return nil, err 248 } 249 return comments, nil 250 } 251 252 func listAllPullRequestsComments(ctx context.Context, cli *github.Client, 253 owner, repo string, pr int, opts *github.PullRequestListCommentsOptions) ([]*github.PullRequestComment, error) { 254 comments, resp, err := cli.PullRequests.ListComments(ctx, owner, repo, pr, opts) 255 if err != nil { 256 return nil, err 257 } 258 if resp.NextPage == 0 { 259 return comments, nil 260 } 261 newOpts := &github.PullRequestListCommentsOptions{ 262 ListOptions: github.ListOptions{ 263 Page: resp.NextPage, 264 PerPage: opts.PerPage, 265 }, 266 } 267 restComments, err := listAllPullRequestsComments(ctx, cli, owner, repo, pr, newOpts) 268 if err != nil { 269 return nil, err 270 } 271 return append(comments, restComments...), nil 272 } 273 274 func buildBody(c *reviewdog.Comment) string { 275 cbody := commentutil.MarkdownComment(c) 276 if suggestion := buildSuggestions(c); suggestion != "" { 277 cbody += "\n" + suggestion 278 } 279 return cbody 280 } 281 282 func buildSuggestions(c *reviewdog.Comment) string { 283 var sb strings.Builder 284 for _, s := range c.Result.Diagnostic.GetSuggestions() { 285 txt, err := buildSingleSuggestion(c, s) 286 if err != nil { 287 sb.WriteString(invalidSuggestionPre + err.Error() + invalidSuggestionPost + "\n") 288 continue 289 } 290 sb.WriteString(txt) 291 sb.WriteString("\n") 292 } 293 return sb.String() 294 } 295 296 func buildSingleSuggestion(c *reviewdog.Comment, s *rdf.Suggestion) (string, error) { 297 start := s.GetRange().GetStart() 298 startLine := int(start.GetLine()) 299 end := s.GetRange().GetEnd() 300 endLine := int(end.GetLine()) 301 if endLine == 0 { 302 endLine = startLine 303 } 304 gStart, gEnd := githubCommentLineRange(c) 305 if startLine != gStart || endLine != gEnd { 306 return "", fmt.Errorf("GitHub comment range and suggestion line range must be same. L%d-L%d v.s. L%d-L%d", 307 gStart, gEnd, startLine, endLine) 308 } 309 if start.GetColumn() > 0 || end.GetColumn() > 0 { 310 return buildNonLineBasedSuggestion(c, s) 311 } 312 313 txt := s.GetText() 314 backticks := commentutil.GetCodeFenceLength(txt) 315 316 var sb strings.Builder 317 sb.Grow(backticks + len("suggestion\n") + len(txt) + len("\n") + backticks) 318 commentutil.WriteCodeFence(&sb, backticks) 319 sb.WriteString("suggestion\n") 320 if txt != "" { 321 sb.WriteString(txt) 322 sb.WriteString("\n") 323 } 324 commentutil.WriteCodeFence(&sb, backticks) 325 return sb.String(), nil 326 } 327 328 func buildNonLineBasedSuggestion(c *reviewdog.Comment, s *rdf.Suggestion) (string, error) { 329 sourceLines := c.Result.SourceLines 330 if len(sourceLines) == 0 { 331 return "", errors.New("source lines are not available") 332 } 333 start := s.GetRange().GetStart() 334 end := s.GetRange().GetEnd() 335 startLineContent, err := getSourceLine(sourceLines, int(start.GetLine())) 336 if err != nil { 337 return "", err 338 } 339 endLineContent, err := getSourceLine(sourceLines, int(end.GetLine())) 340 if err != nil { 341 return "", err 342 } 343 344 txt := startLineContent[:max(start.GetColumn()-1, 0)] + s.GetText() + endLineContent[max(end.GetColumn()-1, 0):] 345 backticks := commentutil.GetCodeFenceLength(txt) 346 347 var sb strings.Builder 348 sb.Grow(backticks + len("suggestion\n") + len(txt) + len("\n") + backticks) 349 commentutil.WriteCodeFence(&sb, backticks) 350 sb.WriteString("suggestion\n") 351 sb.WriteString(txt) 352 sb.WriteString("\n") 353 commentutil.WriteCodeFence(&sb, backticks) 354 return sb.String(), nil 355 } 356 357 func getSourceLine(sourceLines map[int]string, line int) (string, error) { 358 lineContent, ok := sourceLines[line] 359 if !ok { 360 return "", fmt.Errorf("source line (L=%d) is not available for this suggestion", line) 361 } 362 return lineContent, nil 363 } 364 365 func max(x, y int32) int32 { 366 if x < y { 367 return y 368 } 369 return x 370 }