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