github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/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  }