github.com/massongit/reviewdog@v0.0.0-20240331071725-4a16675475a8/service/github/github.go (about) 1 package github 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "log" 8 "net/http" 9 "os" 10 "os/exec" 11 "path/filepath" 12 "strings" 13 "sync" 14 15 "github.com/google/go-github/v60/github" 16 17 "github.com/reviewdog/reviewdog" 18 "github.com/reviewdog/reviewdog/cienv" 19 "github.com/reviewdog/reviewdog/proto/rdf" 20 "github.com/reviewdog/reviewdog/service/commentutil" 21 "github.com/reviewdog/reviewdog/service/github/githubutils" 22 "github.com/reviewdog/reviewdog/service/serviceutil" 23 ) 24 25 var _ reviewdog.CommentService = (*PullRequest)(nil) 26 var _ reviewdog.DiffService = (*PullRequest)(nil) 27 28 const maxCommentsPerRequest = 30 29 30 const ( 31 invalidSuggestionPre = "<details><summary>reviewdog suggestion error</summary>" 32 invalidSuggestionPost = "</details>" 33 ) 34 35 func isPermissionError(err error) bool { 36 var githubErr *github.ErrorResponse 37 if !errors.As(err, &githubErr) { 38 return false 39 } 40 status := githubErr.Response.StatusCode 41 return status == http.StatusForbidden || status == http.StatusNotFound 42 } 43 44 // PullRequest is a comment and diff service for GitHub PullRequest. 45 // 46 // API: 47 // 48 // https://docs.github.com/en/rest/pulls/comments?apiVersion=2022-11-28#create-a-review-comment-for-a-pull-request 49 // POST /repos/:owner/:repo/pulls/:number/comments 50 type PullRequest struct { 51 cli *github.Client 52 owner string 53 repo string 54 pr int 55 sha string 56 57 muComments sync.Mutex 58 postComments []*reviewdog.Comment 59 logWriter *githubutils.GitHubActionLogWriter 60 fallbackToLog bool 61 62 postedcs commentutil.PostedComments 63 64 // wd is working directory relative to root of repository. 65 wd string 66 } 67 68 // NewGitHubPullRequest returns a new PullRequest service. 69 // PullRequest service needs git command in $PATH. 70 // 71 // The GitHub Token generated by GitHub Actions may not have the necessary permissions. 72 // For example, in the case of a PR from a forked repository, or when write permission is prohibited in the repository settings [1]. 73 // 74 // In such a case, the service will fallback to GitHub Actions workflow commands [2]. 75 // 76 // [1]: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token 77 // [2]: https://docs.github.com/en/actions/reference/workflow-commands-for-github-actions 78 func NewGitHubPullRequest(cli *github.Client, owner, repo string, pr int, sha, level string) (*PullRequest, error) { 79 workDir, err := serviceutil.GitRelWorkdir() 80 if err != nil { 81 return nil, fmt.Errorf("PullRequest needs 'git' command: %w", err) 82 } 83 return &PullRequest{ 84 cli: cli, 85 owner: owner, 86 repo: repo, 87 pr: pr, 88 sha: sha, 89 logWriter: githubutils.NewGitHubActionLogWriter(level), 90 wd: workDir, 91 }, nil 92 } 93 94 // Post accepts a comment and holds it. Flush method actually posts comments to 95 // GitHub in parallel. 96 func (g *PullRequest) Post(_ context.Context, c *reviewdog.Comment) error { 97 c.Result.Diagnostic.GetLocation().Path = filepath.ToSlash(filepath.Join(g.wd, 98 c.Result.Diagnostic.GetLocation().GetPath())) 99 g.muComments.Lock() 100 defer g.muComments.Unlock() 101 g.postComments = append(g.postComments, c) 102 return nil 103 } 104 105 // Flush posts comments which has not been posted yet. 106 func (g *PullRequest) Flush(ctx context.Context) error { 107 g.muComments.Lock() 108 defer g.muComments.Unlock() 109 110 if err := g.setPostedComment(ctx); err != nil { 111 return err 112 } 113 return g.postAsReviewComment(ctx) 114 } 115 116 func (g *PullRequest) postAsReviewComment(ctx context.Context) error { 117 if g.fallbackToLog { 118 // we don't have permission to post a review comment. 119 // Fallback to GitHub Actions log as report. 120 for _, c := range g.postComments { 121 if err := g.logWriter.Post(ctx, c); err != nil { 122 return err 123 } 124 } 125 return g.logWriter.Flush(ctx) 126 } 127 128 postComments := g.postComments 129 g.postComments = nil 130 rawComments := make([]*reviewdog.Comment, 0, len(postComments)) 131 reviewComments := make([]*github.DraftReviewComment, 0, len(postComments)) 132 remaining := make([]*reviewdog.Comment, 0) 133 for _, c := range postComments { 134 if !c.Result.InDiffContext { 135 // GitHub Review API cannot report results outside diff. If it's running 136 // in GitHub Actions, fallback to GitHub Actions log as report. 137 if cienv.IsInGitHubAction() { 138 if err := g.logWriter.Post(ctx, c); err != nil { 139 return err 140 } 141 } 142 continue 143 } 144 body := buildBody(c) 145 if g.postedcs.IsPosted(c, githubCommentLine(c), body) { 146 // it's already posted. skip it. 147 continue 148 } 149 150 // Only posts maxCommentsPerRequest comments per 1 request to avoid spammy 151 // review comments. An example GitHub error if we don't limit the # of 152 // review comments. 153 // 154 // > 403 You have triggered an abuse detection mechanism and have been 155 // > temporarily blocked from content creation. Please retry your request 156 // > again later. 157 // https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#rate-limiting 158 if len(reviewComments) >= maxCommentsPerRequest { 159 remaining = append(remaining, c) 160 continue 161 } 162 reviewComments = append(reviewComments, buildDraftReviewComment(c, body)) 163 } 164 if err := g.logWriter.Flush(ctx); err != nil { 165 return err 166 } 167 168 if len(reviewComments) > 0 { 169 // send review comments to GitHub. 170 review := &github.PullRequestReviewRequest{ 171 CommitID: &g.sha, 172 Event: github.String("COMMENT"), 173 Comments: reviewComments, 174 Body: github.String(g.remainingCommentsSummary(remaining)), 175 } 176 _, _, err := g.cli.PullRequests.CreateReview(ctx, g.owner, g.repo, g.pr, review) 177 if err != nil { 178 log.Printf("reviewdog: failed to post a review comment: %v", err) 179 // GitHub returns 403 or 404 if we don't have permission to post a review comment. 180 // fallback to log message in this case. 181 if isPermissionError(err) && cienv.IsInGitHubAction() { 182 goto FALLBACK 183 } 184 return err 185 } 186 } 187 188 return nil 189 190 FALLBACK: 191 // fallback to GitHub Actions log as report. 192 fmt.Fprintln(os.Stderr, `reviewdog: This GitHub Token doesn't have write permission of Review API [1], 193 so reviewdog will report results via logging command [2] and create annotations similar to 194 github-pr-check reporter as a fallback. 195 [1]: https://docs.github.com/en/actions/reference/events-that-trigger-workflows#pull_request_target 196 [2]: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions`) 197 g.fallbackToLog = true 198 199 for _, c := range rawComments { 200 if err := g.logWriter.Post(ctx, c); err != nil { 201 return err 202 } 203 } 204 return g.logWriter.Flush(ctx) 205 } 206 207 // Document: https://docs.github.com/en/rest/reference/pulls#create-a-review-comment-for-a-pull-request 208 func buildDraftReviewComment(c *reviewdog.Comment, body string) *github.DraftReviewComment { 209 loc := c.Result.Diagnostic.GetLocation() 210 startLine, endLine := githubCommentLineRange(c) 211 r := &github.DraftReviewComment{ 212 Path: github.String(loc.GetPath()), 213 Side: github.String("RIGHT"), 214 Body: github.String(body), 215 Line: github.Int(endLine), 216 } 217 // GitHub API: Start line must precede the end line. 218 if startLine < endLine { 219 r.StartSide = github.String("RIGHT") 220 r.StartLine = github.Int(startLine) 221 } 222 return r 223 } 224 225 // line represents end line if it's a multiline comment in GitHub, otherwise 226 // it's start line. 227 // Document: https://docs.github.com/en/rest/reference/pulls#create-a-review-comment-for-a-pull-request 228 func githubCommentLine(c *reviewdog.Comment) int { 229 if !c.Result.InDiffContext { 230 return 0 231 } 232 _, end := githubCommentLineRange(c) 233 return end 234 } 235 236 func githubCommentLineRange(c *reviewdog.Comment) (start int, end int) { 237 // Prefer first suggestion line range to diagnostic location if available so 238 // that reviewdog can post code suggestion as well when the line ranges are 239 // different between the diagnostic location and its suggestion. 240 if c.Result.FirstSuggestionInDiffContext && len(c.Result.Diagnostic.GetSuggestions()) > 0 { 241 s := c.Result.Diagnostic.GetSuggestions()[0] 242 startLine := s.GetRange().GetStart().GetLine() 243 endLine := s.GetRange().GetEnd().GetLine() 244 if endLine == 0 { 245 endLine = startLine 246 } 247 return int(startLine), int(endLine) 248 } 249 loc := c.Result.Diagnostic.GetLocation() 250 startLine := loc.GetRange().GetStart().GetLine() 251 endLine := loc.GetRange().GetEnd().GetLine() 252 if endLine == 0 { 253 endLine = startLine 254 } 255 return int(startLine), int(endLine) 256 } 257 258 func (g *PullRequest) remainingCommentsSummary(remaining []*reviewdog.Comment) string { 259 if len(remaining) == 0 { 260 return "" 261 } 262 perTool := make(map[string][]*reviewdog.Comment) 263 for _, c := range remaining { 264 perTool[c.ToolName] = append(perTool[c.ToolName], c) 265 } 266 var sb strings.Builder 267 sb.WriteString("Remaining comments which cannot be posted as a review comment to avoid GitHub Rate Limit\n") 268 sb.WriteString("\n") 269 for tool, comments := range perTool { 270 sb.WriteString("<details>\n") 271 sb.WriteString(fmt.Sprintf("<summary>%s</summary>\n", tool)) 272 sb.WriteString("\n") 273 for _, c := range comments { 274 sb.WriteString(githubutils.LinkedMarkdownDiagnostic(g.owner, g.repo, g.sha, c.Result.Diagnostic)) 275 sb.WriteString("\n") 276 } 277 sb.WriteString("</details>\n") 278 } 279 return sb.String() 280 } 281 282 // setPostedComment get posted comments from GitHub. 283 func (g *PullRequest) setPostedComment(ctx context.Context) error { 284 g.postedcs = make(commentutil.PostedComments) 285 cs, err := g.comment(ctx) 286 if err != nil { 287 return err 288 } 289 for _, c := range cs { 290 if c.Line == nil || c.Path == nil || c.Body == nil || c.SubjectType == nil { 291 continue 292 } 293 var line int 294 if c.GetSubjectType() == "line" { 295 line = c.GetLine() 296 } 297 g.postedcs.AddPostedComment(c.GetPath(), line, c.GetBody()) 298 } 299 return nil 300 } 301 302 // Diff returns a diff of PullRequest. 303 func (g *PullRequest) Diff(ctx context.Context) ([]byte, error) { 304 d, _, err := g.cli.PullRequests.Get(ctx, g.owner, g.repo, g.pr) 305 if err != nil { 306 return nil, err 307 } 308 309 bytes, err := exec.Command("git", "diff", "--find-renames", d.GetHead().GetSHA(), d.GetBase().GetSHA()).Output() 310 if err != nil { 311 return nil, fmt.Errorf("failed to run git diff: %w", err) 312 } 313 314 return bytes, nil 315 } 316 317 // Strip returns 1 as a strip of git diff. 318 func (g *PullRequest) Strip() int { 319 return 1 320 } 321 322 func (g *PullRequest) comment(ctx context.Context) ([]*github.PullRequestComment, error) { 323 // https://developer.github.com/v3/guides/traversing-with-pagination/ 324 opts := &github.PullRequestListCommentsOptions{ 325 ListOptions: github.ListOptions{ 326 PerPage: 100, 327 }, 328 } 329 comments, err := listAllPullRequestsComments(ctx, g.cli, g.owner, g.repo, g.pr, opts) 330 if err != nil { 331 return nil, err 332 } 333 return comments, nil 334 } 335 336 func listAllPullRequestsComments(ctx context.Context, cli *github.Client, 337 owner, repo string, pr int, opts *github.PullRequestListCommentsOptions) ([]*github.PullRequestComment, error) { 338 comments, resp, err := cli.PullRequests.ListComments(ctx, owner, repo, pr, opts) 339 if err != nil { 340 return nil, err 341 } 342 if resp.NextPage == 0 { 343 return comments, nil 344 } 345 newOpts := &github.PullRequestListCommentsOptions{ 346 ListOptions: github.ListOptions{ 347 Page: resp.NextPage, 348 PerPage: opts.PerPage, 349 }, 350 } 351 restComments, err := listAllPullRequestsComments(ctx, cli, owner, repo, pr, newOpts) 352 if err != nil { 353 return nil, err 354 } 355 return append(comments, restComments...), nil 356 } 357 358 func buildBody(c *reviewdog.Comment) string { 359 cbody := commentutil.MarkdownComment(c) 360 if suggestion := buildSuggestions(c); suggestion != "" { 361 cbody += "\n" + suggestion 362 } 363 return cbody 364 } 365 366 func buildSuggestions(c *reviewdog.Comment) string { 367 var sb strings.Builder 368 for _, s := range c.Result.Diagnostic.GetSuggestions() { 369 txt, err := buildSingleSuggestion(c, s) 370 if err != nil { 371 sb.WriteString(invalidSuggestionPre + err.Error() + invalidSuggestionPost + "\n") 372 continue 373 } 374 sb.WriteString(txt) 375 sb.WriteString("\n") 376 } 377 return sb.String() 378 } 379 380 func buildSingleSuggestion(c *reviewdog.Comment, s *rdf.Suggestion) (string, error) { 381 start := s.GetRange().GetStart() 382 startLine := int(start.GetLine()) 383 end := s.GetRange().GetEnd() 384 endLine := int(end.GetLine()) 385 if endLine == 0 { 386 endLine = startLine 387 } 388 gStart, gEnd := githubCommentLineRange(c) 389 if startLine != gStart || endLine != gEnd { 390 return "", fmt.Errorf("GitHub comment range and suggestion line range must be same. L%d-L%d v.s. L%d-L%d", 391 gStart, gEnd, startLine, endLine) 392 } 393 if start.GetColumn() > 0 || end.GetColumn() > 0 { 394 return buildNonLineBasedSuggestion(c, s) 395 } 396 397 txt := s.GetText() 398 backticks := commentutil.GetCodeFenceLength(txt) 399 400 var sb strings.Builder 401 sb.Grow(backticks + len("suggestion\n") + len(txt) + len("\n") + backticks) 402 commentutil.WriteCodeFence(&sb, backticks) 403 sb.WriteString("suggestion\n") 404 if txt != "" { 405 sb.WriteString(txt) 406 sb.WriteString("\n") 407 } 408 commentutil.WriteCodeFence(&sb, backticks) 409 return sb.String(), nil 410 } 411 412 func buildNonLineBasedSuggestion(c *reviewdog.Comment, s *rdf.Suggestion) (string, error) { 413 sourceLines := c.Result.SourceLines 414 if len(sourceLines) == 0 { 415 return "", errors.New("source lines are not available") 416 } 417 start := s.GetRange().GetStart() 418 end := s.GetRange().GetEnd() 419 startLineContent, err := getSourceLine(sourceLines, int(start.GetLine())) 420 if err != nil { 421 return "", err 422 } 423 endLineContent, err := getSourceLine(sourceLines, int(end.GetLine())) 424 if err != nil { 425 return "", err 426 } 427 428 txt := startLineContent[:max(start.GetColumn()-1, 0)] + s.GetText() + endLineContent[max(end.GetColumn()-1, 0):] 429 backticks := commentutil.GetCodeFenceLength(txt) 430 431 var sb strings.Builder 432 sb.Grow(backticks + len("suggestion\n") + len(txt) + len("\n") + backticks) 433 commentutil.WriteCodeFence(&sb, backticks) 434 sb.WriteString("suggestion\n") 435 sb.WriteString(txt) 436 sb.WriteString("\n") 437 commentutil.WriteCodeFence(&sb, backticks) 438 return sb.String(), nil 439 } 440 441 func getSourceLine(sourceLines map[int]string, line int) (string, error) { 442 lineContent, ok := sourceLines[line] 443 if !ok { 444 return "", fmt.Errorf("source line (L=%d) is not available for this suggestion", line) 445 } 446 return lineContent, nil 447 }