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