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