github.com/friedemannf/reviewdog@v0.14.0/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/v37/github"
    12  
    13  	"github.com/friedemannf/reviewdog/diff"
    14  	"github.com/friedemannf/reviewdog/doghouse"
    15  	"github.com/friedemannf/reviewdog/filter"
    16  	"github.com/friedemannf/reviewdog/proto/rdf"
    17  	"github.com/friedemannf/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 maxFilteredFinding = 150
    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  	lines = append(lines, "reported by [reviewdog](https://github.com/friedemannf/reviewdog) :dog:")
   200  
   201  	var findings []*filter.FilteredDiagnostic
   202  	var filteredFindings []*filter.FilteredDiagnostic
   203  	for _, c := range checks {
   204  		if c.ShouldReport {
   205  			findings = append(findings, c)
   206  		} else {
   207  			filteredFindings = append(filteredFindings, c)
   208  		}
   209  	}
   210  	lines = append(lines, ch.summaryFindings("Findings", findings)...)
   211  	lines = append(lines, ch.summaryFindings("Filtered Findings", filteredFindings)...)
   212  
   213  	return strings.Join(lines, "\n")
   214  }
   215  
   216  func (ch *Checker) summaryFindings(name string, checks []*filter.FilteredDiagnostic) []string {
   217  	var lines []string
   218  	lines = append(lines, "<details>")
   219  	lines = append(lines, fmt.Sprintf("<summary>%s (%d)</summary>", name, len(checks)))
   220  	lines = append(lines, "")
   221  	for i, c := range checks {
   222  		if i >= maxFilteredFinding {
   223  			lines = append(lines, "... (Too many findings. Dropped some findings)")
   224  			break
   225  		}
   226  		lines = append(lines, githubutils.LinkedMarkdownDiagnostic(
   227  			ch.req.Owner, ch.req.Repo, ch.req.SHA, c.Diagnostic))
   228  	}
   229  	lines = append(lines, "</details>")
   230  	return lines
   231  }
   232  
   233  func (ch *Checker) toCheckRunAnnotation(c *filter.FilteredDiagnostic) *github.CheckRunAnnotation {
   234  	loc := c.Diagnostic.GetLocation()
   235  	startLine := int(loc.GetRange().GetStart().GetLine())
   236  	endLine := int(loc.GetRange().GetEnd().GetLine())
   237  	if endLine == 0 {
   238  		endLine = startLine
   239  	}
   240  	a := &github.CheckRunAnnotation{
   241  		Path:            github.String(loc.GetPath()),
   242  		StartLine:       github.Int(startLine),
   243  		EndLine:         github.Int(endLine),
   244  		AnnotationLevel: github.String(ch.annotationLevel(c.Diagnostic.Severity)),
   245  		Message:         github.String(c.Diagnostic.GetMessage()),
   246  		Title:           github.String(ch.buildTitle(c)),
   247  	}
   248  	// Annotations only support start_column and end_column on the same line.
   249  	if startLine == endLine {
   250  		if s, e := loc.GetRange().GetStart().GetColumn(), loc.GetRange().GetEnd().GetColumn(); s != 0 && e != 0 {
   251  			a.StartColumn = github.Int(int(s))
   252  			a.EndColumn = github.Int(int(e))
   253  		}
   254  	}
   255  	if s := c.Diagnostic.GetOriginalOutput(); s != "" {
   256  		a.RawDetails = github.String(s)
   257  	}
   258  	return a
   259  }
   260  
   261  func (ch *Checker) buildTitle(c *filter.FilteredDiagnostic) string {
   262  	var sb strings.Builder
   263  	toolName := c.Diagnostic.GetSource().GetName()
   264  	if toolName == "" {
   265  		toolName = ch.req.Name
   266  	}
   267  	if toolName != "" {
   268  		sb.WriteString(fmt.Sprintf("[%s] ", toolName))
   269  	}
   270  	loc := c.Diagnostic.GetLocation()
   271  	sb.WriteString(loc.GetPath())
   272  	if startLine := int(loc.GetRange().GetStart().GetLine()); startLine > 0 {
   273  		sb.WriteString(fmt.Sprintf("#L%d", startLine))
   274  		if endLine := int(loc.GetRange().GetEnd().GetLine()); startLine < endLine {
   275  			sb.WriteString(fmt.Sprintf("-L%d", endLine))
   276  		}
   277  	}
   278  	if code := c.Diagnostic.GetCode().GetValue(); code != "" {
   279  		if url := c.Diagnostic.GetCode().GetUrl(); url != "" {
   280  			sb.WriteString(fmt.Sprintf(" <%s>(%s)", code, url))
   281  		} else {
   282  			sb.WriteString(fmt.Sprintf(" <%s>", code))
   283  		}
   284  	}
   285  	return sb.String()
   286  }
   287  
   288  func (ch *Checker) pullRequestDiff(ctx context.Context, pr int) ([]*diff.FileDiff, error) {
   289  	d, err := ch.rawPullRequestDiff(ctx, pr)
   290  	if err != nil {
   291  		return nil, err
   292  	}
   293  	filediffs, err := diff.ParseMultiFile(bytes.NewReader(d))
   294  	if err != nil {
   295  		return nil, fmt.Errorf("fail to parse diff: %w", err)
   296  	}
   297  	return filediffs, nil
   298  }
   299  
   300  func (ch *Checker) rawPullRequestDiff(ctx context.Context, pr int) ([]byte, error) {
   301  	d, err := ch.gh.GetPullRequestDiff(ctx, ch.req.Owner, ch.req.Repo, pr)
   302  	if err != nil {
   303  		return nil, err
   304  	}
   305  	return d, nil
   306  }
   307  
   308  func annotationsToDiagnostics(as []*doghouse.Annotation) []*rdf.Diagnostic {
   309  	ds := make([]*rdf.Diagnostic, 0, len(as))
   310  	for _, a := range as {
   311  		ds = append(ds, annotationToDiagnostic(a))
   312  	}
   313  	return ds
   314  }
   315  
   316  func annotationToDiagnostic(a *doghouse.Annotation) *rdf.Diagnostic {
   317  	if a.Diagnostic != nil {
   318  		return a.Diagnostic
   319  	}
   320  	// Old reviwedog CLI doesn't have the Diagnostic field.
   321  	return &rdf.Diagnostic{
   322  		Location: &rdf.Location{
   323  			Path: a.Path,
   324  			Range: &rdf.Range{
   325  				Start: &rdf.Position{
   326  					Line: int32(a.Line),
   327  				},
   328  			},
   329  		},
   330  		Message:        a.Message,
   331  		OriginalOutput: a.RawMessage,
   332  	}
   333  }
   334  
   335  func min(x, y int) int {
   336  	if x > y {
   337  		return y
   338  	}
   339  	return x
   340  }