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