github.com/abayer/test-infra@v0.0.5/mungegithub/mungers/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/golang/glog"
    30  	"k8s.io/apimachinery/pkg/util/sets"
    31  )
    32  
    33  const (
    34  	ownersFileName           = "OWNERS"
    35  	ApprovalNotificationName = "ApprovalNotifier"
    36  )
    37  
    38  type RepoInterface interface {
    39  	Approvers(path string) sets.String
    40  	LeafApprovers(path string) sets.String
    41  	FindApproverOwnersForPath(path string) string
    42  }
    43  
    44  type Owners struct {
    45  	filenames []string
    46  	repo      RepoInterface
    47  	seed      int64
    48  }
    49  
    50  func NewOwners(filenames []string, r RepoInterface, s int64) Owners {
    51  	return Owners{filenames: filenames, repo: r, seed: s}
    52  }
    53  
    54  // GetApprovers returns a map from ownersFiles -> people that are approvers in them
    55  func (o Owners) GetApprovers() map[string]sets.String {
    56  	ownersToApprovers := map[string]sets.String{}
    57  
    58  	for fn := range o.GetOwnersSet() {
    59  		ownersToApprovers[fn] = o.repo.Approvers(fn)
    60  	}
    61  
    62  	return ownersToApprovers
    63  }
    64  
    65  // GetLeafApprovers returns a map from ownersFiles -> people that are approvers in them (only the leaf)
    66  func (o Owners) GetLeafApprovers() map[string]sets.String {
    67  	ownersToApprovers := map[string]sets.String{}
    68  
    69  	for fn := range o.GetOwnersSet() {
    70  		ownersToApprovers[fn] = o.repo.LeafApprovers(fn)
    71  	}
    72  
    73  	return ownersToApprovers
    74  }
    75  
    76  // GetAllPotentialApprovers returns the people from relevant owners files needed to get the PR approved
    77  func (o Owners) GetAllPotentialApprovers() []string {
    78  	approversOnly := []string{}
    79  	for _, approverList := range o.GetLeafApprovers() {
    80  		for approver := range approverList {
    81  			approversOnly = append(approversOnly, approver)
    82  		}
    83  	}
    84  	sort.Strings(approversOnly)
    85  	return approversOnly
    86  }
    87  
    88  // GetReverseMap returns a map from people -> OWNERS files for which they are an approver
    89  func (o Owners) GetReverseMap(approvers map[string]sets.String) map[string]sets.String {
    90  	approverOwnersfiles := map[string]sets.String{}
    91  	for ownersFile, approvers := range approvers {
    92  		for approver := range approvers {
    93  			if _, ok := approverOwnersfiles[approver]; ok {
    94  				approverOwnersfiles[approver].Insert(ownersFile)
    95  			} else {
    96  				approverOwnersfiles[approver] = sets.NewString(ownersFile)
    97  			}
    98  		}
    99  	}
   100  	return approverOwnersfiles
   101  }
   102  
   103  func findMostCoveringApprover(allApprovers []string, reverseMap map[string]sets.String, unapproved sets.String) string {
   104  	maxCovered := 0
   105  	var bestPerson string
   106  	for _, approver := range allApprovers {
   107  		filesCanApprove := reverseMap[approver]
   108  		if filesCanApprove.Intersection(unapproved).Len() > maxCovered {
   109  			maxCovered = len(filesCanApprove)
   110  			bestPerson = approver
   111  		}
   112  	}
   113  	return bestPerson
   114  }
   115  
   116  // temporaryUnapprovedFiles returns the list of files that wouldn't be
   117  // approved by the given set of approvers.
   118  func (o Owners) temporaryUnapprovedFiles(approvers sets.String) sets.String {
   119  	ap := NewApprovers(o)
   120  	for approver := range approvers {
   121  		ap.AddApprover(approver, "", false)
   122  	}
   123  	return ap.UnapprovedFiles()
   124  }
   125  
   126  // KeepCoveringApprovers finds who we should keep as suggested approvers given a pre-selection
   127  // knownApprovers must be a subset of potentialApprovers.
   128  func (o Owners) KeepCoveringApprovers(reverseMap map[string]sets.String, knownApprovers sets.String, potentialApprovers []string) sets.String {
   129  	keptApprovers := sets.NewString()
   130  
   131  	unapproved := o.temporaryUnapprovedFiles(knownApprovers)
   132  
   133  	for _, suggestedApprover := range o.GetSuggestedApprovers(reverseMap, potentialApprovers).List() {
   134  		if reverseMap[suggestedApprover].Intersection(unapproved).Len() != 0 {
   135  			keptApprovers.Insert(suggestedApprover)
   136  		}
   137  	}
   138  
   139  	return keptApprovers
   140  }
   141  
   142  // GetSuggestedApprovers solves the exact cover problem, finding an approver capable of
   143  // approving every OWNERS file in the PR
   144  func (o Owners) GetSuggestedApprovers(reverseMap map[string]sets.String, potentialApprovers []string) sets.String {
   145  	ap := NewApprovers(o)
   146  	for !ap.IsApproved() {
   147  		newApprover := findMostCoveringApprover(potentialApprovers, reverseMap, ap.UnapprovedFiles())
   148  		if newApprover == "" {
   149  			glog.Errorf("Couldn't find/suggest approvers for each files. Unapproved: %s", ap.UnapprovedFiles())
   150  			return ap.GetCurrentApproversSet()
   151  		}
   152  		ap.AddApprover(newApprover, "", false)
   153  	}
   154  
   155  	return ap.GetCurrentApproversSet()
   156  }
   157  
   158  // GetOwnersSet returns a set containing all the Owners files necessary to get the PR approved
   159  func (o Owners) GetOwnersSet() sets.String {
   160  	owners := sets.NewString()
   161  	for _, fn := range o.filenames {
   162  		owners.Insert(o.repo.FindApproverOwnersForPath(fn))
   163  	}
   164  	return removeSubdirs(owners.List())
   165  }
   166  
   167  // Shuffles the potential approvers so that we don't always suggest the same people
   168  func (o Owners) GetShuffledApprovers() []string {
   169  	approversList := o.GetAllPotentialApprovers()
   170  	order := rand.New(rand.NewSource(o.seed)).Perm(len(approversList))
   171  	people := make([]string, 0, len(approversList))
   172  	for _, i := range order {
   173  		people = append(people, approversList[i])
   174  	}
   175  	return people
   176  }
   177  
   178  // removeSubdirs takes a list of directories as an input and returns a set of directories with all
   179  // subdirectories removed.  E.g. [/a,/a/b/c,/d/e,/d/e/f] -> [/a, /d/e]
   180  func removeSubdirs(dirList []string) sets.String {
   181  	toDel := sets.String{}
   182  	for i := 0; i < len(dirList)-1; i++ {
   183  		for j := i + 1; j < len(dirList); j++ {
   184  			// ex /a/b has prefix /a so if remove /a/b since its already covered
   185  			if strings.HasPrefix(dirList[i], dirList[j]) {
   186  				toDel.Insert(dirList[i])
   187  			} else if strings.HasPrefix(dirList[j], dirList[i]) {
   188  				toDel.Insert(dirList[j])
   189  			}
   190  		}
   191  	}
   192  	finalSet := sets.NewString(dirList...)
   193  	finalSet.Delete(toDel.List()...)
   194  	return finalSet
   195  }
   196  
   197  // Approval has the information about each approval on a PR
   198  type Approval struct {
   199  	Login     string // Login of the approver (can include uppercase)
   200  	How       string // How did the approver approved
   201  	Reference string // Where did the approver approved
   202  	NoIssue   bool   // Approval also accepts missing associated issue
   203  }
   204  
   205  // String creates a link for the approval. Use `Login` if you just want the name.
   206  func (a Approval) String() string {
   207  	return fmt.Sprintf(
   208  		`*<a href="%s" title="%s">%s</a>*`,
   209  		a.Reference,
   210  		a.How,
   211  		a.Login,
   212  	)
   213  }
   214  
   215  type Approvers struct {
   216  	owners          Owners
   217  	approvers       map[string]Approval // The keys of this map are normalized to lowercase.
   218  	assignees       sets.String
   219  	AssociatedIssue int
   220  	RequireIssue    bool
   221  }
   222  
   223  // IntersectSetsCase runs the intersection between to sets.String in a
   224  // case-insensitive way. It returns the name with the case of "one".
   225  func IntersectSetsCase(one, other sets.String) sets.String {
   226  	lower := sets.NewString()
   227  	for item := range other {
   228  		lower.Insert(strings.ToLower(item))
   229  	}
   230  
   231  	intersection := sets.NewString()
   232  	for item := range one {
   233  		if lower.Has(strings.ToLower(item)) {
   234  			intersection.Insert(item)
   235  		}
   236  	}
   237  	return intersection
   238  }
   239  
   240  // NewApprovers create a new "Approvers" with no approval.
   241  func NewApprovers(owners Owners) Approvers {
   242  	return Approvers{
   243  		owners:    owners,
   244  		approvers: map[string]Approval{},
   245  		assignees: sets.NewString(),
   246  	}
   247  }
   248  
   249  // shouldNotOverrideApproval decides whether or not we should keep the
   250  // original approval:
   251  // If someone approves a PR multiple times, we only want to keep the
   252  // latest approval, unless a previous approval was "no-issue", and the
   253  // most recent isn't.
   254  func (ap *Approvers) shouldNotOverrideApproval(login string, noIssue bool) bool {
   255  	login = strings.ToLower(login)
   256  	approval, alreadyApproved := ap.approvers[login]
   257  
   258  	return alreadyApproved && approval.NoIssue && !noIssue
   259  }
   260  
   261  // AddLGTMer adds a new LGTM Approver
   262  func (ap *Approvers) AddLGTMer(login, reference string, noIssue bool) {
   263  	if ap.shouldNotOverrideApproval(login, noIssue) {
   264  		return
   265  	}
   266  	ap.approvers[strings.ToLower(login)] = Approval{
   267  		Login:     login,
   268  		How:       "LGTM",
   269  		Reference: reference,
   270  		NoIssue:   noIssue,
   271  	}
   272  }
   273  
   274  // AddApprover adds a new Approver
   275  func (ap *Approvers) AddApprover(login, reference string, noIssue bool) {
   276  	if ap.shouldNotOverrideApproval(login, noIssue) {
   277  		return
   278  	}
   279  	ap.approvers[strings.ToLower(login)] = Approval{
   280  		Login:     login,
   281  		How:       "Approved",
   282  		Reference: reference,
   283  		NoIssue:   noIssue,
   284  	}
   285  }
   286  
   287  // AddSAuthorSelfApprover adds the author self approval
   288  func (ap *Approvers) AddAuthorSelfApprover(login, reference string) {
   289  	if ap.shouldNotOverrideApproval(login, false) {
   290  		return
   291  	}
   292  	ap.approvers[strings.ToLower(login)] = Approval{
   293  		Login:     login,
   294  		How:       "Author self-approved",
   295  		Reference: reference,
   296  		NoIssue:   false,
   297  	}
   298  }
   299  
   300  // RemoveApprover removes an approver from the list.
   301  func (ap *Approvers) RemoveApprover(login string) {
   302  	delete(ap.approvers, strings.ToLower(login))
   303  }
   304  
   305  // AddAssignees adds assignees to the list
   306  func (ap *Approvers) AddAssignees(logins ...string) {
   307  	for _, login := range logins {
   308  		ap.assignees.Insert(strings.ToLower(login))
   309  	}
   310  }
   311  
   312  // GetCurrentApproversSet returns the set of approvers (login only, normalized to lower case)
   313  func (ap Approvers) GetCurrentApproversSet() sets.String {
   314  	currentApprovers := sets.NewString()
   315  
   316  	for approver := range ap.approvers {
   317  		currentApprovers.Insert(approver)
   318  	}
   319  
   320  	return currentApprovers
   321  }
   322  
   323  // GetCurrentApproversSetCased returns the set of approvers logins with the original cases.
   324  func (ap Approvers) GetCurrentApproversSetCased() sets.String {
   325  	currentApprovers := sets.NewString()
   326  
   327  	for _, approval := range ap.approvers {
   328  		currentApprovers.Insert(approval.Login)
   329  	}
   330  
   331  	return currentApprovers
   332  }
   333  
   334  // GetNoIssueApproversSet returns the set of "no-issue" approvers (login
   335  // only)
   336  func (ap Approvers) GetNoIssueApproversSet() sets.String {
   337  	approvers := sets.NewString()
   338  
   339  	for approver := range ap.NoIssueApprovers() {
   340  		approvers.Insert(approver)
   341  	}
   342  
   343  	return approvers
   344  }
   345  
   346  // GetFilesApprovers returns a map from files -> list of current approvers.
   347  func (ap Approvers) GetFilesApprovers() map[string]sets.String {
   348  	filesApprovers := map[string]sets.String{}
   349  	currentApprovers := ap.GetCurrentApproversSetCased()
   350  
   351  	for fn, potentialApprovers := range ap.owners.GetApprovers() {
   352  		// The order of parameter matters here:
   353  		// - currentApprovers is the list of github handles that have approved
   354  		// - potentialApprovers is the list of handles in the OWNER
   355  		// files (lower case).
   356  		//
   357  		// We want to keep the syntax of the github handle
   358  		// rather than the potential mis-cased username found in
   359  		// the OWNERS file, that's why it's the first parameter.
   360  		filesApprovers[fn] = IntersectSetsCase(currentApprovers, potentialApprovers)
   361  	}
   362  
   363  	return filesApprovers
   364  }
   365  
   366  // NoIssueApprovers returns the list of people who have "no-issue"
   367  // approved the pull-request. They are included in the list iff they can
   368  // approve one of the files.
   369  func (ap Approvers) NoIssueApprovers() map[string]Approval {
   370  	nia := map[string]Approval{}
   371  	reverseMap := ap.owners.GetReverseMap(ap.owners.GetApprovers())
   372  
   373  	for login, approver := range ap.approvers {
   374  		if !approver.NoIssue {
   375  			continue
   376  		}
   377  
   378  		if len(reverseMap[login]) == 0 {
   379  			continue
   380  		}
   381  
   382  		nia[login] = approver
   383  	}
   384  
   385  	return nia
   386  }
   387  
   388  // UnapprovedFiles returns owners files that still need approval
   389  func (ap Approvers) UnapprovedFiles() sets.String {
   390  	unapproved := sets.NewString()
   391  	for fn, approvers := range ap.GetFilesApprovers() {
   392  		if len(approvers) == 0 {
   393  			unapproved.Insert(fn)
   394  		}
   395  	}
   396  	return unapproved
   397  }
   398  
   399  // UnapprovedFiles returns owners files that still need approval
   400  func (ap Approvers) GetFiles(org, project string) []File {
   401  	allOwnersFiles := []File{}
   402  	filesApprovers := ap.GetFilesApprovers()
   403  	for _, fn := range ap.owners.GetOwnersSet().List() {
   404  		if len(filesApprovers[fn]) == 0 {
   405  			allOwnersFiles = append(allOwnersFiles, UnapprovedFile{fn, org, project})
   406  		} else {
   407  			allOwnersFiles = append(allOwnersFiles, ApprovedFile{fn, filesApprovers[fn], org, project})
   408  		}
   409  	}
   410  
   411  	return allOwnersFiles
   412  }
   413  
   414  // GetCCs gets the list of suggested approvers for a pull-request.  It
   415  // now considers current assignees as potential approvers. Here is how
   416  // it works:
   417  // - We find suggested approvers from all potential approvers, but
   418  // remove those that are not useful considering current approvers and
   419  // assignees. This only uses leave approvers to find approvers the
   420  // closest to the changes.
   421  // - We find a subset of suggested approvers from from current
   422  // approvers, suggested approvers and assignees, but we remove thoses
   423  // that are not useful considering suggestd approvers and current
   424  // approvers. This uses the full approvers list, and will result in root
   425  // approvers to be suggested when they are assigned.
   426  // We return the union of the two sets: suggested and suggested
   427  // assignees.
   428  // The goal of this second step is to only keep the assignees that are
   429  // the most useful.
   430  func (ap Approvers) GetCCs() []string {
   431  	randomizedApprovers := ap.owners.GetShuffledApprovers()
   432  
   433  	currentApprovers := ap.GetCurrentApproversSet()
   434  	approversAndAssignees := currentApprovers.Union(ap.assignees)
   435  	leafReverseMap := ap.owners.GetReverseMap(ap.owners.GetLeafApprovers())
   436  	suggested := ap.owners.KeepCoveringApprovers(leafReverseMap, approversAndAssignees, randomizedApprovers)
   437  	approversAndSuggested := currentApprovers.Union(suggested)
   438  	everyone := approversAndSuggested.Union(ap.assignees)
   439  	fullReverseMap := ap.owners.GetReverseMap(ap.owners.GetApprovers())
   440  	keepAssignees := ap.owners.KeepCoveringApprovers(fullReverseMap, approversAndSuggested, everyone.List())
   441  
   442  	return suggested.Union(keepAssignees).List()
   443  }
   444  
   445  // AreFilesApproved returns a bool indicating whether or not OWNERS files associated with
   446  // the PR are approved.  If this returns true, the PR may still not be fully approved depending
   447  // on the associated issue requirement
   448  func (ap Approvers) AreFilesApproved() bool {
   449  	return ap.UnapprovedFiles().Len() == 0
   450  }
   451  
   452  // IsApproved returns a bool indicating whether the PR is fully approved:
   453  // - all OWNERS files associated with the PR have been approved AND
   454  // EITHER
   455  // 	- the munger config is such that an issue is not required to be associated with the PR
   456  // 	- that there is an associated issue with the PR
   457  // 	- an OWNER has indicated that the PR is trivial enough that an issue need not be associated with the PR
   458  func (ap Approvers) IsApproved() bool {
   459  	return ap.AreFilesApproved() && (!ap.RequireIssue || ap.AssociatedIssue != 0 || len(ap.NoIssueApprovers()) != 0)
   460  }
   461  
   462  // ListApprovals returns the list of approvals
   463  func (ap Approvers) ListApprovals() []Approval {
   464  	approvals := []Approval{}
   465  
   466  	for _, approver := range ap.GetCurrentApproversSet().List() {
   467  		approvals = append(approvals, ap.approvers[approver])
   468  	}
   469  
   470  	return approvals
   471  }
   472  
   473  // ListNoIssueApprovals returns the list of "no-issue" approvals
   474  func (ap Approvers) ListNoIssueApprovals() []Approval {
   475  	approvals := []Approval{}
   476  
   477  	for _, approver := range ap.GetNoIssueApproversSet().List() {
   478  		approvals = append(approvals, ap.approvers[approver])
   479  	}
   480  
   481  	return approvals
   482  }
   483  
   484  type File interface {
   485  	String() string
   486  }
   487  
   488  type ApprovedFile struct {
   489  	filepath  string
   490  	approvers sets.String
   491  	org       string
   492  	project   string
   493  }
   494  
   495  type UnapprovedFile struct {
   496  	filepath string
   497  	org      string
   498  	project  string
   499  }
   500  
   501  func (a ApprovedFile) String() string {
   502  	fullOwnersPath := filepath.Join(a.filepath, ownersFileName)
   503  	link := fmt.Sprintf("https://github.com/%s/%s/blob/master/%v", a.org, a.project, fullOwnersPath)
   504  	return fmt.Sprintf("- ~~[%s](%s)~~ [%v]\n", fullOwnersPath, link, strings.Join(a.approvers.List(), ","))
   505  }
   506  
   507  func (ua UnapprovedFile) String() string {
   508  	fullOwnersPath := filepath.Join(ua.filepath, ownersFileName)
   509  	link := fmt.Sprintf("https://github.com/%s/%s/blob/master/%v", ua.org, ua.project, fullOwnersPath)
   510  	return fmt.Sprintf("- **[%s](%s)**\n", fullOwnersPath, link)
   511  }
   512  
   513  // GenerateTemplateOrFail takes a template, name and data, and generates
   514  // the corresping string. nil is returned if it fails. An error is
   515  // logged.
   516  func GenerateTemplateOrFail(templ, name string, data interface{}) *string {
   517  	buf := bytes.NewBufferString("")
   518  	if messageTempl, err := template.New(name).Parse(templ); err != nil {
   519  		glog.Errorf("Failed to generate template for %s: %s", name, err)
   520  		return nil
   521  	} else if err := messageTempl.Execute(buf, data); err != nil {
   522  		glog.Errorf("Failed to execute template for %s: %s", name, err)
   523  		return nil
   524  	}
   525  	message := buf.String()
   526  	return &message
   527  }
   528  
   529  func notification(name, arguments, context string) *string {
   530  	str := "[" + strings.ToUpper(name) + "]"
   531  
   532  	args := strings.TrimSpace(arguments)
   533  	if args != "" {
   534  		str += " " + args
   535  	}
   536  
   537  	ctx := strings.TrimSpace(context)
   538  	if ctx != "" {
   539  		str += "\n\n" + ctx
   540  	}
   541  
   542  	return &str
   543  }
   544  
   545  // getGubernatorMetadata returns a JSON string with machine-readable information about approvers.
   546  // This MUST be kept in sync with gubernator/github/classifier.py, particularly get_approvers.
   547  func getGubernatorMetadata(toBeAssigned []string) string {
   548  	bytes, err := json.Marshal(map[string][]string{"approvers": toBeAssigned})
   549  	if err == nil {
   550  		return fmt.Sprintf("\n<!-- META=%s -->", bytes)
   551  	}
   552  	return ""
   553  }