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