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