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 }