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