github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/plugins/blunderbuss/blunderbuss.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 blunderbuss
    18  
    19  import (
    20  	"context"
    21  	"fmt"
    22  	"regexp"
    23  
    24  	githubql "github.com/shurcooL/githubv4"
    25  	"github.com/sirupsen/logrus"
    26  	"k8s.io/apimachinery/pkg/util/sets"
    27  	"sigs.k8s.io/prow/pkg/layeredsets"
    28  
    29  	"sigs.k8s.io/prow/pkg/config"
    30  	"sigs.k8s.io/prow/pkg/github"
    31  	"sigs.k8s.io/prow/pkg/pluginhelp"
    32  	"sigs.k8s.io/prow/pkg/plugins"
    33  	"sigs.k8s.io/prow/pkg/plugins/assign"
    34  	"sigs.k8s.io/prow/pkg/repoowners"
    35  )
    36  
    37  const (
    38  	// PluginName defines this plugin's registered name.
    39  	PluginName = "blunderbuss"
    40  )
    41  
    42  var (
    43  	match = regexp.MustCompile(`(?mi)^/auto-cc\s*$`)
    44  )
    45  
    46  func init() {
    47  	plugins.RegisterPullRequestHandler(PluginName, handlePullRequestEvent, helpProvider)
    48  	plugins.RegisterGenericCommentHandler(PluginName, handleGenericCommentEvent, helpProvider)
    49  }
    50  
    51  func configString(reviewCount int) string {
    52  	var pluralSuffix string
    53  	if reviewCount > 1 {
    54  		pluralSuffix = "s"
    55  	}
    56  	return fmt.Sprintf("Blunderbuss is currently configured to request reviews from %d reviewer%s.", reviewCount, pluralSuffix)
    57  }
    58  
    59  func helpProvider(config *plugins.Configuration, _ []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    60  	var reviewCount int
    61  	if config.Blunderbuss.ReviewerCount != nil {
    62  		reviewCount = *config.Blunderbuss.ReviewerCount
    63  	}
    64  	two := 2
    65  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
    66  		Blunderbuss: plugins.Blunderbuss{
    67  			ReviewerCount:         &two,
    68  			MaxReviewerCount:      3,
    69  			ExcludeApprovers:      true,
    70  			UseStatusAvailability: true,
    71  			IgnoreAuthors:         []string{},
    72  		},
    73  	})
    74  	if err != nil {
    75  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName)
    76  	}
    77  	pluginHelp := &pluginhelp.PluginHelp{
    78  		Description: "The blunderbuss plugin automatically requests reviews from reviewers when a new PR is created. The reviewers are selected based on the reviewers specified in the OWNERS files that apply to the files modified by the PR.",
    79  		Config: map[string]string{
    80  			"": configString(reviewCount),
    81  		},
    82  		Snippet: yamlSnippet,
    83  	}
    84  	pluginHelp.AddCommand(pluginhelp.Command{
    85  		Usage:       "/auto-cc",
    86  		Featured:    false,
    87  		Description: "Manually request reviews from reviewers for a PR. Useful if OWNERS file were updated since the PR was opened.",
    88  		Examples:    []string{"/auto-cc"},
    89  		WhoCanUse:   "Anyone",
    90  	})
    91  	return pluginHelp, nil
    92  }
    93  
    94  type reviewersClient interface {
    95  	FindReviewersOwnersForFile(path string) string
    96  	Reviewers(path string) layeredsets.String
    97  	RequiredReviewers(path string) sets.Set[string]
    98  	LeafReviewers(path string) sets.Set[string]
    99  }
   100  
   101  type ownersClient interface {
   102  	reviewersClient
   103  	FindApproverOwnersForFile(path string) string
   104  	Approvers(path string) layeredsets.String
   105  	LeafApprovers(path string) sets.Set[string]
   106  }
   107  
   108  type fallbackReviewersClient struct {
   109  	ownersClient
   110  }
   111  
   112  func (foc fallbackReviewersClient) FindReviewersOwnersForFile(path string) string {
   113  	return foc.ownersClient.FindApproverOwnersForFile(path)
   114  }
   115  
   116  func (foc fallbackReviewersClient) Reviewers(path string) layeredsets.String {
   117  	return foc.ownersClient.Approvers(path)
   118  }
   119  
   120  func (foc fallbackReviewersClient) LeafReviewers(path string) sets.Set[string] {
   121  	return foc.ownersClient.LeafApprovers(path)
   122  }
   123  
   124  type githubClient interface {
   125  	RequestReview(org, repo string, number int, logins []string) error
   126  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
   127  	GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
   128  	Query(context.Context, interface{}, map[string]interface{}) error
   129  }
   130  
   131  type repoownersClient interface {
   132  	LoadRepoOwners(org, repo, base string) (repoowners.RepoOwner, error)
   133  }
   134  
   135  func handlePullRequestEvent(pc plugins.Agent, pre github.PullRequestEvent) error {
   136  	return handlePullRequest(
   137  		pc.GitHubClient,
   138  		pc.OwnersClient,
   139  		pc.Logger,
   140  		pc.PluginConfig.Blunderbuss,
   141  		pre.Action,
   142  		&pre.PullRequest,
   143  		&pre.Repo,
   144  	)
   145  }
   146  
   147  func handlePullRequest(ghc githubClient, roc repoownersClient, log *logrus.Entry, config plugins.Blunderbuss, action github.PullRequestEventAction, pr *github.PullRequest, repo *github.Repo) error {
   148  	if !(action == github.PullRequestActionOpened || action == github.PullRequestActionReadyForReview) || assign.CCRegexp.MatchString(pr.Body) {
   149  		return nil
   150  	}
   151  	if pr.Draft && config.IgnoreDrafts {
   152  		// ignore Draft PR when IgnoreDrafts is true
   153  		return nil
   154  	}
   155  	// Ignore PRs submitted by users matching logins set in IgnoreAuthors
   156  	for _, user := range config.IgnoreAuthors {
   157  		if user == pr.User.Login {
   158  			return nil
   159  		}
   160  	}
   161  	return handle(
   162  		ghc,
   163  		roc,
   164  		log,
   165  		config.ReviewerCount,
   166  		config.MaxReviewerCount,
   167  		config.ExcludeApprovers,
   168  		config.UseStatusAvailability,
   169  		repo,
   170  		pr,
   171  	)
   172  }
   173  
   174  func handleGenericCommentEvent(pc plugins.Agent, ce github.GenericCommentEvent) error {
   175  	return handleGenericComment(
   176  		pc.GitHubClient,
   177  		pc.OwnersClient,
   178  		pc.Logger,
   179  		pc.PluginConfig.Blunderbuss,
   180  		ce.Action,
   181  		ce.IsPR,
   182  		ce.Number,
   183  		ce.IssueState,
   184  		&ce.Repo,
   185  		ce.Body,
   186  	)
   187  }
   188  
   189  func handleGenericComment(ghc githubClient, roc repoownersClient, log *logrus.Entry, config plugins.Blunderbuss, action github.GenericCommentEventAction, isPR bool, prNumber int, issueState string, repo *github.Repo, body string) error {
   190  	if action != github.GenericCommentActionCreated || !isPR || issueState == "closed" {
   191  		return nil
   192  	}
   193  
   194  	if !match.MatchString(body) {
   195  		return nil
   196  	}
   197  
   198  	pr, err := ghc.GetPullRequest(repo.Owner.Login, repo.Name, prNumber)
   199  	if err != nil {
   200  		return fmt.Errorf("error loading PullRequest: %w", err)
   201  	}
   202  
   203  	return handle(
   204  		ghc,
   205  		roc,
   206  		log,
   207  		config.ReviewerCount,
   208  		config.MaxReviewerCount,
   209  		config.ExcludeApprovers,
   210  		config.UseStatusAvailability,
   211  		repo,
   212  		pr,
   213  	)
   214  }
   215  
   216  func handle(ghc githubClient, roc repoownersClient, log *logrus.Entry, reviewerCount *int, maxReviewers int, excludeApprovers bool, useStatusAvailability bool, repo *github.Repo, pr *github.PullRequest) error {
   217  	oc, err := roc.LoadRepoOwners(repo.Owner.Login, repo.Name, pr.Base.Ref)
   218  	if err != nil {
   219  		return fmt.Errorf("error loading RepoOwners: %w", err)
   220  	}
   221  
   222  	changes, err := ghc.GetPullRequestChanges(repo.Owner.Login, repo.Name, pr.Number)
   223  	if err != nil {
   224  		return fmt.Errorf("error getting PR changes: %w", err)
   225  	}
   226  
   227  	var reviewers []string
   228  	var requiredReviewers []string
   229  	if reviewerCount != nil {
   230  		reviewers, requiredReviewers, err = getReviewers(oc, ghc, log, pr.User.Login, changes, *reviewerCount, useStatusAvailability)
   231  		if err != nil {
   232  			return err
   233  		}
   234  		if missing := *reviewerCount - len(reviewers); missing > 0 {
   235  			if !excludeApprovers {
   236  				// Attempt to use approvers as additional reviewers. This must use
   237  				// reviewerCount instead of missing because owners can be both reviewers
   238  				// and approvers and the search might stop too early if it finds
   239  				// duplicates.
   240  				frc := fallbackReviewersClient{ownersClient: oc}
   241  				approvers, _, err := getReviewers(frc, ghc, log, pr.User.Login, changes, *reviewerCount, useStatusAvailability)
   242  				if err != nil {
   243  					return err
   244  				}
   245  				var added int
   246  				combinedReviewers := sets.New[string](reviewers...)
   247  				for _, approver := range approvers {
   248  					if !combinedReviewers.Has(approver) {
   249  						reviewers = append(reviewers, approver)
   250  						combinedReviewers.Insert(approver)
   251  						added++
   252  					}
   253  				}
   254  				log.Infof("Added %d approvers as reviewers. %d/%d reviewers found.", added, combinedReviewers.Len(), *reviewerCount)
   255  			}
   256  		}
   257  		if missing := *reviewerCount - len(reviewers); missing > 0 {
   258  			log.Debugf("Not enough reviewers found in OWNERS files for files touched by this PR. %d/%d reviewers found.", len(reviewers), *reviewerCount)
   259  		}
   260  	}
   261  
   262  	if maxReviewers > 0 && len(reviewers) > maxReviewers {
   263  		log.Infof("Limiting request of %d reviewers to %d maxReviewers.", len(reviewers), maxReviewers)
   264  		reviewers = reviewers[:maxReviewers]
   265  	}
   266  
   267  	// add required reviewers if any
   268  	reviewers = append(reviewers, requiredReviewers...)
   269  
   270  	if len(reviewers) > 0 {
   271  		log.Infof("Requesting reviews from users %s.", reviewers)
   272  		return ghc.RequestReview(repo.Owner.Login, repo.Name, pr.Number, reviewers)
   273  	}
   274  	return nil
   275  }
   276  
   277  func getReviewers(rc reviewersClient, ghc githubClient, log *logrus.Entry, author string, files []github.PullRequestChange, minReviewers int, useStatusAvailability bool) ([]string, []string, error) {
   278  	authorSet := sets.New[string](github.NormLogin(author))
   279  	reviewers := layeredsets.NewString()
   280  	requiredReviewers := sets.New[string]()
   281  	leafReviewers := layeredsets.NewString()
   282  	busyReviewers := sets.New[string]()
   283  	ownersSeen := sets.New[string]()
   284  	if minReviewers == 0 {
   285  		return reviewers.List(), sets.List(requiredReviewers), nil
   286  	}
   287  	// first build 'reviewers' by taking a unique reviewer from each OWNERS file.
   288  	for _, file := range files {
   289  		ownersFile := rc.FindReviewersOwnersForFile(file.Filename)
   290  		if ownersSeen.Has(ownersFile) {
   291  			continue
   292  		}
   293  		ownersSeen.Insert(ownersFile)
   294  
   295  		// record required reviewers if any
   296  		requiredReviewers.Insert(rc.RequiredReviewers(file.Filename).UnsortedList()...)
   297  
   298  		fileUnusedLeafs := layeredsets.NewString(sets.List(rc.LeafReviewers(file.Filename))...).Difference(reviewers.Set()).Difference(authorSet)
   299  		if fileUnusedLeafs.Len() == 0 {
   300  			continue
   301  		}
   302  		leafReviewers = leafReviewers.Union(fileUnusedLeafs)
   303  		if r := findReviewer(ghc, log, useStatusAvailability, &busyReviewers, &fileUnusedLeafs); r != "" {
   304  			reviewers.Insert(0, r)
   305  		}
   306  	}
   307  	// now ensure that we request review from at least minReviewers reviewers. Favor leaf reviewers.
   308  	unusedLeafs := leafReviewers.Difference(reviewers.Set())
   309  	for reviewers.Len() < minReviewers && unusedLeafs.Len() > 0 {
   310  		if r := findReviewer(ghc, log, useStatusAvailability, &busyReviewers, &unusedLeafs); r != "" {
   311  			reviewers.Insert(1, r)
   312  		}
   313  	}
   314  	for _, file := range files {
   315  		if reviewers.Len() >= minReviewers {
   316  			break
   317  		}
   318  		fileReviewers := rc.Reviewers(file.Filename).Difference(authorSet)
   319  		for reviewers.Len() < minReviewers && fileReviewers.Len() > 0 {
   320  			if r := findReviewer(ghc, log, useStatusAvailability, &busyReviewers, &fileReviewers); r != "" {
   321  				reviewers.Insert(2, r)
   322  			}
   323  		}
   324  	}
   325  	return reviewers.List(), sets.List(requiredReviewers), nil
   326  }
   327  
   328  // findReviewer finds a reviewer from a set, potentially using status
   329  // availability.
   330  func findReviewer(ghc githubClient, log *logrus.Entry, useStatusAvailability bool, busyReviewers *sets.Set[string], targetSet *layeredsets.String) string {
   331  	// if we don't care about status availability, just pop a target from the set
   332  	if !useStatusAvailability {
   333  		return targetSet.PopRandom()
   334  	}
   335  
   336  	// if we do care, start looping through the candidates
   337  	for {
   338  		if targetSet.Len() == 0 {
   339  			// if there are no candidates left, then break
   340  			break
   341  		}
   342  		candidate := targetSet.PopRandom()
   343  		if busyReviewers.Has(candidate) {
   344  			// we've already verified this reviewer is busy
   345  			continue
   346  		}
   347  		busy, err := isUserBusy(ghc, candidate)
   348  		if err != nil {
   349  			log.WithField("user", candidate).WithError(err).Error("Error checking user availability")
   350  		}
   351  		if !busy {
   352  			return candidate
   353  		}
   354  		// if we haven't returned the candidate, then they must be busy.
   355  		log.WithField("user", candidate).Debug("User marked as a busy reviewer")
   356  		busyReviewers.Insert(candidate)
   357  	}
   358  	return ""
   359  }
   360  
   361  type githubAvailabilityQuery struct {
   362  	User struct {
   363  		Login  githubql.String
   364  		Status struct {
   365  			IndicatesLimitedAvailability githubql.Boolean
   366  		}
   367  	} `graphql:"user(login: $user)"`
   368  }
   369  
   370  func isUserBusy(ghc githubClient, user string) (bool, error) {
   371  	var query githubAvailabilityQuery
   372  	vars := map[string]interface{}{
   373  		"user": githubql.String(user),
   374  	}
   375  	ctx := context.Background()
   376  	err := ghc.Query(ctx, &query, vars)
   377  	return bool(query.User.Status.IndicatesLimitedAvailability), err
   378  }