github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/plugins/lgtm/lgtm.go (about)

     1  /*
     2  Copyright 2016 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 lgtm implements the lgtm plugin
    18  package lgtm
    19  
    20  import (
    21  	"fmt"
    22  	"regexp"
    23  	"strings"
    24  
    25  	"github.com/sirupsen/logrus"
    26  
    27  	"sigs.k8s.io/prow/pkg/config"
    28  	"sigs.k8s.io/prow/pkg/github"
    29  	"sigs.k8s.io/prow/pkg/labels"
    30  	"sigs.k8s.io/prow/pkg/layeredsets"
    31  	"sigs.k8s.io/prow/pkg/pluginhelp"
    32  	"sigs.k8s.io/prow/pkg/plugins"
    33  	"sigs.k8s.io/prow/pkg/repoowners"
    34  )
    35  
    36  const (
    37  	// PluginName defines this plugin's registered name.
    38  	PluginName = labels.LGTM
    39  )
    40  
    41  var (
    42  	addLGTMLabelNotification   = "LGTM label has been added.  <details>Git tree hash: %s</details>"
    43  	addLGTMLabelNotificationRe = regexp.MustCompile(fmt.Sprintf(addLGTMLabelNotification, "(.*)"))
    44  	configInfoReviewActsAsLgtm = `Reviews of "approve" or "request changes" act as adding or removing LGTM.`
    45  	configInfoStoreTreeHash    = `Squashing commits does not remove LGTM.`
    46  	// LGTMLabel is the name of the lgtm label applied by the lgtm plugin
    47  	LGTMLabel = labels.LGTM
    48  	// LGTMRe is the regex that matches lgtm comments
    49  	LGTMRe = regexp.MustCompile(`(?mi)^/lgtm(?: no-issue)?\s*$`)
    50  	// LGTMCancelRe is the regex that matches lgtm cancel comments
    51  	LGTMCancelRe        = regexp.MustCompile(`(?mi)^/(remove-lgtm|lgtm cancel)\s*$`)
    52  	removeLGTMLabelNoti = "New changes are detected. LGTM label has been removed."
    53  )
    54  
    55  func configInfoStickyLgtmTeam(team string) string {
    56  	return fmt.Sprintf(`Commits from "%s" do not remove LGTM.`, team)
    57  }
    58  
    59  type commentPruner interface {
    60  	PruneComments(shouldPrune func(github.IssueComment) bool)
    61  }
    62  
    63  func init() {
    64  	plugins.RegisterGenericCommentHandler(PluginName, handleGenericCommentEvent, helpProvider)
    65  	plugins.RegisterPullRequestHandler(PluginName, func(pc plugins.Agent, pe github.PullRequestEvent) error {
    66  		return handlePullRequestEvent(pc, pe)
    67  	}, helpProvider)
    68  	plugins.RegisterReviewEventHandler(PluginName, handlePullRequestReviewEvent, helpProvider)
    69  }
    70  
    71  func helpProvider(config *plugins.Configuration, enabledRepos []config.OrgRepo) (*pluginhelp.PluginHelp, error) {
    72  	configInfo := map[string]string{}
    73  	for _, repo := range enabledRepos {
    74  		opts := config.LgtmFor(repo.Org, repo.Repo)
    75  		var isConfigured bool
    76  		var configInfoStrings []string
    77  		configInfoStrings = append(configInfoStrings, "The plugin has the following configuration:<ul>")
    78  		if opts.ReviewActsAsLgtm {
    79  			configInfoStrings = append(configInfoStrings, "<li>"+configInfoReviewActsAsLgtm+"</li>")
    80  			isConfigured = true
    81  		}
    82  		if opts.StoreTreeHash {
    83  			configInfoStrings = append(configInfoStrings, "<li>"+configInfoStoreTreeHash+"</li>")
    84  			isConfigured = true
    85  		}
    86  		if opts.StickyLgtmTeam != "" {
    87  			configInfoStrings = append(configInfoStrings, "<li>"+configInfoStickyLgtmTeam(opts.StickyLgtmTeam)+"</li>")
    88  			isConfigured = true
    89  		}
    90  		configInfoStrings = append(configInfoStrings, "</ul>")
    91  		if isConfigured {
    92  			configInfo[repo.String()] = strings.Join(configInfoStrings, "\n")
    93  		}
    94  	}
    95  	yamlSnippet, err := plugins.CommentMap.GenYaml(&plugins.Configuration{
    96  		Lgtm: []plugins.Lgtm{
    97  			{
    98  				Repos:            []string{"kubernetes/test-infra"},
    99  				ReviewActsAsLgtm: true,
   100  				StickyLgtmTeam:   "team1",
   101  				StoreTreeHash:    true,
   102  			},
   103  		},
   104  	})
   105  	if err != nil {
   106  		logrus.WithError(err).Warnf("cannot generate comments for %s plugin", PluginName)
   107  	}
   108  	pluginHelp := &pluginhelp.PluginHelp{
   109  		Description: "The lgtm plugin manages the application and removal of the 'lgtm' (Looks Good To Me) label which is typically used to gate merging.",
   110  		Config:      configInfo,
   111  		Snippet:     yamlSnippet,
   112  	}
   113  	pluginHelp.AddCommand(pluginhelp.Command{
   114  		Usage:       "/[remove-]lgtm [cancel] or GitHub Review action",
   115  		Description: "Adds or removes the 'lgtm' label which is typically used to gate merging.",
   116  		Featured:    true,
   117  		WhoCanUse:   "Collaborators on the repository. '/lgtm cancel' can be used additionally by the PR author.",
   118  		Examples:    []string{"/lgtm", "/lgtm cancel", "/remove-lgtm", "<a href=\"https://help.github.com/articles/about-pull-request-reviews/\">'Approve' or 'Request Changes'</a>"},
   119  	})
   120  	return pluginHelp, nil
   121  }
   122  
   123  type githubClient interface {
   124  	IsCollaborator(owner, repo, login string) (bool, error)
   125  	AddLabel(owner, repo string, number int, label string) error
   126  	AssignIssue(owner, repo string, number int, assignees []string) error
   127  	CreateComment(owner, repo string, number int, comment string) error
   128  	RemoveLabel(owner, repo string, number int, label string) error
   129  	GetIssueLabels(org, repo string, number int) ([]github.Label, error)
   130  	GetPullRequest(org, repo string, number int) (*github.PullRequest, error)
   131  	GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error)
   132  	ListIssueComments(org, repo string, number int) ([]github.IssueComment, error)
   133  	DeleteComment(org, repo string, ID int) error
   134  	BotUserChecker() (func(candidate string) bool, error)
   135  	GetSingleCommit(org, repo, SHA string) (github.RepositoryCommit, error)
   136  	IsMember(org, user string) (bool, error)
   137  	ListTeams(org string) ([]github.Team, error)
   138  	ListTeamMembersBySlug(org, teamSlug, role string) ([]github.TeamMember, error)
   139  	RequestReview(org, repo string, number int, logins []string) error
   140  }
   141  
   142  // reviewCtx contains information about each review event
   143  type reviewCtx struct {
   144  	author, issueAuthor, body, htmlURL string
   145  	repo                               github.Repo
   146  	assignees                          []github.User
   147  	number                             int
   148  }
   149  
   150  func handleGenericCommentEvent(pc plugins.Agent, e github.GenericCommentEvent) error {
   151  	cp, err := pc.CommentPruner()
   152  	if err != nil {
   153  		return err
   154  	}
   155  	return handleGenericComment(pc.GitHubClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, cp, e)
   156  }
   157  
   158  func handlePullRequestEvent(pc plugins.Agent, pre github.PullRequestEvent) error {
   159  	return handlePullRequest(
   160  		pc.Logger,
   161  		pc.GitHubClient,
   162  		pc.PluginConfig,
   163  		&pre,
   164  	)
   165  }
   166  
   167  func handlePullRequestReviewEvent(pc plugins.Agent, e github.ReviewEvent) error {
   168  	// If ReviewActsAsLgtm is disabled, ignore review event.
   169  	opts := pc.PluginConfig.LgtmFor(e.Repo.Owner.Login, e.Repo.Name)
   170  	if !opts.ReviewActsAsLgtm {
   171  		return nil
   172  	}
   173  	cp, err := pc.CommentPruner()
   174  	if err != nil {
   175  		return err
   176  	}
   177  	return handlePullRequestReview(pc.GitHubClient, pc.PluginConfig, pc.OwnersClient, pc.Logger, cp, e)
   178  }
   179  
   180  func handleGenericComment(gc githubClient, config *plugins.Configuration, ownersClient repoowners.Interface, log *logrus.Entry, cp commentPruner, e github.GenericCommentEvent) error {
   181  	rc := reviewCtx{
   182  		author:      e.User.Login,
   183  		issueAuthor: e.IssueAuthor.Login,
   184  		body:        e.Body,
   185  		htmlURL:     e.HTMLURL,
   186  		repo:        e.Repo,
   187  		assignees:   e.Assignees,
   188  		number:      e.Number,
   189  	}
   190  
   191  	// Only consider open PRs and new comments.
   192  	if !e.IsPR || e.IssueState != "open" || e.Action != github.GenericCommentActionCreated {
   193  		return nil
   194  	}
   195  
   196  	// If we create an "/lgtm" comment, add lgtm if necessary.
   197  	// If we create a "/lgtm cancel" comment, remove lgtm if necessary.
   198  	wantLGTM := false
   199  	if LGTMRe.MatchString(rc.body) {
   200  		wantLGTM = true
   201  	} else if LGTMCancelRe.MatchString(rc.body) {
   202  		wantLGTM = false
   203  	} else {
   204  		return nil
   205  	}
   206  
   207  	// use common handler to do the rest
   208  	return handle(wantLGTM, config, ownersClient, rc, gc, log, cp)
   209  }
   210  
   211  func handlePullRequestReview(gc githubClient, config *plugins.Configuration, ownersClient repoowners.Interface, log *logrus.Entry, cp commentPruner, e github.ReviewEvent) error {
   212  	rc := reviewCtx{
   213  		author:      e.Review.User.Login,
   214  		issueAuthor: e.PullRequest.User.Login,
   215  		repo:        e.Repo,
   216  		assignees:   e.PullRequest.Assignees,
   217  		number:      e.PullRequest.Number,
   218  		body:        e.Review.Body,
   219  		htmlURL:     e.Review.HTMLURL,
   220  	}
   221  
   222  	// Only react to reviews that are being submitted (not editted or dismissed).
   223  	if e.Action != github.ReviewActionSubmitted {
   224  		return nil
   225  	}
   226  
   227  	// If the review event body contains an '/lgtm' or '/lgtm cancel' comment,
   228  	// skip handling the review event
   229  	if LGTMRe.MatchString(rc.body) || LGTMCancelRe.MatchString(rc.body) {
   230  		return nil
   231  	}
   232  
   233  	// The review webhook returns state as lowercase, while the review API
   234  	// returns state as uppercase. Uppercase the value here so it always
   235  	// matches the constant.
   236  	reviewState := github.ReviewState(strings.ToUpper(string(e.Review.State)))
   237  
   238  	// If we review with Approve, add lgtm if necessary.
   239  	// If we review with Request Changes, remove lgtm if necessary.
   240  	wantLGTM := false
   241  	if reviewState == github.ReviewStateApproved {
   242  		wantLGTM = true
   243  	} else if reviewState == github.ReviewStateChangesRequested {
   244  		wantLGTM = false
   245  	} else {
   246  		return nil
   247  	}
   248  
   249  	// use common handler to do the rest
   250  	return handle(wantLGTM, config, ownersClient, rc, gc, log, cp)
   251  }
   252  
   253  func handle(wantLGTM bool, config *plugins.Configuration, ownersClient repoowners.Interface, rc reviewCtx, gc githubClient, log *logrus.Entry, cp commentPruner) error {
   254  	author := rc.author
   255  	issueAuthor := rc.issueAuthor
   256  	assignees := rc.assignees
   257  	number := rc.number
   258  	body := rc.body
   259  	htmlURL := rc.htmlURL
   260  	org := rc.repo.Owner.Login
   261  	repoName := rc.repo.Name
   262  
   263  	// Author cannot LGTM own PR, comment and abort
   264  	isAuthor := author == issueAuthor
   265  	if isAuthor && wantLGTM {
   266  		resp := "you cannot LGTM your own PR."
   267  		log.Infof("Commenting with \"%s\".", resp)
   268  		return gc.CreateComment(rc.repo.Owner.Login, rc.repo.Name, rc.number, plugins.FormatResponseRaw(rc.body, rc.htmlURL, rc.author, resp))
   269  	}
   270  
   271  	// Determine if reviewer is already assigned
   272  	isAssignee := false
   273  	for _, assignee := range assignees {
   274  		if assignee.Login == author {
   275  			isAssignee = true
   276  			break
   277  		}
   278  	}
   279  
   280  	// check if skip collaborators is enabled for this org/repo
   281  	skipCollaborators := skipCollaborators(config, org, repoName)
   282  
   283  	// check if the commenter is a collaborator
   284  	isCollaborator, err := gc.IsCollaborator(org, repoName, author)
   285  	if err != nil {
   286  		log.WithError(err).Error("Failed to check if author is a collaborator.")
   287  		return err // abort if we can't determine if commenter is a collaborator
   288  	}
   289  
   290  	// if commenter isn't a collaborator, and we care about collaborators, abort
   291  	if !isAuthor && !skipCollaborators && !isCollaborator {
   292  		resp := "changing LGTM is restricted to collaborators"
   293  		log.Infof("Reply to /lgtm request with comment: \"%s\"", resp)
   294  		return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp))
   295  	}
   296  
   297  	// either ensure that the commenter is a collaborator or an approver/reviewer
   298  	if !isAuthor && !isAssignee && !skipCollaborators {
   299  		// in this case we need to ensure the commenter is assignable to the PR
   300  		// by assigning them
   301  		log.Infof("Assigning %s/%s#%d to %s", org, repoName, number, author)
   302  		if err := gc.AssignIssue(org, repoName, number, []string{author}); err != nil {
   303  			log.WithError(err).Errorf("Failed to assign %s/%s#%d to %s", org, repoName, number, author)
   304  		}
   305  	} else if !isAuthor && skipCollaborators {
   306  		// in this case we depend on OWNERS files instead to check if the author
   307  		// is an approver or reviewer of the changed files
   308  		log.Debugf("Skipping collaborator checks and loading OWNERS for %s/%s#%d", org, repoName, number)
   309  		ro, err := loadRepoOwners(gc, ownersClient, org, repoName, number)
   310  		if err != nil {
   311  			return err
   312  		}
   313  		filenames, err := getChangedFiles(gc, org, repoName, number)
   314  		if err != nil {
   315  			return err
   316  		}
   317  		if !loadReviewers(ro, filenames).Has(github.NormLogin(author)) {
   318  			resp := "adding LGTM is restricted to approvers and reviewers in OWNERS files."
   319  			log.Infof("Reply to /lgtm request with comment: \"%s\"", resp)
   320  			return gc.CreateComment(org, repoName, number, plugins.FormatResponseRaw(body, htmlURL, author, resp))
   321  		}
   322  	}
   323  
   324  	// now we update the LGTM labels, having checked all cases where changing
   325  	// LGTM was not allowed for the commenter
   326  
   327  	// Only add the label if it doesn't have it, and vice versa.
   328  	labels, err := gc.GetIssueLabels(org, repoName, number)
   329  	if err != nil {
   330  		log.WithError(err).Error("Failed to get issue labels.")
   331  	}
   332  	hasLGTM := github.HasLabel(LGTMLabel, labels)
   333  
   334  	// remove the label if necessary, we're done after this
   335  	opts := config.LgtmFor(rc.repo.Owner.Login, rc.repo.Name)
   336  	if hasLGTM && !wantLGTM {
   337  		log.Info("Removing LGTM label.")
   338  		if err := removeLGTMAndRequestReview(gc, org, repoName, number, getLogins(assignees), opts.StoreTreeHash); err != nil {
   339  			return err
   340  		}
   341  		if opts.StoreTreeHash {
   342  			cp.PruneComments(func(comment github.IssueComment) bool {
   343  				return addLGTMLabelNotificationRe.MatchString(comment.Body)
   344  			})
   345  		}
   346  	} else if !hasLGTM && wantLGTM {
   347  		log.Info("Adding LGTM label.")
   348  		if err := gc.AddLabel(org, repoName, number, LGTMLabel); err != nil {
   349  			return err
   350  		}
   351  		if !stickyLgtm(log, gc, config, opts, issueAuthor, org) {
   352  			if opts.StoreTreeHash {
   353  				pr, err := gc.GetPullRequest(org, repoName, number)
   354  				if err != nil {
   355  					log.WithError(err).Error("Failed to get pull request.")
   356  				}
   357  				commit, err := gc.GetSingleCommit(org, repoName, pr.Head.SHA)
   358  				if err != nil {
   359  					log.WithField("sha", pr.Head.SHA).WithError(err).Error("Failed to get commit.")
   360  				}
   361  				treeHash := commit.Commit.Tree.SHA
   362  				log.WithField("tree", treeHash).Info("Adding comment to store tree-hash.")
   363  				if err := gc.CreateComment(org, repoName, number, fmt.Sprintf(addLGTMLabelNotification, treeHash)); err != nil {
   364  					log.WithError(err).Error("Failed to add comment.")
   365  				}
   366  			}
   367  			// Delete the LGTM removed noti after the LGTM label is added.
   368  			cp.PruneComments(func(comment github.IssueComment) bool {
   369  				return strings.Contains(comment.Body, removeLGTMLabelNoti)
   370  			})
   371  		}
   372  	}
   373  
   374  	return nil
   375  }
   376  
   377  func stickyLgtm(log *logrus.Entry, gc githubClient, _ *plugins.Configuration, lgtm *plugins.Lgtm, author, org string) bool {
   378  	if lgtm.StickyLgtmTeam == "" {
   379  		return false
   380  	}
   381  	teams, err := gc.ListTeams(org)
   382  	if err != nil {
   383  		log.WithError(err).Errorf("Failed to list teams in org %s.", org)
   384  		return false
   385  	}
   386  	for _, teamInOrg := range teams {
   387  		if teamInOrg.Name == lgtm.StickyLgtmTeam {
   388  			members, err := gc.ListTeamMembersBySlug(org, teamInOrg.Slug, github.RoleAll)
   389  			if err != nil {
   390  				log.WithError(err).Errorf("Failed to list members in %s:%s.", org, teamInOrg.Name)
   391  				return false
   392  			}
   393  			for _, member := range members {
   394  				if member.Login == author {
   395  					// The author is in the trusted team
   396  					return true
   397  				}
   398  			}
   399  			// The author is not in the trusted team
   400  			return false
   401  		}
   402  	}
   403  	log.Errorf("The configured trusted_team_for_sticky_lgtm (%q) was not found in the org %q.", lgtm.StickyLgtmTeam, org)
   404  	return false
   405  }
   406  
   407  func handlePullRequest(log *logrus.Entry, gc githubClient, config *plugins.Configuration, pe *github.PullRequestEvent) error {
   408  	if pe.PullRequest.Merged {
   409  		return nil
   410  	}
   411  
   412  	if pe.Action != github.PullRequestActionSynchronize {
   413  		return nil
   414  	}
   415  
   416  	org := pe.PullRequest.Base.Repo.Owner.Login
   417  	repo := pe.PullRequest.Base.Repo.Name
   418  	number := pe.PullRequest.Number
   419  
   420  	opts := config.LgtmFor(org, repo)
   421  	if stickyLgtm(log, gc, config, opts, pe.PullRequest.User.Login, org) {
   422  		// If the author is trusted, skip tree hash verification and LGTM removal.
   423  		return nil
   424  	}
   425  
   426  	// If we don't have the lgtm label, we don't need to check anything
   427  	labels, err := gc.GetIssueLabels(org, repo, number)
   428  	if err != nil {
   429  		log.WithError(err).Error("Failed to get labels.")
   430  	}
   431  	if !github.HasLabel(LGTMLabel, labels) {
   432  		return nil
   433  	}
   434  
   435  	if opts.StoreTreeHash {
   436  		// Check if we have a tree-hash comment
   437  		var lastLgtmTreeHash string
   438  		botUserChecker, err := gc.BotUserChecker()
   439  		if err != nil {
   440  			return err
   441  		}
   442  		comments, err := gc.ListIssueComments(org, repo, number)
   443  		if err != nil {
   444  			log.WithError(err).Error("Failed to get issue comments.")
   445  		}
   446  		// older comments are still present
   447  		// iterate backwards to find the last LGTM tree-hash
   448  		for i := len(comments) - 1; i >= 0; i-- {
   449  			comment := comments[i]
   450  			m := addLGTMLabelNotificationRe.FindStringSubmatch(comment.Body)
   451  			if botUserChecker(comment.User.Login) && m != nil && comment.UpdatedAt.Equal(comment.CreatedAt) {
   452  				lastLgtmTreeHash = m[1]
   453  				break
   454  			}
   455  		}
   456  		if lastLgtmTreeHash != "" {
   457  			// Get the current tree-hash
   458  			commit, err := gc.GetSingleCommit(org, repo, pe.PullRequest.Head.SHA)
   459  			if err != nil {
   460  				log.WithField("sha", pe.PullRequest.Head.SHA).WithError(err).Error("Failed to get commit.")
   461  			}
   462  			treeHash := commit.Commit.Tree.SHA
   463  			if treeHash == lastLgtmTreeHash {
   464  				// Don't remove the label, PR code hasn't changed
   465  				log.Infof("Keeping LGTM label as the tree-hash remained the same: %s", treeHash)
   466  				return nil
   467  			}
   468  		}
   469  	}
   470  
   471  	if err := removeLGTMAndRequestReview(gc, org, repo, number, getLogins(pe.PullRequest.Assignees), opts.StoreTreeHash); err != nil {
   472  		return fmt.Errorf("failed removing lgtm label: %w", err)
   473  	}
   474  
   475  	// Create a comment to inform participants that LGTM label is removed due to new
   476  	// pull request changes.
   477  	log.Infof("Commenting with an LGTM removed notification to %s/%s#%d with a message: %s", org, repo, number, removeLGTMLabelNoti)
   478  	return gc.CreateComment(org, repo, number, removeLGTMLabelNoti)
   479  }
   480  
   481  func removeLGTMAndRequestReview(gc githubClient, org, repo string, number int, logins []string, storeTreeHash bool) error {
   482  	if err := gc.RemoveLabel(org, repo, number, LGTMLabel); err != nil {
   483  		return fmt.Errorf("failed removing lgtm label: %w", err)
   484  	}
   485  
   486  	// Re-request review because LGTM has been removed only if storeTreeHash enabled.
   487  	// TODO(mpherman): Surface User errors to PR
   488  	if storeTreeHash {
   489  		if err := gc.RequestReview(org, repo, number, logins); err != nil {
   490  			return fmt.Errorf("failed to re-request review")
   491  		}
   492  	}
   493  	return nil
   494  }
   495  
   496  func getLogins(usrs []github.User) []string {
   497  	res := []string{}
   498  	for _, usr := range usrs {
   499  		res = append(res, usr.Login)
   500  	}
   501  	return res
   502  }
   503  
   504  func skipCollaborators(config *plugins.Configuration, org, repo string) bool {
   505  	full := fmt.Sprintf("%s/%s", org, repo)
   506  	for _, elem := range config.Owners.SkipCollaborators {
   507  		if elem == org || elem == full {
   508  			return true
   509  		}
   510  	}
   511  	return false
   512  }
   513  
   514  func loadRepoOwners(gc githubClient, ownersClient repoowners.Interface, org, repo string, number int) (repoowners.RepoOwner, error) {
   515  	pr, err := gc.GetPullRequest(org, repo, number)
   516  	if err != nil {
   517  		return nil, err
   518  	}
   519  	return ownersClient.LoadRepoOwners(org, repo, pr.Base.Ref)
   520  }
   521  
   522  // getChangedFiles returns all the changed files for the provided pull request.
   523  func getChangedFiles(gc githubClient, org, repo string, number int) ([]string, error) {
   524  	changes, err := gc.GetPullRequestChanges(org, repo, number)
   525  	if err != nil {
   526  		return nil, fmt.Errorf("cannot get PR changes for %s/%s#%d", org, repo, number)
   527  	}
   528  	var filenames []string
   529  	for _, change := range changes {
   530  		filenames = append(filenames, change.Filename)
   531  	}
   532  	return filenames, nil
   533  }
   534  
   535  // loadReviewers returns all reviewers and approvers from all OWNERS files that
   536  // cover the provided filenames.
   537  func loadReviewers(ro repoowners.RepoOwner, filenames []string) layeredsets.String {
   538  	reviewers := layeredsets.String{}
   539  	for _, filename := range filenames {
   540  		reviewers = reviewers.Union(ro.Approvers(filename)).Union(ro.Reviewers(filename))
   541  	}
   542  	return reviewers
   543  }