github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/plugins/golint/golint.go (about)

     1  /*
     2  Copyright 2017 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package golint
    18  
    19  import (
    20  	"fmt"
    21  	"os"
    22  	"path/filepath"
    23  	"regexp"
    24  	"strconv"
    25  	"strings"
    26  	"time"
    27  
    28  	"github.com/sirupsen/logrus"
    29  	"golang.org/x/lint"
    30  
    31  	"sigs.k8s.io/prow/pkg/config"
    32  	"sigs.k8s.io/prow/pkg/genfiles"
    33  	"sigs.k8s.io/prow/pkg/git/v2"
    34  	"sigs.k8s.io/prow/pkg/github"
    35  	"sigs.k8s.io/prow/pkg/pluginhelp"
    36  	"sigs.k8s.io/prow/pkg/plugins"
    37  	"sigs.k8s.io/prow/pkg/plugins/golint/suggestion"
    38  )
    39  
    40  const (
    41  	pluginName  = "golint"
    42  	commentTag  = "<!-- golint -->"
    43  	maxComments = 20
    44  )
    45  
    46  var lintRe = regexp.MustCompile(`(?mi)^/lint\s*$`)
    47  
    48  func init() {
    49  	plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, helpProvider)
    50  }
    51  
    52  func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    53  	pointEight := 0.8
    54  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
    55  		Golint: plugins.Golint{
    56  			MinimumConfidence: &pointEight,
    57  		},
    58  	})
    59  	if err != nil {
    60  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", pluginName)
    61  	}
    62  	pluginHelp := &pluginhelp.PluginHelp{
    63  		Description: "The golint plugin runs golint on changes made to *.go files in a PR. It then creates a new review on the pull request and leaves golint warnings at the appropriate lines of code.",
    64  		Config: map[string]string{
    65  			"": fmt.Sprintf("The golint plugin will report problems with a minimum confidence of %f.", minConfidence(config.Golint)),
    66  		},
    67  		Snippet: yamlSnippet,
    68  	}
    69  	pluginHelp.AddCommand(pluginhelp.Command{
    70  		Usage:       "/lint",
    71  		Featured:    false,
    72  		Description: "Runs golint on changes made to *.go files in a PR",
    73  		WhoCanUse:   "Anyone can trigger this command on a PR.",
    74  		Examples:    []string{"/lint"},
    75  	})
    76  	return pluginHelp, nil
    77  }
    78  
    79  type githubClient interface {
    80  	GetFile(org, repo, filepath, commit string) ([]byte, error)
    81  	GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
    82  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
    83  	CreateReview(org, repo string, number int, r github.DraftReview) error
    84  	ListPullRequestComments(org, repo string, number int) ([]github.ReviewComment, error)
    85  }
    86  
    87  const defaultConfidence = 0.8
    88  
    89  func minConfidence(g plugins.Golint) float64 {
    90  	if g.MinimumConfidence == nil {
    91  		return defaultConfidence
    92  	}
    93  	return *g.MinimumConfidence
    94  }
    95  
    96  func handleGenericComment(pc plugins.Agent, e github.GenericCommentEvent) error {
    97  	return handle(minConfidence(pc.PluginConfig.Golint), pc.GitHubClient, pc.GitClient, pc.Logger, &e)
    98  }
    99  
   100  // modifiedGoFiles returns a map from filename to patch string for all go files
   101  // that are modified in the PR excluding vendor/ and generated files.
   102  func modifiedGoFiles(ghc githubClient, org, repo string, number int, sha string) (map[string]string, error) {
   103  	changes, err := ghc.GetPullRequestChanges(org, repo, number)
   104  	if err != nil {
   105  		return nil, err
   106  	}
   107  
   108  	gfg, err := genfiles.NewGroup(ghc, org, repo, sha)
   109  	if err != nil {
   110  		return nil, err
   111  	}
   112  
   113  	modifiedFiles := make(map[string]string)
   114  	for _, change := range changes {
   115  		switch {
   116  		case strings.HasPrefix(change.Filename, "vendor/"):
   117  			continue
   118  		case filepath.Ext(change.Filename) != ".go":
   119  			continue
   120  		case gfg.Match(change.Filename):
   121  			continue
   122  		case change.Status == github.PullRequestFileRemoved || change.Status == github.PullRequestFileRenamed:
   123  			continue
   124  		}
   125  		modifiedFiles[change.Filename] = change.Patch
   126  	}
   127  	return modifiedFiles, nil
   128  }
   129  
   130  // newProblems compares the list of problems with the list of past comments on
   131  // the PR to decide which are new.
   132  func newProblems(cs []github.ReviewComment, ps map[string]map[int]lint.Problem) map[string]map[int]lint.Problem {
   133  	// Make a copy, then remove the old elements.
   134  	res := make(map[string]map[int]lint.Problem)
   135  	for f, ls := range ps {
   136  		res[f] = make(map[int]lint.Problem)
   137  		for l, p := range ls {
   138  			res[f][l] = p
   139  		}
   140  	}
   141  	for _, c := range cs {
   142  		if c.Position == nil {
   143  			continue
   144  		}
   145  		if !strings.Contains(c.Body, commentTag) {
   146  			continue
   147  		}
   148  		delete(res[c.Path], *c.Position)
   149  	}
   150  	return res
   151  }
   152  
   153  // problemsInFiles runs golint on the files. It returns a map from the file to
   154  // a map from the line in the patch to the problem.
   155  func problemsInFiles(r git.RepoClient, files map[string]string) (map[string]map[int]lint.Problem, []github.DraftReviewComment) {
   156  	problems := make(map[string]map[int]lint.Problem)
   157  	var lintErrorComments []github.DraftReviewComment
   158  	l := new(lint.Linter)
   159  	for f, patch := range files {
   160  		problems[f] = make(map[int]lint.Problem)
   161  		src, err := os.ReadFile(filepath.Join(r.Directory(), f))
   162  		if err != nil {
   163  			lintErrorComments = append(lintErrorComments, github.DraftReviewComment{
   164  				Path: f,
   165  				Body: fmt.Sprintf("%v", err),
   166  			})
   167  		}
   168  		ps, err := l.Lint(f, src)
   169  		if err != nil {
   170  			// Get error line by parsing the error message
   171  			errLineIndexStart := strings.LastIndex(err.Error(), f) + len(f)
   172  			reNumber := regexp.MustCompile(`:([0-9]+):`)
   173  			matches := reNumber.FindStringSubmatch(err.Error()[errLineIndexStart:])
   174  			newComment := github.DraftReviewComment{
   175  				Path: f,
   176  				Body: err.Error(),
   177  			}
   178  			if len(matches) > 1 {
   179  				errLineString := matches[1]
   180  				errLine, errAtoi := strconv.Atoi(errLineString)
   181  				if errAtoi == nil {
   182  					newComment.Position = errLine
   183  				}
   184  				// Trim error message to after the line and column numbers
   185  				reTrimError := regexp.MustCompile(`(:[0-9]+:[0-9]+: )`)
   186  				matches = reTrimError.FindStringSubmatch(err.Error())
   187  				if len(matches) > 0 {
   188  					newComment.Body = err.Error()[len(matches[0])+errLineIndexStart:]
   189  				}
   190  			}
   191  			lintErrorComments = append(lintErrorComments, newComment)
   192  		}
   193  		al, err := AddedLines(patch)
   194  		if err != nil {
   195  			lintErrorComments = append(lintErrorComments,
   196  				github.DraftReviewComment{
   197  					Path: f,
   198  					Body: fmt.Sprintf("computing added lines in %s: %v", f, err),
   199  				})
   200  		}
   201  		for _, p := range ps {
   202  			if pl, ok := al[p.Position.Line]; ok {
   203  				problems[f][pl] = p
   204  			}
   205  		}
   206  	}
   207  	return problems, lintErrorComments
   208  }
   209  
   210  func handle(minimumConfidence float64, ghc githubClient, gc git.ClientFactory, log *logrus.Entry, e *github.GenericCommentEvent) error {
   211  	// Only handle open PRs and new requests.
   212  	if e.IssueState != "open" || !e.IsPR || e.Action != github.GenericCommentActionCreated {
   213  		return nil
   214  	}
   215  	if !lintRe.MatchString(e.Body) {
   216  		return nil
   217  	}
   218  
   219  	org := e.Repo.Owner.Login
   220  	repo := e.Repo.Name
   221  
   222  	pr, err := ghc.GetPullRequest(org, repo, e.Number)
   223  	if err != nil {
   224  		return err
   225  	}
   226  
   227  	// List modified files.
   228  	modifiedFiles, err := modifiedGoFiles(ghc, org, repo, pr.Number, pr.Head.SHA)
   229  	if err != nil {
   230  		return err
   231  	}
   232  	if len(modifiedFiles) == 0 {
   233  		return nil
   234  	}
   235  	log.Infof("Will lint %d modified go files.", len(modifiedFiles))
   236  
   237  	// Clone the repo, checkout the PR.
   238  	startClone := time.Now()
   239  	r, err := gc.ClientFor(org, repo)
   240  	if err != nil {
   241  		return err
   242  	}
   243  	defer func() {
   244  		if err := r.Clean(); err != nil {
   245  			log.WithError(err).Error("Error cleaning up repo.")
   246  		}
   247  	}()
   248  	if err := r.CheckoutPullRequest(e.Number); err != nil {
   249  		return err
   250  	}
   251  	finishClone := time.Now()
   252  	log.WithField("duration", time.Since(startClone)).Info("Cloned and checked out PR.")
   253  
   254  	// Compute lint errors.
   255  	problems, lintErrorComments := problemsInFiles(r, modifiedFiles)
   256  
   257  	// Filter out problems that are below our threshold
   258  	for file := range problems {
   259  		for line, problem := range problems[file] {
   260  			if problem.Confidence < minimumConfidence {
   261  				delete(problems[file], line)
   262  			}
   263  		}
   264  	}
   265  	log.WithField("duration", time.Since(finishClone)).Info("Linted.")
   266  
   267  	nps := problems
   268  	if len(problems) > 0 {
   269  		oldComments, err := ghc.ListPullRequestComments(org, repo, e.Number)
   270  		if err != nil {
   271  			return err
   272  		}
   273  		nps = newProblems(oldComments, problems)
   274  	}
   275  
   276  	// Make the list of comments.
   277  	var comments = lintErrorComments
   278  	for f, ls := range nps {
   279  		for l, p := range ls {
   280  			var suggestion = suggestion.SuggestCodeChange(p)
   281  			var body string
   282  			var link string
   283  			if p.Link != "" {
   284  				link = fmt.Sprintf("[More info](%s). ", p.Link)
   285  			}
   286  			body = fmt.Sprintf("%sGolint %s: %s. %s%s", suggestion, p.Category, p.Text, link, commentTag)
   287  			comments = append(comments, github.DraftReviewComment{
   288  				Path:     f,
   289  				Position: l,
   290  				Body:     body,
   291  			})
   292  		}
   293  	}
   294  
   295  	// Trim down the number of comments if necessary.
   296  	totalProblems := numProblems(problems)
   297  	newProblems := numProblems(nps)
   298  	oldProblems := totalProblems - newProblems
   299  
   300  	allowedComments := maxComments - oldProblems
   301  	if allowedComments < 0 {
   302  		allowedComments = 0
   303  	}
   304  	if len(comments) > allowedComments {
   305  		comments = comments[:allowedComments]
   306  	}
   307  
   308  	// Make the review body.
   309  	s := "s"
   310  	if totalProblems == 1 {
   311  		s = ""
   312  	}
   313  
   314  	response := fmt.Sprintf("%d warning%s.", totalProblems, s)
   315  
   316  	if oldProblems != 0 {
   317  		response = fmt.Sprintf("%d unresolved warning%s and %d new warning%s.", oldProblems, s, newProblems, s)
   318  	}
   319  
   320  	return ghc.CreateReview(org, repo, e.Number, github.DraftReview{
   321  		Body:     plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, response),
   322  		Action:   github.Comment,
   323  		Comments: comments,
   324  	})
   325  }
   326  
   327  func numProblems(ps map[string]map[int]lint.Problem) int {
   328  	var num int
   329  	for _, m := range ps {
   330  		num += len(m)
   331  	}
   332  	return num
   333  }
   334  
   335  // AddedLines returns line numbers that were added in the patch, along with
   336  // their line in the patch itself as a map from line to patch line.
   337  // https://www.gnu.org/software/diffutils/manual/diffutils.html#Detailed-Unified
   338  // GitHub omits the ---/+++ lines since that information is in the
   339  // PullRequestChange object.
   340  func AddedLines(patch string) (map[int]int, error) {
   341  	result := make(map[int]int)
   342  	if patch == "" {
   343  		return result, nil
   344  	}
   345  	lines := strings.Split(patch, "\n")
   346  	for i := 0; i < len(lines); i++ {
   347  		// dodge the "\ No newline at end of file" line
   348  		if lines[i] == "\\ No newline at end of file" {
   349  			continue
   350  		}
   351  		_, oldLen, newLine, newLen, err := parseHunkLine(lines[i])
   352  		if err != nil {
   353  			return nil, fmt.Errorf("couldn't parse hunk on line %d in patch %s: %w", i, patch, err)
   354  		}
   355  		oldAdd := 0
   356  		newAdd := 0
   357  		for oldAdd < oldLen || newAdd < newLen {
   358  			i++
   359  			if i >= len(lines) {
   360  				return nil, fmt.Errorf("invalid patch: %s", patch)
   361  			}
   362  			switch lines[i][0] {
   363  			case ' ':
   364  				oldAdd++
   365  				newAdd++
   366  			case '-':
   367  				oldAdd++
   368  			case '+':
   369  				result[newLine+newAdd] = i
   370  				newAdd++
   371  			default:
   372  				return nil, fmt.Errorf("bad prefix on line %d in patch %s", i, patch)
   373  			}
   374  		}
   375  	}
   376  	return result, nil
   377  }
   378  
   379  // Matches the hunk line in unified diffs. These are of the form:
   380  // @@ -l,s +l,s @@ section head
   381  // We need to extract the four numbers, but the command and s is optional.
   382  // See https://en.wikipedia.org/wiki/Diff_utility#Unified_format
   383  var hunkRe = regexp.MustCompile(`^@@ -(\d+),?(\d+)? \+(\d+),?(\d+)? @@.*`)
   384  
   385  func parseHunkLine(hunk string) (oldLine, oldLength, newLine, newLength int, err error) {
   386  	if !hunkRe.MatchString(hunk) {
   387  		err = fmt.Errorf("invalid hunk line: %s", hunk)
   388  		return
   389  	}
   390  	matches := hunkRe.FindStringSubmatch(hunk)
   391  	oldLine, err = strconv.Atoi(matches[1])
   392  	if err != nil {
   393  		return
   394  	}
   395  	if matches[2] != "" {
   396  		oldLength, err = strconv.Atoi(matches[2])
   397  		if err != nil {
   398  			return
   399  		}
   400  	} else {
   401  		oldLength = 1
   402  	}
   403  	newLine, err = strconv.Atoi(matches[3])
   404  	if err != nil {
   405  		return
   406  	}
   407  	if matches[4] != "" {
   408  		newLength, err = strconv.Atoi(matches[4])
   409  		if err != nil {
   410  			return
   411  		}
   412  	} else {
   413  		newLength = 1
   414  	}
   415  	return
   416  }