github.com/abayer/test-infra@v0.0.5/prow/plugins/buildifier/buildifier.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 // buildifier defines a Prow plugin that runs buildifier over modified BUILD, 18 // WORKSPACE, and skylark (.bzl) files in pull requests. 19 package buildifier 20 21 import ( 22 "bytes" 23 "fmt" 24 "io/ioutil" 25 "path/filepath" 26 "regexp" 27 "sort" 28 "strings" 29 "time" 30 31 "github.com/bazelbuild/buildtools/build" 32 "github.com/sirupsen/logrus" 33 34 "k8s.io/test-infra/prow/genfiles" 35 "k8s.io/test-infra/prow/git" 36 "k8s.io/test-infra/prow/github" 37 "k8s.io/test-infra/prow/pluginhelp" 38 "k8s.io/test-infra/prow/plugins" 39 ) 40 41 const ( 42 pluginName = "buildifier" 43 maxComments = 20 44 ) 45 46 var buildifyRe = regexp.MustCompile(`(?mi)^/buildif(y|ier)\s*$`) 47 48 func init() { 49 plugins.RegisterGenericCommentHandler(pluginName, handleGenericComment, nil) 50 } 51 52 func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) { 53 // The Config field is omitted because this plugin is not configurable. 54 pluginHelp := &pluginhelp.PluginHelp{ 55 Description: "The buildifier plugin runs buildifier on changes made to Bazel files in a PR. It then creates a new review on the pull request and leaves warnings at the appropriate lines of code.", 56 } 57 pluginHelp.AddCommand(pluginhelp.Command{ 58 Usage: "/buildif(y|ier)", 59 Featured: false, 60 Description: "Runs buildifier on changes made to Bazel files in a PR", 61 WhoCanUse: "Anyone can trigger this command on a PR.", 62 Examples: []string{"/buildify", "/buildifier"}, 63 }) 64 return pluginHelp, nil 65 } 66 67 type githubClient interface { 68 GetFile(org, repo, filepath, commit string) ([]byte, error) 69 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 70 GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) 71 CreateReview(org, repo string, number int, r github.DraftReview) error 72 ListPullRequestComments(org, repo string, number int) ([]github.ReviewComment, error) 73 } 74 75 func handleGenericComment(pc plugins.PluginClient, e github.GenericCommentEvent) error { 76 return handle(pc.GitHubClient, pc.GitClient, pc.Logger, &e) 77 } 78 79 // modifiedBazelFiles returns a map from filename to patch string for all Bazel files 80 // that are modified in the PR. 81 func modifiedBazelFiles(ghc githubClient, org, repo string, number int, sha string) (map[string]string, error) { 82 changes, err := ghc.GetPullRequestChanges(org, repo, number) 83 if err != nil { 84 return nil, err 85 } 86 87 gfg, err := genfiles.NewGroup(ghc, org, repo, sha) 88 if err != nil { 89 return nil, err 90 } 91 92 modifiedFiles := make(map[string]string) 93 for _, change := range changes { 94 switch { 95 case gfg.Match(change.Filename): 96 continue 97 case change.Status == github.PullRequestFileRemoved || change.Status == github.PullRequestFileRenamed: 98 continue 99 // This also happens to match BUILD.bazel. 100 case strings.Contains(change.Filename, "BUILD"): 101 break 102 case strings.Contains(change.Filename, "WORKSPACE"): 103 break 104 case filepath.Ext(change.Filename) != ".bzl": 105 continue 106 } 107 modifiedFiles[change.Filename] = change.Patch 108 } 109 return modifiedFiles, nil 110 } 111 112 func uniqProblems(problems []string) []string { 113 sort.Strings(problems) 114 var uniq []string 115 last := "" 116 for _, s := range problems { 117 if s != last { 118 last = s 119 uniq = append(uniq, s) 120 } 121 } 122 return uniq 123 } 124 125 // problemsInFiles runs buildifier on the files. It returns a map from the file to 126 // a list of problems with that file. 127 func problemsInFiles(r *git.Repo, files map[string]string) (map[string][]string, error) { 128 problems := make(map[string][]string) 129 for f := range files { 130 src, err := ioutil.ReadFile(filepath.Join(r.Dir, f)) 131 if err != nil { 132 return nil, err 133 } 134 // This is modeled after the logic from buildifier: 135 // https://github.com/bazelbuild/buildtools/blob/8818289/buildifier/buildifier.go#L261 136 content, err := build.Parse(f, src) 137 if err != nil { 138 return nil, fmt.Errorf("parsing as Bazel file %v", err) 139 } 140 beforeRewrite := build.Format(content) 141 var info build.RewriteInfo 142 build.Rewrite(content, &info) 143 ndata := build.Format(content) 144 if !bytes.Equal(src, ndata) && !bytes.Equal(src, beforeRewrite) { 145 // TODO(mattmoor): This always seems to be empty? 146 problems[f] = uniqProblems(info.Log) 147 } 148 } 149 return problems, nil 150 } 151 152 func handle(ghc githubClient, gc *git.Client, log *logrus.Entry, e *github.GenericCommentEvent) error { 153 // Only handle open PRs and new requests. 154 if e.IssueState != "open" || !e.IsPR || e.Action != github.GenericCommentActionCreated { 155 return nil 156 } 157 if !buildifyRe.MatchString(e.Body) { 158 return nil 159 } 160 161 org := e.Repo.Owner.Login 162 repo := e.Repo.Name 163 164 pr, err := ghc.GetPullRequest(org, repo, e.Number) 165 if err != nil { 166 return err 167 } 168 169 // List modified files. 170 modifiedFiles, err := modifiedBazelFiles(ghc, org, repo, pr.Number, pr.Head.SHA) 171 if err != nil { 172 return err 173 } 174 if len(modifiedFiles) == 0 { 175 return nil 176 } 177 log.Infof("Will buildify %d modified Bazel files.", len(modifiedFiles)) 178 179 // Clone the repo, checkout the PR. 180 startClone := time.Now() 181 r, err := gc.Clone(e.Repo.FullName) 182 if err != nil { 183 return err 184 } 185 defer func() { 186 if err := r.Clean(); err != nil { 187 log.WithError(err).Error("Error cleaning up repo.") 188 } 189 }() 190 if err := r.CheckoutPullRequest(e.Number); err != nil { 191 return err 192 } 193 finishClone := time.Now() 194 log.WithField("duration", time.Since(startClone)).Info("Cloned and checked out PR.") 195 196 // Compute buildifier errors. 197 problems, err := problemsInFiles(r, modifiedFiles) 198 if err != nil { 199 return err 200 } 201 log.WithField("duration", time.Since(finishClone)).Info("Buildified.") 202 203 // Make the list of comments. 204 var comments []github.DraftReviewComment 205 for f := range problems { 206 comments = append(comments, github.DraftReviewComment{ 207 Path: f, 208 // TODO(mattmoor): Include the messages if they are ever non-empty. 209 Body: strings.Join([]string{ 210 "This Bazel file needs formatting, run:", 211 "```shell", 212 fmt.Sprintf("buildifier -mode=fix %q", f), 213 "```"}, "\n"), 214 Position: 1, 215 }) 216 } 217 218 // Trim down the number of comments if necessary. 219 totalProblems := len(problems) 220 221 // Make the review body. 222 s := "s" 223 if totalProblems == 1 { 224 s = "" 225 } 226 response := fmt.Sprintf("%d warning%s.", totalProblems, s) 227 228 return ghc.CreateReview(org, repo, e.Number, github.DraftReview{ 229 Body: plugins.FormatResponseRaw(e.Body, e.HTMLURL, e.User.Login, response), 230 Action: github.Comment, 231 Comments: comments, 232 }) 233 }