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