github.com/mistwind/reviewdog@v0.0.0-20230322024206-9cfa11856d58/doghouse/server/doghouse.go (about)

     1  package server
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"net/http"
     8  	"strings"
     9  	"time"
    10  
    11  	"github.com/google/go-github/v39/github"
    12  
    13  	"github.com/mistwind/reviewdog/diff"
    14  	"github.com/mistwind/reviewdog/doghouse"
    15  	"github.com/mistwind/reviewdog/filter"
    16  	"github.com/mistwind/reviewdog/proto/rdf"
    17  	"github.com/mistwind/reviewdog/service/github/githubutils"
    18  )
    19  
    20  // GitHub check runs API cannot handle too large requests.
    21  // Set max number of filtered findings to be shown in check-run summary.
    22  // ERROR:
    23  //  https://api.github.com/repos/easymotion/vim-easymotion/check-runs: 422
    24  //  Invalid request.
    25  //  Only 65535 characters are allowed; 250684 were supplied. []
    26  const maxAllowedSize = 65535
    27  
    28  // > The Checks API limits the number of annotations to a maximum of 50 per API
    29  // > request.
    30  // https://developer.github.com/v3/checks/runs/#output-object
    31  const maxAnnotationsPerRequest = 50
    32  
    33  type Checker struct {
    34  	req *doghouse.CheckRequest
    35  	gh  checkerGitHubClientInterface
    36  }
    37  
    38  func NewChecker(req *doghouse.CheckRequest, gh *github.Client) *Checker {
    39  	return &Checker{req: req, gh: &checkerGitHubClient{Client: gh}}
    40  }
    41  
    42  func (ch *Checker) Check(ctx context.Context) (*doghouse.CheckResponse, error) {
    43  	var filediffs []*diff.FileDiff
    44  	if ch.req.PullRequest != 0 {
    45  		var err error
    46  		filediffs, err = ch.pullRequestDiff(ctx, ch.req.PullRequest)
    47  		if err != nil {
    48  			return nil, fmt.Errorf("fail to parse diff: %w", err)
    49  		}
    50  	}
    51  
    52  	results := annotationsToDiagnostics(ch.req.Annotations)
    53  	filterMode := ch.req.FilterMode
    54  	//lint:ignore SA1019 Need to support OutsideDiff for backward compatibility.
    55  	if ch.req.PullRequest == 0 || ch.req.OutsideDiff {
    56  		// If it's not Pull Request run, do not filter results by diff regardless
    57  		// of the filter mode.
    58  		filterMode = filter.ModeNoFilter
    59  	}
    60  	filtered := filter.FilterCheck(results, filediffs, 1, "", filterMode)
    61  	check, err := ch.createCheck(ctx)
    62  	if err != nil {
    63  		// If this error is StatusForbidden (403) here, it means reviewdog is
    64  		// running on GitHub Actions and has only read permission (because it's
    65  		// running for Pull Requests from forked repository). If the token itself
    66  		// is invalid, reviewdog should return an error earlier (e.g. when reading
    67  		// Pull Requests diff), so it should be ok not to return error here and
    68  		// return results instead.
    69  		if err, ok := err.(*github.ErrorResponse); ok && err.Response.StatusCode == http.StatusForbidden {
    70  			return &doghouse.CheckResponse{CheckedResults: filtered}, nil
    71  		}
    72  		return nil, fmt.Errorf("failed to create check: %w", err)
    73  	}
    74  
    75  	checkRun, conclusion, err := ch.postCheck(ctx, check.GetID(), filtered)
    76  	if err != nil {
    77  		return nil, fmt.Errorf("failed to post result: %w", err)
    78  	}
    79  	res := &doghouse.CheckResponse{
    80  		ReportURL:  checkRun.GetHTMLURL(),
    81  		Conclusion: conclusion,
    82  	}
    83  	return res, nil
    84  }
    85  
    86  func (ch *Checker) postCheck(ctx context.Context, checkID int64, checks []*filter.FilteredDiagnostic) (*github.CheckRun, string, error) {
    87  	var annotations []*github.CheckRunAnnotation
    88  	for _, c := range checks {
    89  		if !c.ShouldReport {
    90  			continue
    91  		}
    92  		annotations = append(annotations, ch.toCheckRunAnnotation(c))
    93  	}
    94  	if len(annotations) > 0 {
    95  		if err := ch.postAnnotations(ctx, checkID, annotations); err != nil {
    96  			return nil, "", fmt.Errorf("failed to post annotations: %w", err)
    97  		}
    98  	}
    99  
   100  	conclusion := "success"
   101  	if len(annotations) > 0 {
   102  		conclusion = ch.conclusion()
   103  	}
   104  	opt := github.UpdateCheckRunOptions{
   105  		Name:        ch.checkName(),
   106  		Status:      github.String("completed"),
   107  		Conclusion:  github.String(conclusion),
   108  		CompletedAt: &github.Timestamp{Time: time.Now()},
   109  		Output: &github.CheckRunOutput{
   110  			Title:   github.String(ch.checkTitle()),
   111  			Summary: github.String(ch.summary(checks)),
   112  		},
   113  	}
   114  	checkRun, err := ch.gh.UpdateCheckRun(ctx, ch.req.Owner, ch.req.Repo, checkID, opt)
   115  	if err != nil {
   116  		return nil, "", err
   117  	}
   118  	return checkRun, conclusion, nil
   119  }
   120  
   121  func (ch *Checker) createCheck(ctx context.Context) (*github.CheckRun, error) {
   122  	opt := github.CreateCheckRunOptions{
   123  		Name:    ch.checkName(),
   124  		HeadSHA: ch.req.SHA,
   125  		Status:  github.String("in_progress"),
   126  	}
   127  	return ch.gh.CreateCheckRun(ctx, ch.req.Owner, ch.req.Repo, opt)
   128  }
   129  
   130  func (ch *Checker) postAnnotations(ctx context.Context, checkID int64, annotations []*github.CheckRunAnnotation) error {
   131  	opt := github.UpdateCheckRunOptions{
   132  		Name: ch.checkName(),
   133  		Output: &github.CheckRunOutput{
   134  			Title:       github.String(ch.checkTitle()),
   135  			Summary:     github.String(""), // Post summary with the last request.
   136  			Annotations: annotations[:min(maxAnnotationsPerRequest, len(annotations))],
   137  		},
   138  	}
   139  	if _, err := ch.gh.UpdateCheckRun(ctx, ch.req.Owner, ch.req.Repo, checkID, opt); err != nil {
   140  		return err
   141  	}
   142  	if len(annotations) > maxAnnotationsPerRequest {
   143  		return ch.postAnnotations(ctx, checkID, annotations[maxAnnotationsPerRequest:])
   144  	}
   145  	return nil
   146  }
   147  
   148  func (ch *Checker) checkName() string {
   149  	if ch.req.Name != "" {
   150  		return ch.req.Name
   151  	}
   152  	return "reviewdog"
   153  }
   154  
   155  func (ch *Checker) checkTitle() string {
   156  	if name := ch.checkName(); name != "reviewdog" {
   157  		return fmt.Sprintf("reviewdog [%s] report", name)
   158  	}
   159  	return "reviewdog report"
   160  }
   161  
   162  // https://developer.github.com/v3/checks/runs/#parameters-1
   163  func (ch *Checker) conclusion() string {
   164  	switch strings.ToLower(ch.req.Level) {
   165  	case "info", "warning":
   166  		return "neutral"
   167  	}
   168  	return "failure"
   169  }
   170  
   171  // https://developer.github.com/v3/checks/runs/#annotations-object
   172  func (ch *Checker) annotationLevel(s rdf.Severity) string {
   173  	switch s {
   174  	case rdf.Severity_ERROR:
   175  		return "failure"
   176  	case rdf.Severity_WARNING:
   177  		return "warning"
   178  	case rdf.Severity_INFO:
   179  		return "notice"
   180  	default:
   181  		return ch.reqAnnotationLevel()
   182  	}
   183  }
   184  
   185  func (ch *Checker) reqAnnotationLevel() string {
   186  	switch strings.ToLower(ch.req.Level) {
   187  	case "info":
   188  		return "notice"
   189  	case "warning":
   190  		return "warning"
   191  	case "failure":
   192  		return "failure"
   193  	}
   194  	return "failure"
   195  }
   196  
   197  func (ch *Checker) summary(checks []*filter.FilteredDiagnostic) string {
   198  	var lines []string
   199  	var usedBytes int
   200  	lines = append(lines, "reported by [reviewdog](https://github.com/mistwind/reviewdog) :dog:")
   201  	usedBytes += len(lines[0]) + 1
   202  	var findings []*filter.FilteredDiagnostic
   203  	var filteredFindings []*filter.FilteredDiagnostic
   204  	for _, c := range checks {
   205  		if c.ShouldReport {
   206  			findings = append(findings, c)
   207  		} else {
   208  			filteredFindings = append(filteredFindings, c)
   209  		}
   210  	}
   211  
   212  	findingMsgs, usedBytes := ch.summaryFindings("Findings", usedBytes, findings)
   213  	lines = append(lines, findingMsgs...)
   214  	filteredFindingsMsgs, _ := ch.summaryFindings("Filtered Findings", usedBytes, filteredFindings)
   215  	lines = append(lines, filteredFindingsMsgs...)
   216  	return strings.Join(lines, "\n")
   217  }
   218  
   219  func (ch *Checker) summaryFindings(name string, usedBytes int, checks []*filter.FilteredDiagnostic) ([]string, int) {
   220  	var lines []string
   221  	lines = append(lines, fmt.Sprintf("<details>\n<summary>%s (%d)</summary>\n", name, len(checks)))
   222  	if len(lines[0])+1+usedBytes > maxAllowedSize {
   223  		// bail out if we're already over the limit
   224  		return nil, usedBytes
   225  	}
   226  	usedBytes += len(lines[0]) + 1
   227  	for _, c := range checks {
   228  		nextLine := githubutils.LinkedMarkdownDiagnostic(ch.req.Owner, ch.req.Repo, ch.req.SHA, c.Diagnostic)
   229  		// existing lines + newline + closing details tag must be smaller than the max allowed size
   230  		if usedBytes+len(nextLine)+1+10 >= maxAllowedSize {
   231  			cutoffMsg := "... (Too many findings. Dropped some findings)"
   232  			if usedBytes+len(cutoffMsg)+1+10 <= maxAllowedSize {
   233  				lines = append(lines, cutoffMsg)
   234  				usedBytes += len(cutoffMsg) + 1
   235  			}
   236  			break
   237  		}
   238  		lines = append(lines, nextLine)
   239  		usedBytes += len(nextLine) + 1
   240  	}
   241  	lines = append(lines, "</details>")
   242  	usedBytes += 10 + 1
   243  	return lines, usedBytes
   244  }
   245  
   246  func (ch *Checker) toCheckRunAnnotation(c *filter.FilteredDiagnostic) *github.CheckRunAnnotation {
   247  	loc := c.Diagnostic.GetLocation()
   248  	startLine := int(loc.GetRange().GetStart().GetLine())
   249  	endLine := int(loc.GetRange().GetEnd().GetLine())
   250  	if endLine == 0 {
   251  		endLine = startLine
   252  	}
   253  	a := &github.CheckRunAnnotation{
   254  		Path:            github.String(loc.GetPath()),
   255  		StartLine:       github.Int(startLine),
   256  		EndLine:         github.Int(endLine),
   257  		AnnotationLevel: github.String(ch.annotationLevel(c.Diagnostic.Severity)),
   258  		Message:         github.String(c.Diagnostic.GetMessage()),
   259  		Title:           github.String(ch.buildTitle(c)),
   260  	}
   261  	// Annotations only support start_column and end_column on the same line.
   262  	if startLine == endLine {
   263  		if s, e := loc.GetRange().GetStart().GetColumn(), loc.GetRange().GetEnd().GetColumn(); s != 0 && e != 0 {
   264  			a.StartColumn = github.Int(int(s))
   265  			a.EndColumn = github.Int(int(e))
   266  		}
   267  	}
   268  	if s := c.Diagnostic.GetOriginalOutput(); s != "" {
   269  		a.RawDetails = github.String(s)
   270  	}
   271  	return a
   272  }
   273  
   274  func (ch *Checker) buildTitle(c *filter.FilteredDiagnostic) string {
   275  	var sb strings.Builder
   276  	toolName := c.Diagnostic.GetSource().GetName()
   277  	if toolName == "" {
   278  		toolName = ch.req.Name
   279  	}
   280  	if toolName != "" {
   281  		sb.WriteString(fmt.Sprintf("[%s] ", toolName))
   282  	}
   283  	loc := c.Diagnostic.GetLocation()
   284  	sb.WriteString(loc.GetPath())
   285  	if startLine := int(loc.GetRange().GetStart().GetLine()); startLine > 0 {
   286  		sb.WriteString(fmt.Sprintf("#L%d", startLine))
   287  		if endLine := int(loc.GetRange().GetEnd().GetLine()); startLine < endLine {
   288  			sb.WriteString(fmt.Sprintf("-L%d", endLine))
   289  		}
   290  	}
   291  	if code := c.Diagnostic.GetCode().GetValue(); code != "" {
   292  		if url := c.Diagnostic.GetCode().GetUrl(); url != "" {
   293  			sb.WriteString(fmt.Sprintf(" <%s>(%s)", code, url))
   294  		} else {
   295  			sb.WriteString(fmt.Sprintf(" <%s>", code))
   296  		}
   297  	}
   298  	return sb.String()
   299  }
   300  
   301  func (ch *Checker) pullRequestDiff(ctx context.Context, pr int) ([]*diff.FileDiff, error) {
   302  	d, err := ch.rawPullRequestDiff(ctx, pr)
   303  	if err != nil {
   304  		return nil, err
   305  	}
   306  	filediffs, err := diff.ParseMultiFile(bytes.NewReader(d))
   307  	if err != nil {
   308  		return nil, fmt.Errorf("fail to parse diff: %w", err)
   309  	}
   310  	return filediffs, nil
   311  }
   312  
   313  func (ch *Checker) rawPullRequestDiff(ctx context.Context, pr int) ([]byte, error) {
   314  	d, err := ch.gh.GetPullRequestDiff(ctx, ch.req.Owner, ch.req.Repo, pr)
   315  	if err != nil {
   316  		return nil, err
   317  	}
   318  	return d, nil
   319  }
   320  
   321  func annotationsToDiagnostics(as []*doghouse.Annotation) []*rdf.Diagnostic {
   322  	ds := make([]*rdf.Diagnostic, 0, len(as))
   323  	for _, a := range as {
   324  		ds = append(ds, annotationToDiagnostic(a))
   325  	}
   326  	return ds
   327  }
   328  
   329  func annotationToDiagnostic(a *doghouse.Annotation) *rdf.Diagnostic {
   330  	if a.Diagnostic != nil {
   331  		return a.Diagnostic
   332  	}
   333  	// Old reviewdog CLI doesn't have the Diagnostic field.
   334  	return &rdf.Diagnostic{
   335  		Location: &rdf.Location{
   336  			//lint:ignore SA1019 use deprecated fields because of backward compatibility.
   337  			Path: a.Path,
   338  			Range: &rdf.Range{
   339  				Start: &rdf.Position{
   340  					//lint:ignore SA1019 use deprecated fields because of backward compatibility.
   341  					Line: int32(a.Line),
   342  				},
   343  			},
   344  		},
   345  		//lint:ignore SA1019 use deprecated fields because of backward compatibility.
   346  		Message: a.Message,
   347  		//lint:ignore SA1019 use deprecated fields because of backward compatibility.
   348  		OriginalOutput: a.RawMessage,
   349  	}
   350  }
   351  
   352  func min(x, y int) int {
   353  	if x > y {
   354  		return y
   355  	}
   356  	return x
   357  }