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