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