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  }