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