github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/plugins/approve/approvers/owners.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 approvers
    18  
    19  import (
    20  	"bytes"
    21  	"encoding/json"
    22  	"fmt"
    23  	"math/rand"
    24  	"net/url"
    25  	"path/filepath"
    26  	"sort"
    27  	"strings"
    28  	"text/template"
    29  
    30  	"github.com/sirupsen/logrus"
    31  
    32  	"k8s.io/apimachinery/pkg/util/sets"
    33  
    34  	"sigs.k8s.io/prow/pkg/layeredsets"
    35  	"sigs.k8s.io/prow/pkg/plugins/ownersconfig"
    36  )
    37  
    38  const (
    39  	// ApprovalNotificationName defines the name used in the title for the approval notifications.
    40  	ApprovalNotificationName = "ApprovalNotifier"
    41  )
    42  
    43  // Repo allows querying and interacting with OWNERS information in a repo.
    44  type Repo interface {
    45  	Approvers(path string) layeredsets.String
    46  	LeafApprovers(path string) sets.Set[string]
    47  	FindApproverOwnersForFile(file string) string
    48  	IsNoParentOwners(path string) bool
    49  	IsAutoApproveUnownedSubfolders(directory string) bool
    50  	Filenames() ownersconfig.Filenames
    51  }
    52  
    53  // Owners provides functionality related to owners of a specific code change.
    54  type Owners struct {
    55  	// filenamesUnfiltered contains all files in a given PR, including those
    56  	// that do not need approval because of IsAutoApproveUnownedSubfolders
    57  	filenamesUnfiltered []string
    58  	// filenames refers to the files in a given PR, not to OWNERS files. Files
    59  	// that are a directory below an Owners file with IsAutoApproveUnownedSubfolders
    60  	// are excluded here but kept in filenamesUnfiltered.
    61  	filenames []string
    62  	repo      Repo
    63  	seed      int64
    64  
    65  	log *logrus.Entry
    66  }
    67  
    68  // NewOwners consturcts a new Owners instance. filenames is the slice of files changed.
    69  func NewOwners(log *logrus.Entry, filenames []string, r Repo, s int64) Owners {
    70  	return Owners{filenamesUnfiltered: filenames, filenames: filenames, repo: r, seed: s, log: log}
    71  }
    72  
    73  // GetApprovers returns a map from ownersFiles -> people that are approvers in them
    74  func (o Owners) GetApprovers() map[string]sets.Set[string] {
    75  	ownersToApprovers := map[string]sets.Set[string]{}
    76  	for _, toApprove := range o.filenames {
    77  		ownersFile := o.repo.FindApproverOwnersForFile(toApprove)
    78  		approvers := o.repo.Approvers(toApprove).Set()
    79  		if _, ok := ownersToApprovers[ownersFile]; !ok {
    80  			ownersToApprovers[ownersFile] = sets.New[string]()
    81  		}
    82  		ownersToApprovers[ownersFile] = ownersToApprovers[ownersFile].Union(approvers)
    83  	}
    84  
    85  	owners := o.GetOwnersSet()
    86  	for k := range ownersToApprovers {
    87  		if !owners.Has(k) {
    88  			delete(ownersToApprovers, k)
    89  		}
    90  	}
    91  
    92  	return ownersToApprovers
    93  }
    94  
    95  // GetLeafApprovers returns a map from ownersFiles -> people that are approvers in them (only the leaf)
    96  func (o Owners) GetLeafApprovers() map[string]sets.Set[string] {
    97  	ownersToApprovers := map[string]sets.Set[string]{}
    98  
    99  	for _, toApprove := range o.filenames {
   100  		ownersFile := o.repo.FindApproverOwnersForFile(toApprove)
   101  		approvers := o.repo.LeafApprovers(toApprove)
   102  		if _, ok := ownersToApprovers[ownersFile]; !ok {
   103  			ownersToApprovers[ownersFile] = sets.New[string]()
   104  		}
   105  		ownersToApprovers[ownersFile] = ownersToApprovers[ownersFile].Union(approvers)
   106  	}
   107  
   108  	owners := o.GetOwnersSet()
   109  	for k := range ownersToApprovers {
   110  		if !owners.Has(k) {
   111  			delete(ownersToApprovers, k)
   112  		}
   113  	}
   114  
   115  	return ownersToApprovers
   116  }
   117  
   118  // GetAllPotentialApprovers returns the people from relevant owners files needed to get the PR approved
   119  func (o Owners) GetAllPotentialApprovers() []string {
   120  	approversOnly := []string{}
   121  	for _, approverList := range o.GetLeafApprovers() {
   122  		for approver := range approverList {
   123  			approversOnly = append(approversOnly, approver)
   124  		}
   125  	}
   126  	sort.Strings(approversOnly)
   127  	if len(approversOnly) == 0 {
   128  		o.log.Debug("No potential approvers exist. Does the repo have OWNERS files?")
   129  	}
   130  	return approversOnly
   131  }
   132  
   133  // GetReverseMap returns a map from people -> OWNERS files for which they are an approver
   134  func (o Owners) GetReverseMap(approvers map[string]sets.Set[string]) map[string]sets.Set[string] {
   135  	approverOwnersfiles := map[string]sets.Set[string]{}
   136  	for ownersFile, approvers := range approvers {
   137  		for approver := range approvers {
   138  			if _, ok := approverOwnersfiles[approver]; ok {
   139  				approverOwnersfiles[approver].Insert(ownersFile)
   140  			} else {
   141  				approverOwnersfiles[approver] = sets.New[string](ownersFile)
   142  			}
   143  		}
   144  	}
   145  	return approverOwnersfiles
   146  }
   147  
   148  func findMostCoveringApprover(allApprovers []string, coveredApproversSet sets.Set[string], reverseMap map[string]sets.Set[string], unapproved sets.Set[string]) string {
   149  	maxCovered := 0
   150  	var bestPerson string
   151  	for _, approver := range allApprovers {
   152  		filesCanApprove := reverseMap[approver]
   153  		if filesCanApprove.Intersection(unapproved).Len() > maxCovered && !coveredApproversSet.Has(approver) {
   154  			maxCovered = len(filesCanApprove)
   155  			bestPerson = approver
   156  		}
   157  	}
   158  
   159  	// todo: make it better.
   160  
   161  	return bestPerson
   162  }
   163  
   164  // temporaryUnapprovedFiles returns the list of files that wouldn't be
   165  // approved by the given set of approvers.
   166  func (o Owners) temporaryUnapprovedFiles(approvers sets.Set[string]) sets.Set[string] {
   167  	ap := NewApprovers(o)
   168  	for approver := range approvers {
   169  		ap.AddApprover(approver, "", false)
   170  	}
   171  	return ap.UnapprovedFiles()
   172  }
   173  
   174  // KeepCoveringApprovers finds who we should keep as suggested approvers given a pre-selection
   175  // knownApprovers must be a subset of potentialApprovers.
   176  func (o Owners) KeepCoveringApprovers(reverseMap map[string]sets.Set[string], knownApprovers sets.Set[string], potentialApprovers []string) sets.Set[string] {
   177  	if len(potentialApprovers) == 0 {
   178  		o.log.Debug("No potential approvers exist to filter for relevance. Does this repo have OWNERS files?")
   179  	}
   180  	keptApprovers := sets.New[string]()
   181  
   182  	unapproved := o.temporaryUnapprovedFiles(knownApprovers)
   183  
   184  	for _, suggestedApprover := range sets.List(o.GetSuggestedApprovers(reverseMap, potentialApprovers)) {
   185  		if reverseMap[suggestedApprover].Intersection(unapproved).Len() != 0 {
   186  			keptApprovers.Insert(suggestedApprover)
   187  		}
   188  	}
   189  
   190  	return keptApprovers
   191  }
   192  
   193  // GetSuggestedApprovers solves the exact cover problem, finding an approver capable of
   194  // approving every OWNERS file in the PR
   195  func (o Owners) GetSuggestedApprovers(reverseMap map[string]sets.Set[string], potentialApprovers []string) sets.Set[string] {
   196  	ap := NewApprovers(o)
   197  	for !ap.RequirementsMet() {
   198  		newApprover := findMostCoveringApprover(potentialApprovers, ap.GetCurrentApproversSet(), reverseMap, ap.UnapprovedFiles())
   199  		if newApprover == "" {
   200  			o.log.Debugf("Couldn't find/suggest approvers for each files. Unapproved: %q", sets.List(ap.UnapprovedFiles()))
   201  			return ap.GetCurrentApproversSet()
   202  		}
   203  		ap.AddApprover(newApprover, "", false)
   204  	}
   205  
   206  	return ap.GetCurrentApproversSet()
   207  }
   208  
   209  // GetOwnersSet returns a set containing all the Owners files necessary to get the PR approved
   210  func (o Owners) GetOwnersSet() sets.Set[string] {
   211  	owners := sets.New[string]()
   212  
   213  	var newFilenames []string
   214  	for _, toApprove := range o.filenames {
   215  		ownersFile := o.repo.FindApproverOwnersForFile(toApprove)
   216  		// If the ownersfile for toApprove is in the parent folder and has IsAutoApproveUnownedSubfolders enabled, we purge
   217  		// the file from our filenames list, because it doesn't need approval
   218  		if strings.Contains(filepath.Dir(filepath.Dir(toApprove)), ownersFile) && o.repo.IsAutoApproveUnownedSubfolders(ownersFile) {
   219  			continue
   220  		}
   221  		owners.Insert(o.repo.FindApproverOwnersForFile(toApprove))
   222  		newFilenames = append(newFilenames, toApprove)
   223  
   224  	}
   225  	o.filenames = newFilenames
   226  	o.removeSubdirs(owners)
   227  	return owners
   228  }
   229  
   230  // GetShuffledApprovers shuffles the potential approvers so that we don't
   231  // always suggest the same people.
   232  func (o Owners) GetShuffledApprovers() []string {
   233  	approversList := o.GetAllPotentialApprovers()
   234  	order := rand.New(rand.NewSource(o.seed)).Perm(len(approversList))
   235  	people := make([]string, 0, len(approversList))
   236  	for _, i := range order {
   237  		people = append(people, approversList[i])
   238  	}
   239  	return people
   240  }
   241  
   242  // removeSubdirs takes a set of directories as an input and removes all subdirectories.
   243  // E.g. [a, a/b/c, d/e, d/e/f] -> [a, d/e]
   244  // Subdirs will not be removed if they are configured to have no parent OWNERS files or if any
   245  // OWNERS file in the relative path between the subdir and the higher level dir is configured to
   246  // have no parent OWNERS files.
   247  func (o Owners) removeSubdirs(dirs sets.Set[string]) {
   248  	canonicalize := func(p string) string {
   249  		if p == "." {
   250  			return ""
   251  		}
   252  		return p
   253  	}
   254  	for _, dir := range sets.List(dirs) {
   255  		path := dir
   256  		for {
   257  			if o.repo.IsNoParentOwners(path) || canonicalize(path) == "" {
   258  				break
   259  			}
   260  			path = filepath.Dir(path)
   261  			if dirs.Has(canonicalize(path)) {
   262  				dirs.Delete(dir)
   263  				break
   264  			}
   265  		}
   266  	}
   267  }
   268  
   269  // Approval has the information about each approval on a PR
   270  type Approval struct {
   271  	Login     string // Login of the approver (can include uppercase)
   272  	How       string // How did the approver approved
   273  	Reference string // Where did the approver approved
   274  	NoIssue   bool   // Approval also accepts missing associated issue
   275  }
   276  
   277  // String creates a link for the approval. Use `Login` if you just want the name.
   278  func (a Approval) String() string {
   279  	return fmt.Sprintf(
   280  		`*<a href="%s" title="%s">%s</a>*`,
   281  		a.Reference,
   282  		a.How,
   283  		a.Login,
   284  	)
   285  }
   286  
   287  // Approvers is struct that provide functionality with regard to approvals of a specific
   288  // code change.
   289  type Approvers struct {
   290  	owners          Owners
   291  	approvers       map[string]Approval // The keys of this map are normalized to lowercase.
   292  	assignees       sets.Set[string]
   293  	AssociatedIssue int
   294  	RequireIssue    bool
   295  
   296  	ManuallyApproved func() bool
   297  }
   298  
   299  // CaseInsensitiveIntersection runs the intersection between to sets.Set[string] in a
   300  // case-insensitive way. It returns the lowercased intersection.
   301  func CaseInsensitiveIntersection(one, other sets.Set[string]) sets.Set[string] {
   302  	lower := sets.New[string]()
   303  	for item := range other {
   304  		lower.Insert(strings.ToLower(item))
   305  	}
   306  
   307  	intersection := sets.New[string]()
   308  	for item := range one {
   309  		if lower.Has(strings.ToLower(item)) {
   310  			intersection.Insert(item)
   311  		}
   312  	}
   313  	return intersection
   314  }
   315  
   316  // NewApprovers create a new "Approvers" with no approval.
   317  func NewApprovers(owners Owners) Approvers {
   318  	return Approvers{
   319  		owners:    owners,
   320  		approvers: map[string]Approval{},
   321  		assignees: sets.New[string](),
   322  
   323  		ManuallyApproved: func() bool {
   324  			return false
   325  		},
   326  	}
   327  }
   328  
   329  // shouldNotOverrideApproval decides whether or not we should keep the
   330  // original approval:
   331  // If someone approves a PR multiple times, we only want to keep the
   332  // latest approval, unless a previous approval was "no-issue", and the
   333  // most recent isn't.
   334  func (ap *Approvers) shouldNotOverrideApproval(login string, noIssue bool) bool {
   335  	login = strings.ToLower(login)
   336  	approval, alreadyApproved := ap.approvers[login]
   337  
   338  	return alreadyApproved && approval.NoIssue && !noIssue
   339  }
   340  
   341  // AddLGTMer adds a new LGTM Approver
   342  func (ap *Approvers) AddLGTMer(login, reference string, noIssue bool) {
   343  	if ap.shouldNotOverrideApproval(login, noIssue) {
   344  		return
   345  	}
   346  	ap.approvers[strings.ToLower(login)] = Approval{
   347  		Login:     login,
   348  		How:       "LGTM",
   349  		Reference: reference,
   350  		NoIssue:   noIssue,
   351  	}
   352  }
   353  
   354  // AddApprover adds a new Approver
   355  func (ap *Approvers) AddApprover(login, reference string, noIssue bool) {
   356  	if ap.shouldNotOverrideApproval(login, noIssue) {
   357  		return
   358  	}
   359  	ap.approvers[strings.ToLower(login)] = Approval{
   360  		Login:     login,
   361  		How:       "Approved",
   362  		Reference: reference,
   363  		NoIssue:   noIssue,
   364  	}
   365  }
   366  
   367  // AddAuthorSelfApprover adds the author self approval
   368  func (ap *Approvers) AddAuthorSelfApprover(login, reference string, noIssue bool) {
   369  	if ap.shouldNotOverrideApproval(login, noIssue) {
   370  		return
   371  	}
   372  	ap.approvers[strings.ToLower(login)] = Approval{
   373  		Login:     login,
   374  		How:       "Author self-approved",
   375  		Reference: reference,
   376  		NoIssue:   noIssue,
   377  	}
   378  }
   379  
   380  // RemoveApprover removes an approver from the list.
   381  func (ap *Approvers) RemoveApprover(login string) {
   382  	delete(ap.approvers, strings.ToLower(login))
   383  }
   384  
   385  // AddAssignees adds assignees to the list
   386  func (ap *Approvers) AddAssignees(logins ...string) {
   387  	for _, login := range logins {
   388  		ap.assignees.Insert(strings.ToLower(login))
   389  	}
   390  }
   391  
   392  // GetCurrentApproversSet returns the set of approvers (login only, normalized to lower case)
   393  func (ap Approvers) GetCurrentApproversSet() sets.Set[string] {
   394  	currentApprovers := sets.New[string]()
   395  
   396  	for approver := range ap.approvers {
   397  		currentApprovers.Insert(approver)
   398  	}
   399  
   400  	return currentApprovers
   401  }
   402  
   403  // GetCurrentApproversSetCased returns the set of approvers logins with the original cases.
   404  func (ap Approvers) GetCurrentApproversSetCased() sets.Set[string] {
   405  	currentApprovers := sets.New[string]()
   406  
   407  	for _, approval := range ap.approvers {
   408  		currentApprovers.Insert(approval.Login)
   409  	}
   410  
   411  	return currentApprovers
   412  }
   413  
   414  // GetNoIssueApproversSet returns the set of "no-issue" approvers (login
   415  // only)
   416  func (ap Approvers) GetNoIssueApproversSet() sets.Set[string] {
   417  	approvers := sets.New[string]()
   418  
   419  	for approver := range ap.NoIssueApprovers() {
   420  		approvers.Insert(approver)
   421  	}
   422  
   423  	return approvers
   424  }
   425  
   426  // GetFilesApprovers returns a map from files -> list of current approvers.
   427  func (ap Approvers) GetFilesApprovers() map[string]sets.Set[string] {
   428  	filesApprovers := map[string]sets.Set[string]{}
   429  	currentApprovers := ap.GetCurrentApproversSetCased()
   430  	for ownersFilename, potentialApprovers := range ap.owners.GetApprovers() {
   431  		// The order of parameter matters here:
   432  		// - currentApprovers is the list of github handles that have approved
   433  		// - potentialApprovers is the list of handles in the OWNER
   434  		// files (lower case).
   435  		//
   436  		// We want to keep the syntax of the github handle
   437  		// rather than the potential mis-cased username found in
   438  		// the OWNERS file, that's why it's the first parameter.
   439  		filesApprovers[ownersFilename] = CaseInsensitiveIntersection(currentApprovers, potentialApprovers)
   440  	}
   441  
   442  	return filesApprovers
   443  }
   444  
   445  // NoIssueApprovers returns the list of people who have "no-issue"
   446  // approved the pull-request. They are included in the list if they can
   447  // approve one of the files.
   448  func (ap Approvers) NoIssueApprovers() map[string]Approval {
   449  	nia := map[string]Approval{}
   450  	reverseMap := ap.owners.GetReverseMap(ap.owners.GetApprovers())
   451  
   452  	for login, approver := range ap.approvers {
   453  		if !approver.NoIssue {
   454  			continue
   455  		}
   456  
   457  		if len(reverseMap[login]) == 0 {
   458  			continue
   459  		}
   460  
   461  		nia[login] = approver
   462  	}
   463  
   464  	return nia
   465  }
   466  
   467  // UnapprovedFiles returns owners files that still need approval
   468  func (ap Approvers) UnapprovedFiles() sets.Set[string] {
   469  	unapproved := sets.New[string]()
   470  	ownersSet := ap.owners.GetOwnersSet()
   471  	currentApprovers := ap.GetCurrentApproversSetCased()
   472  
   473  	for _, toApprove := range ap.owners.filenames {
   474  		ownersFile := ap.owners.repo.FindApproverOwnersForFile(toApprove)
   475  		if !ownersSet.Has(ownersFile) {
   476  			continue
   477  		}
   478  
   479  		if CaseInsensitiveIntersection(ap.owners.repo.Approvers(toApprove).Set(), currentApprovers).Len() == 0 {
   480  			unapproved.Insert(ownersFile)
   481  		}
   482  	}
   483  	return unapproved
   484  }
   485  
   486  // GetFiles returns owners files that still need approval.
   487  func (ap Approvers) GetFiles(baseURL *url.URL, branch string) []File {
   488  	var allOwnersFiles []File
   489  	filesApprovers := ap.GetFilesApprovers()
   490  	unapproverdFiles := ap.UnapprovedFiles()
   491  	for _, file := range sets.List(ap.owners.GetOwnersSet()) {
   492  		if unapproverdFiles.Has(file) {
   493  			allOwnersFiles = append(allOwnersFiles, UnapprovedFile{
   494  				baseURL:        baseURL,
   495  				filepath:       file,
   496  				ownersFilename: ap.owners.repo.Filenames().Owners,
   497  				approvers:      filesApprovers[file],
   498  				branch:         branch,
   499  			})
   500  		} else {
   501  			allOwnersFiles = append(allOwnersFiles, ApprovedFile{
   502  				baseURL:        baseURL,
   503  				filepath:       file,
   504  				ownersFilename: ap.owners.repo.Filenames().Owners,
   505  				approvers:      filesApprovers[file],
   506  				branch:         branch,
   507  			})
   508  		}
   509  	}
   510  
   511  	return allOwnersFiles
   512  }
   513  
   514  // GetCCs gets the list of suggested approvers for a pull-request.  It
   515  // now considers current assignees as potential approvers. Here is how
   516  // it works:
   517  // - We find suggested approvers from all potential approvers, but
   518  // remove those that are not useful considering current approvers and
   519  // assignees. This only uses leaf approvers to find the closest
   520  // approvers to the changes.
   521  // - We find a subset of suggested approvers from current
   522  // approvers, suggested approvers and assignees, but we remove those
   523  // that are not useful considering suggested approvers and current
   524  // approvers. This uses the full approvers list, and will result in root
   525  // approvers to be suggested when they are assigned.
   526  // We return the union of the two sets: suggested and suggested
   527  // assignees.
   528  // The goal of this second step is to only keep the assignees that are
   529  // the most useful.
   530  func (ap Approvers) GetCCs() []string {
   531  	randomizedApprovers := ap.owners.GetShuffledApprovers()
   532  
   533  	currentApprovers := ap.GetCurrentApproversSet()
   534  	approversAndAssignees := currentApprovers.Union(ap.assignees)
   535  	leafReverseMap := ap.owners.GetReverseMap(ap.owners.GetLeafApprovers())
   536  	suggested := ap.owners.KeepCoveringApprovers(leafReverseMap, approversAndAssignees, randomizedApprovers)
   537  	approversAndSuggested := currentApprovers.Union(suggested)
   538  	everyone := approversAndSuggested.Union(ap.assignees)
   539  	fullReverseMap := ap.owners.GetReverseMap(ap.owners.GetApprovers())
   540  	keepAssignees := ap.owners.KeepCoveringApprovers(fullReverseMap, approversAndSuggested, sets.List(everyone))
   541  
   542  	return sets.List(suggested.Union(keepAssignees))
   543  }
   544  
   545  // AreFilesApproved returns a bool indicating whether or not OWNERS files associated with
   546  // the PR are approved.  A PR with no OWNERS files is not considered approved. If this
   547  // returns true, the PR may still not be fully approved depending on the associated issue
   548  // requirement
   549  func (ap Approvers) AreFilesApproved() bool {
   550  	return (len(ap.owners.filenames) != 0 || len(ap.owners.filenamesUnfiltered) != 0) && ap.UnapprovedFiles().Len() == 0
   551  }
   552  
   553  // RequirementsMet returns a bool indicating whether the PR has met all approval requirements:
   554  // - all OWNERS files associated with the PR have been approved AND
   555  // EITHER
   556  //   - the munger config is such that an issue is not required to be associated with the PR
   557  //   - that there is an associated issue with the PR
   558  //   - an OWNER has indicated that the PR is trivial enough that an issue need not be associated with the PR
   559  func (ap Approvers) RequirementsMet() bool {
   560  	return ap.AreFilesApproved() && (!ap.RequireIssue || ap.AssociatedIssue != 0 || len(ap.NoIssueApprovers()) != 0)
   561  }
   562  
   563  // IsApproved returns a bool indicating whether the PR is fully approved.
   564  // If a human manually added the approved label, this returns true, ignoring normal approval rules.
   565  func (ap Approvers) IsApproved() bool {
   566  	return ap.RequirementsMet() || ap.ManuallyApproved()
   567  }
   568  
   569  // ListApprovals returns the list of approvals
   570  func (ap Approvers) ListApprovals() []Approval {
   571  	approvals := []Approval{}
   572  
   573  	for _, approver := range sets.List(ap.GetCurrentApproversSet()) {
   574  		approvals = append(approvals, ap.approvers[approver])
   575  	}
   576  
   577  	return approvals
   578  }
   579  
   580  // ListNoIssueApprovals returns the list of "no-issue" approvals
   581  func (ap Approvers) ListNoIssueApprovals() []Approval {
   582  	approvals := []Approval{}
   583  
   584  	for _, approver := range sets.List(ap.GetNoIssueApproversSet()) {
   585  		approvals = append(approvals, ap.approvers[approver])
   586  	}
   587  
   588  	return approvals
   589  }
   590  
   591  // AssignedCCs returns potential approvers that are already assigned
   592  func (ap Approvers) AssignedCCs() []string {
   593  	return sets.List(sets.New[string](ap.GetCCs()...).Intersection(ap.assignees))
   594  }
   595  
   596  // SuggestedCCs returns potential approvers that are not already assigned
   597  func (ap Approvers) SuggestedCCs() []string {
   598  	return sets.List(sets.New[string](ap.GetCCs()...).Difference(ap.assignees))
   599  }
   600  
   601  // File in an interface for files
   602  type File interface {
   603  	String() string
   604  }
   605  
   606  // ApprovedFile contains the information of a an approved file.
   607  type ApprovedFile struct {
   608  	baseURL        *url.URL
   609  	filepath       string
   610  	ownersFilename string
   611  	// approvers is the set of users that approved this file change.
   612  	approvers sets.Set[string]
   613  	branch    string
   614  }
   615  
   616  // UnapprovedFile contains the information of a an unapproved file.
   617  type UnapprovedFile struct {
   618  	baseURL        *url.URL
   619  	filepath       string
   620  	ownersFilename string
   621  	// approvers is the set of users that partially approved this file change.
   622  	approvers sets.Set[string]
   623  	branch    string
   624  }
   625  
   626  func (a ApprovedFile) String() string {
   627  	fullOwnersPath := filepath.Join(a.filepath, a.ownersFilename)
   628  	if strings.HasSuffix(a.filepath, ".md") {
   629  		fullOwnersPath = a.filepath
   630  	}
   631  	link := fmt.Sprintf("%s/blob/%s/%v",
   632  		a.baseURL.String(),
   633  		a.branch,
   634  		fullOwnersPath,
   635  	)
   636  	return fmt.Sprintf("- ~~[%s](%s)~~ [%v]\n", fullOwnersPath, link, strings.Join(sets.List(a.approvers), ","))
   637  }
   638  
   639  func (ua UnapprovedFile) String() string {
   640  	fullOwnersPath := filepath.Join(ua.filepath, ua.ownersFilename)
   641  	if strings.HasSuffix(ua.filepath, ".md") {
   642  		fullOwnersPath = ua.filepath
   643  	}
   644  	link := fmt.Sprintf("%s/blob/%s/%v",
   645  		ua.baseURL.String(),
   646  		ua.branch,
   647  		fullOwnersPath,
   648  	)
   649  	if ua.approvers.Len() > 0 {
   650  		return fmt.Sprintf("- **[%s](%s)** [%v]\n  > Need more approvers for rest parts.\n", fullOwnersPath, link, strings.Join(sets.List(ua.approvers), ","))
   651  	}
   652  	return fmt.Sprintf("- **[%s](%s)**\n", fullOwnersPath, link)
   653  }
   654  
   655  // GenerateTemplate takes a template, name and data, and generates
   656  // the corresponding string.
   657  func GenerateTemplate(templ, name string, data interface{}) (string, error) {
   658  	buf := bytes.NewBufferString("")
   659  	if messageTempl, err := template.New(name).Parse(templ); err != nil {
   660  		return "", fmt.Errorf("failed to parse template for %s: %w", name, err)
   661  	} else if err := messageTempl.Execute(buf, data); err != nil {
   662  		return "", fmt.Errorf("failed to execute template for %s: %w", name, err)
   663  	}
   664  	return buf.String(), nil
   665  }
   666  
   667  // GetMessage returns the comment body that we want the approve plugin to display on PRs
   668  // The comment shows:
   669  //   - a list of approvers files (and links) needed to get the PR approved
   670  //   - a list of approvers files with strikethroughs that already have an approver's approval
   671  //   - a suggested list of people from each OWNERS files that can fully approve the PR
   672  //   - how an approver can indicate their approval
   673  //   - how an approver can cancel their approval
   674  func GetMessage(ap Approvers, linkURL *url.URL, commandHelpLink, prProcessLink, org, repo, branch string) *string {
   675  	linkURL.Path = org + "/" + repo
   676  	message, err := GenerateTemplate(`{{if (and (not .ap.RequirementsMet) (call .ap.ManuallyApproved )) }}
   677  Approval requirements bypassed by manually added approval.
   678  
   679  {{end -}}
   680  This pull-request has been approved by:{{range $index, $approval := .ap.ListApprovals}}{{if $index}}, {{else}} {{end}}{{$approval}}{{end}}
   681  
   682  {{- if (and (not .ap.AreFilesApproved) (not (call .ap.ManuallyApproved))) }}
   683  {{ if len .ap.SuggestedCCs -}}
   684  {{- if len .ap.AssignedCCs -}}
   685  **Once this PR has been reviewed and has the lgtm label**, please ask for approval from {{range $index, $cc := .ap.AssignedCCs}}{{if $index}}, {{end}}{{printf "[%s](https://github.com/%s)" $cc $cc}}{{end}} and additionally assign {{range $index, $cc := .ap.SuggestedCCs}}{{if $index}}, {{end}}{{printf "[%s](https://github.com/%s)" $cc $cc}}{{end}} for approval. For more information see [the Kubernetes Code Review Process]({{ .prProcessLink }}).
   686  {{- else -}}
   687  **Once this PR has been reviewed and has the lgtm label**, please assign {{range $index, $cc := .ap.SuggestedCCs}}{{if $index}}, {{end}}{{printf "[%s](https://github.com/%s)" $cc $cc}}{{end}} for approval. For more information see [the Kubernetes Code Review Process]({{ .prProcessLink }}).
   688  {{- end}}
   689  {{- else -}}
   690  {{- if len .ap.AssignedCCs -}}
   691  **Once this PR has been reviewed and has the lgtm label**, please ask for approval from {{range $index, $cc := .ap.AssignedCCs}}{{if $index}}, {{end}}{{printf "[%s](https://github.com/%s)" $cc $cc}}{{end}}. For more information see [the Kubernetes Code Review Process]({{ .prProcessLink }}).
   692  {{- end}}
   693  {{- end}}
   694  {{- end}}
   695  
   696  {{if not .ap.RequireIssue -}}
   697  {{else if .ap.AssociatedIssue -}}
   698  Associated issue: *#{{.ap.AssociatedIssue}}*
   699  
   700  {{ else if len .ap.NoIssueApprovers -}}
   701  Associated issue requirement bypassed by:{{range $index, $approval := .ap.ListNoIssueApprovals}}{{if $index}}, {{else}} {{end}}{{$approval}}{{end}}
   702  
   703  {{ else if call .ap.ManuallyApproved -}}
   704  *No associated issue*. Requirement bypassed by manually added approval.
   705  
   706  {{ else -}}
   707  *No associated issue*. Update pull-request body to add a reference to an issue, or get approval with `+"`/approve no-issue`"+`
   708  
   709  {{ end -}}
   710  
   711  The full list of commands accepted by this bot can be found [here]({{ .commandHelpLink }}?repo={{ .org }}%2F{{ .repo }}).
   712  
   713  {{ if (or .ap.AreFilesApproved (call .ap.ManuallyApproved)) -}}
   714  The pull request process is described [here]({{ .prProcessLink }})
   715  
   716  {{ end -}}
   717  <details {{if (and (not .ap.AreFilesApproved) (not (call .ap.ManuallyApproved))) }}open{{end}}>
   718  Needs approval from an approver in each of these files:
   719  
   720  {{range .ap.GetFiles .baseURL .branch}}{{.}}{{end}}
   721  Approvers can indicate their approval by writing `+"`/approve`"+` in a comment
   722  Approvers can cancel approval by writing `+"`/approve cancel`"+` in a comment
   723  </details>`, "message", map[string]interface{}{"ap": ap, "baseURL": linkURL, "commandHelpLink": commandHelpLink, "prProcessLink": prProcessLink, "org": org, "repo": repo, "branch": branch})
   724  	if err != nil {
   725  		ap.owners.log.WithError(err).Errorf("Error generating message.")
   726  		return nil
   727  	}
   728  	message += getGubernatorMetadata(ap.GetCCs())
   729  
   730  	title, err := GenerateTemplate("This PR is **{{if not .IsApproved}}NOT {{end}}APPROVED**", "title", ap)
   731  	if err != nil {
   732  		ap.owners.log.WithError(err).Errorf("Error generating title.")
   733  		return nil
   734  	}
   735  
   736  	return notification(ApprovalNotificationName, title, message)
   737  }
   738  
   739  func notification(name, arguments, context string) *string {
   740  	str := "[" + strings.ToUpper(name) + "]"
   741  
   742  	args := strings.TrimSpace(arguments)
   743  	if args != "" {
   744  		str += " " + args
   745  	}
   746  
   747  	ctx := strings.TrimSpace(context)
   748  	if ctx != "" {
   749  		str += "\n\n" + ctx
   750  	}
   751  
   752  	return &str
   753  }
   754  
   755  // getGubernatorMetadata returns a JSON string with machine-readable information about approvers.
   756  // This MUST be kept in sync with gubernator/github/classifier.py, particularly get_approvers.
   757  func getGubernatorMetadata(toBeAssigned []string) string {
   758  	bytes, err := json.Marshal(map[string][]string{"approvers": toBeAssigned})
   759  	if err == nil {
   760  		return fmt.Sprintf("\n<!-- META=%s -->", bytes)
   761  	}
   762  	return ""
   763  }