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