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