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 }