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