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