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  }