github.com/reviewdog/reviewdog@v0.17.5-0.20240516205324-0cd103a83d58/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 opt := github.RawOptions{Type: github.Diff} 305 d, resp, err := g.cli.PullRequests.GetRaw(ctx, g.owner, g.repo, g.pr, opt) 306 if err != nil { 307 if resp != nil && resp.StatusCode == http.StatusNotAcceptable { 308 log.Print("fallback to use git command") 309 return g.diffUsingGitCommand(ctx) 310 } 311 312 return nil, err 313 } 314 return []byte(d), nil 315 } 316 317 // diffUsingGitCommand returns a diff of PullRequest using git command. 318 func (g *PullRequest) diffUsingGitCommand(ctx context.Context) ([]byte, error) { 319 pr, _, err := g.cli.PullRequests.Get(ctx, g.owner, g.repo, g.pr) 320 if err != nil { 321 return nil, err 322 } 323 324 head := pr.GetHead() 325 headSha := head.GetSHA() 326 327 commitsComparison, _, err := g.cli.Repositories.CompareCommits(ctx, g.owner, g.repo, headSha, pr.GetBase().GetSHA(), nil) 328 if err != nil { 329 return nil, err 330 } 331 332 mergeBaseSha := commitsComparison.GetMergeBaseCommit().GetSHA() 333 334 if os.Getenv("REVIEWDOG_SKIP_GIT_FETCH") != "true" { 335 for _, sha := range []string{mergeBaseSha, headSha} { 336 _, err := exec.Command("git", "fetch", "--depth=1", head.GetRepo().GetHTMLURL(), sha).CombinedOutput() 337 if err != nil { 338 return nil, fmt.Errorf("failed to run git fetch: %w", err) 339 } 340 } 341 } 342 343 bytes, err := exec.Command("git", "diff", "--find-renames", mergeBaseSha, headSha).CombinedOutput() 344 if err != nil { 345 return nil, fmt.Errorf("failed to run git diff: %w", err) 346 } 347 348 return bytes, nil 349 } 350 351 // Strip returns 1 as a strip of git diff. 352 func (g *PullRequest) Strip() int { 353 return 1 354 } 355 356 func (g *PullRequest) comment(ctx context.Context) ([]*github.PullRequestComment, error) { 357 // https://developer.github.com/v3/guides/traversing-with-pagination/ 358 opts := &github.PullRequestListCommentsOptions{ 359 ListOptions: github.ListOptions{ 360 PerPage: 100, 361 }, 362 } 363 comments, err := listAllPullRequestsComments(ctx, g.cli, g.owner, g.repo, g.pr, opts) 364 if err != nil { 365 return nil, err 366 } 367 return comments, nil 368 } 369 370 func listAllPullRequestsComments(ctx context.Context, cli *github.Client, 371 owner, repo string, pr int, opts *github.PullRequestListCommentsOptions) ([]*github.PullRequestComment, error) { 372 comments, resp, err := cli.PullRequests.ListComments(ctx, owner, repo, pr, opts) 373 if err != nil { 374 return nil, err 375 } 376 if resp.NextPage == 0 { 377 return comments, nil 378 } 379 newOpts := &github.PullRequestListCommentsOptions{ 380 ListOptions: github.ListOptions{ 381 Page: resp.NextPage, 382 PerPage: opts.PerPage, 383 }, 384 } 385 restComments, err := listAllPullRequestsComments(ctx, cli, owner, repo, pr, newOpts) 386 if err != nil { 387 return nil, err 388 } 389 return append(comments, restComments...), nil 390 } 391 392 func buildBody(c *reviewdog.Comment) string { 393 cbody := commentutil.MarkdownComment(c) 394 if suggestion := buildSuggestions(c); suggestion != "" { 395 cbody += "\n" + suggestion 396 } 397 return cbody 398 } 399 400 func buildSuggestions(c *reviewdog.Comment) string { 401 var sb strings.Builder 402 for _, s := range c.Result.Diagnostic.GetSuggestions() { 403 txt, err := buildSingleSuggestion(c, s) 404 if err != nil { 405 sb.WriteString(invalidSuggestionPre + err.Error() + invalidSuggestionPost + "\n") 406 continue 407 } 408 sb.WriteString(txt) 409 sb.WriteString("\n") 410 } 411 return sb.String() 412 } 413 414 func buildSingleSuggestion(c *reviewdog.Comment, s *rdf.Suggestion) (string, error) { 415 start := s.GetRange().GetStart() 416 startLine := int(start.GetLine()) 417 end := s.GetRange().GetEnd() 418 endLine := int(end.GetLine()) 419 if endLine == 0 { 420 endLine = startLine 421 } 422 gStart, gEnd := githubCommentLineRange(c) 423 if startLine != gStart || endLine != gEnd { 424 return "", fmt.Errorf("GitHub comment range and suggestion line range must be same. L%d-L%d v.s. L%d-L%d", 425 gStart, gEnd, startLine, endLine) 426 } 427 if start.GetColumn() > 0 || end.GetColumn() > 0 { 428 return buildNonLineBasedSuggestion(c, s) 429 } 430 431 txt := s.GetText() 432 backticks := commentutil.GetCodeFenceLength(txt) 433 434 var sb strings.Builder 435 sb.Grow(backticks + len("suggestion\n") + len(txt) + len("\n") + backticks) 436 commentutil.WriteCodeFence(&sb, backticks) 437 sb.WriteString("suggestion\n") 438 if txt != "" { 439 sb.WriteString(txt) 440 sb.WriteString("\n") 441 } 442 commentutil.WriteCodeFence(&sb, backticks) 443 return sb.String(), nil 444 } 445 446 func buildNonLineBasedSuggestion(c *reviewdog.Comment, s *rdf.Suggestion) (string, error) { 447 sourceLines := c.Result.SourceLines 448 if len(sourceLines) == 0 { 449 return "", errors.New("source lines are not available") 450 } 451 start := s.GetRange().GetStart() 452 end := s.GetRange().GetEnd() 453 startLineContent, err := getSourceLine(sourceLines, int(start.GetLine())) 454 if err != nil { 455 return "", err 456 } 457 endLineContent, err := getSourceLine(sourceLines, int(end.GetLine())) 458 if err != nil { 459 return "", err 460 } 461 462 txt := startLineContent[:max(start.GetColumn()-1, 0)] + s.GetText() + endLineContent[max(end.GetColumn()-1, 0):] 463 backticks := commentutil.GetCodeFenceLength(txt) 464 465 var sb strings.Builder 466 sb.Grow(backticks + len("suggestion\n") + len(txt) + len("\n") + backticks) 467 commentutil.WriteCodeFence(&sb, backticks) 468 sb.WriteString("suggestion\n") 469 sb.WriteString(txt) 470 sb.WriteString("\n") 471 commentutil.WriteCodeFence(&sb, backticks) 472 return sb.String(), nil 473 } 474 475 func getSourceLine(sourceLines map[int]string, line int) (string, error) { 476 lineContent, ok := sourceLines[line] 477 if !ok { 478 return "", fmt.Errorf("source line (L=%d) is not available for this suggestion", line) 479 } 480 return lineContent, nil 481 }