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