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