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