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