github.com/abayer/test-infra@v0.0.5/prow/plugins/approve/approve.go (about) 1 /* 2 Copyright 2017 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 approve 18 19 import ( 20 "fmt" 21 "regexp" 22 "sort" 23 "strconv" 24 "strings" 25 "time" 26 27 "github.com/sirupsen/logrus" 28 29 "k8s.io/test-infra/prow/github" 30 "k8s.io/test-infra/prow/pluginhelp" 31 "k8s.io/test-infra/prow/plugins" 32 "k8s.io/test-infra/prow/plugins/approve/approvers" 33 "k8s.io/test-infra/prow/repoowners" 34 ) 35 36 const ( 37 pluginName = "approve" 38 39 approveCommand = "APPROVE" 40 approvedLabel = "approved" 41 cancelArgument = "cancel" 42 lgtmCommand = "LGTM" 43 noIssueArgument = "no-issue" 44 ) 45 46 var ( 47 associatedIssueRegex = regexp.MustCompile(`(?:kubernetes/[^/]+/issues/|#)(\d+)`) 48 commandRegex = regexp.MustCompile(`(?m)^/([^\s]+)[\t ]*([^\n\r]*)`) 49 notificationRegex = regexp.MustCompile(`(?is)^\[` + approvers.ApprovalNotificationName + `\] *?([^\n]*)(?:\n\n(.*))?`) 50 51 // deprecatedBotNames are the names of the bots that previously handled approvals. 52 // Each can be removed once every PR approved by the old bot has been merged or unapproved. 53 deprecatedBotNames = []string{"k8s-merge-robot", "openshift-merge-robot"} 54 55 // handleFunc is used to allow mocking out the behavior of 'handle' while testing. 56 handleFunc = handle 57 ) 58 59 type githubClient interface { 60 GetPullRequest(org, repo string, number int) (*github.PullRequest, error) 61 GetPullRequestChanges(org, repo string, number int) ([]github.PullRequestChange, error) 62 GetIssueLabels(org, repo string, number int) ([]github.Label, error) 63 ListIssueComments(org, repo string, number int) ([]github.IssueComment, error) 64 ListReviews(org, repo string, number int) ([]github.Review, error) 65 ListPullRequestComments(org, repo string, number int) ([]github.ReviewComment, error) 66 DeleteComment(org, repo string, ID int) error 67 CreateComment(org, repo string, number int, comment string) error 68 BotName() (string, error) 69 AddLabel(org, repo string, number int, label string) error 70 RemoveLabel(org, repo string, number int, label string) error 71 ListIssueEvents(org, repo string, num int) ([]github.ListedIssueEvent, error) 72 } 73 74 type ownersClient interface { 75 LoadRepoOwners(org, repo, base string) (repoowners.RepoOwnerInterface, error) 76 } 77 78 type state struct { 79 org string 80 repo string 81 branch string 82 number int 83 84 body string 85 author string 86 assignees []github.User 87 htmlURL string 88 89 repoOptions *plugins.Approve 90 } 91 92 func init() { 93 plugins.RegisterGenericCommentHandler(pluginName, handleGenericCommentEvent, helpProvider) 94 plugins.RegisterReviewEventHandler(pluginName, handleReviewEvent, helpProvider) 95 plugins.RegisterPullRequestHandler(pluginName, handlePullRequestEvent, helpProvider) 96 } 97 98 func helpProvider(config *plugins.Configuration, enabledRepos []string) (*pluginhelp.PluginHelp, error) { 99 doNot := func(b bool) string { 100 if b { 101 return "" 102 } 103 return "do not " 104 } 105 willNot := func(b bool) string { 106 if b { 107 return "will " 108 } 109 return "will not " 110 } 111 112 approveConfig := map[string]string{} 113 for _, repo := range enabledRepos { 114 parts := strings.Split(repo, "/") 115 if len(parts) != 2 { 116 return nil, fmt.Errorf("invalid repo in enabledRepos: %q", repo) 117 } 118 opts := optionsForRepo(config, parts[0], parts[1]) 119 approveConfig[repo] = fmt.Sprintf("Pull requests %s require an associated issue.<br>Pull request authors %s implicitly approve their own PRs.<br>The /lgtm [cancel] command(s) %s act as approval.<br>A GitHub approved or changes requested review %s act as approval or cancel respectively.", doNot(opts.IssueRequired), doNot(opts.ImplicitSelfApprove), willNot(opts.LgtmActsAsApprove), willNot(opts.ReviewActsAsApprove)) 120 } 121 pluginHelp := &pluginhelp.PluginHelp{ 122 Description: `The approve plugin implements a pull request approval process that manages the '` + approvedLabel + `' label and an approval notification comment. Approval is achieved when the set of users that have approved the PR is capable of approving every file changed by the PR. A user is able to approve a file if their username or an alias they belong to is listed in the 'approvers' section of an OWNERS file in the directory of the file or higher in the directory tree. 123 <br> 124 <br>Per-repo configuration may be used to require that PRs link to an associated issue before approval is granted. It may also be used to specify that the PR authors implicitly approve their own PRs. 125 <br>For more information see <a href="https://git.k8s.io/test-infra/prow/plugins/approve/approvers/README.md">here</a>.`, 126 Config: approveConfig, 127 } 128 pluginHelp.AddCommand(pluginhelp.Command{ 129 Usage: "/approve [no-issue|cancel]", 130 Description: "Approves a pull request", 131 Featured: true, 132 WhoCanUse: "Users listed as 'approvers' in appropriate OWNERS files.", 133 Examples: []string{"/approve", "/approve no-issue"}, 134 }) 135 return pluginHelp, nil 136 } 137 138 func handleGenericCommentEvent(pc plugins.PluginClient, ce github.GenericCommentEvent) error { 139 return handleGenericComment( 140 pc.Logger, 141 pc.GitHubClient, 142 pc.OwnersClient, 143 pc.PluginConfig, 144 &ce, 145 ) 146 } 147 148 func handleGenericComment(log *logrus.Entry, ghc githubClient, oc ownersClient, config *plugins.Configuration, ce *github.GenericCommentEvent) error { 149 if ce.Action != github.GenericCommentActionCreated || !ce.IsPR || ce.IssueState == "closed" { 150 return nil 151 } 152 153 botName, err := ghc.BotName() 154 if err != nil { 155 return err 156 } 157 158 opts := optionsForRepo(config, ce.Repo.Owner.Login, ce.Repo.Name) 159 if !isApprovalCommand(botName, opts.LgtmActsAsApprove, &comment{Body: ce.Body, Author: ce.User.Login}) { 160 return nil 161 } 162 163 pr, err := ghc.GetPullRequest(ce.Repo.Owner.Login, ce.Repo.Name, ce.Number) 164 if err != nil { 165 return err 166 } 167 168 repo, err := oc.LoadRepoOwners(ce.Repo.Owner.Login, ce.Repo.Name, pr.Base.Ref) 169 if err != nil { 170 return err 171 } 172 173 return handleFunc( 174 log, 175 ghc, 176 repo, 177 opts, 178 &state{ 179 org: ce.Repo.Owner.Login, 180 repo: ce.Repo.Name, 181 branch: pr.Base.Ref, 182 number: ce.Number, 183 body: ce.IssueBody, 184 author: ce.IssueAuthor.Login, 185 assignees: ce.Assignees, 186 htmlURL: ce.IssueHTMLURL, 187 }, 188 ) 189 } 190 191 // handleReviewEvent should only handle reviews that have no approval command. 192 // Reviews with approval commands will be handled by handleGenericCommentEvent. 193 func handleReviewEvent(pc plugins.PluginClient, re github.ReviewEvent) error { 194 return handleReview( 195 pc.Logger, 196 pc.GitHubClient, 197 pc.OwnersClient, 198 pc.PluginConfig, 199 &re, 200 ) 201 } 202 203 func handleReview(log *logrus.Entry, ghc githubClient, oc ownersClient, config *plugins.Configuration, re *github.ReviewEvent) error { 204 if re.Action != github.ReviewActionSubmitted { 205 return nil 206 } 207 208 botName, err := ghc.BotName() 209 if err != nil { 210 return err 211 } 212 213 opts := optionsForRepo(config, re.Repo.Owner.Login, re.Repo.Name) 214 215 // Check for an approval command is in the body. If one exists, let the 216 // genericCommentEventHandler handle this event. Approval commands override 217 // review state. 218 if isApprovalCommand(botName, opts.LgtmActsAsApprove, &comment{Body: re.Review.Body, Author: re.Review.User.Login}) { 219 return nil 220 } 221 222 // Check for an approval command via review state. If none exists, don't 223 // handle this event. 224 if !isApprovalState(botName, opts.ReviewActsAsApprove, &comment{Author: re.Review.User.Login, ReviewState: re.Review.State}) { 225 return nil 226 } 227 228 // This is a valid review state command. Get the pull request and handle it. 229 pr, err := ghc.GetPullRequest(re.Repo.Owner.Login, re.Repo.Name, re.PullRequest.Number) 230 if err != nil { 231 log.Error(err) 232 return err 233 } 234 235 repo, err := oc.LoadRepoOwners(re.Repo.Owner.Login, re.Repo.Name, pr.Base.Ref) 236 if err != nil { 237 return err 238 } 239 240 return handleFunc( 241 log, 242 ghc, 243 repo, 244 optionsForRepo(config, re.Repo.Owner.Login, re.Repo.Name), 245 &state{ 246 org: re.Repo.Owner.Login, 247 repo: re.Repo.Name, 248 number: re.PullRequest.Number, 249 body: re.Review.Body, 250 author: re.Review.User.Login, 251 assignees: re.PullRequest.Assignees, 252 htmlURL: re.PullRequest.HTMLURL, 253 }, 254 ) 255 256 } 257 258 func handlePullRequestEvent(pc plugins.PluginClient, pre github.PullRequestEvent) error { 259 return handlePullRequest( 260 pc.Logger, 261 pc.GitHubClient, 262 pc.OwnersClient, 263 pc.PluginConfig, 264 &pre, 265 ) 266 } 267 268 func handlePullRequest(log *logrus.Entry, ghc githubClient, oc ownersClient, config *plugins.Configuration, pre *github.PullRequestEvent) error { 269 if pre.Action != github.PullRequestActionOpened && 270 pre.Action != github.PullRequestActionReopened && 271 pre.Action != github.PullRequestActionSynchronize && 272 pre.Action != github.PullRequestActionLabeled { 273 return nil 274 } 275 botName, err := ghc.BotName() 276 if err != nil { 277 return err 278 } 279 if pre.Action == github.PullRequestActionLabeled && 280 (pre.Label.Name != approvedLabel || pre.Sender.Login == botName || pre.PullRequest.State == "closed") { 281 return nil 282 } 283 284 repo, err := oc.LoadRepoOwners(pre.Repo.Owner.Login, pre.Repo.Name, pre.PullRequest.Base.Ref) 285 if err != nil { 286 return err 287 } 288 289 return handleFunc( 290 log, 291 ghc, 292 repo, 293 optionsForRepo(config, pre.Repo.Owner.Login, pre.Repo.Name), 294 &state{ 295 org: pre.Repo.Owner.Login, 296 repo: pre.Repo.Name, 297 branch: pre.PullRequest.Base.Ref, 298 number: pre.Number, 299 body: pre.PullRequest.Body, 300 author: pre.PullRequest.User.Login, 301 assignees: pre.PullRequest.Assignees, 302 htmlURL: pre.PullRequest.HTMLURL, 303 }, 304 ) 305 } 306 307 // Returns associated issue, or 0 if it can't find any. 308 // This is really simple, and could be improved later. 309 func findAssociatedIssue(body string) int { 310 match := associatedIssueRegex.FindStringSubmatch(body) 311 if len(match) == 0 { 312 return 0 313 } 314 v, err := strconv.Atoi(match[1]) 315 if err != nil { 316 return 0 317 } 318 return v 319 } 320 321 // handle is the workhorse the will actually make updates to the PR. 322 // The algorithm goes as: 323 // - Initially, we build an approverSet 324 // - Go through all comments in order of creation. 325 // - (Issue/PR comments, PR review comments, and PR review bodies are considered as comments) 326 // - If anyone said "/approve", add them to approverSet. 327 // - If anyone said "/lgtm" AND LgtmActsAsApprove is enabled, add them to approverSet. 328 // - If anyone created an approved review AND ReviewActsAsApprove is enabled, add them to approverSet. 329 // - Then, for each file, we see if any approver of this file is in approverSet and keep track of files without approval 330 // - An approver of a file is defined as: 331 // - Someone listed as an "approver" in an OWNERS file in the files directory OR 332 // - in one of the file's parent directories 333 // - Iff all files have been approved, the bot will add the "approved" label. 334 // - Iff a cancel command is found, that reviewer will be removed from the approverSet 335 // and the munger will remove the approved label if it has been applied 336 func handle(log *logrus.Entry, ghc githubClient, repo approvers.RepoInterface, opts *plugins.Approve, pr *state) error { 337 fetchErr := func(context string, err error) error { 338 return fmt.Errorf("failed to get %s for %s/%s#%d: %v", context, pr.org, pr.repo, pr.number, err) 339 } 340 341 changes, err := ghc.GetPullRequestChanges(pr.org, pr.repo, pr.number) 342 if err != nil { 343 return fetchErr("PR file changes", err) 344 } 345 var filenames []string 346 for _, change := range changes { 347 filenames = append(filenames, change.Filename) 348 } 349 labels, err := ghc.GetIssueLabels(pr.org, pr.repo, pr.number) 350 if err != nil { 351 return fetchErr("issue labels", err) 352 } 353 hasApprovedLabel := false 354 for _, label := range labels { 355 if label.Name == approvedLabel { 356 hasApprovedLabel = true 357 break 358 } 359 } 360 botName, err := ghc.BotName() 361 if err != nil { 362 return fetchErr("bot name", err) 363 } 364 issueComments, err := ghc.ListIssueComments(pr.org, pr.repo, pr.number) 365 if err != nil { 366 return fetchErr("issue comments", err) 367 } 368 reviewComments, err := ghc.ListPullRequestComments(pr.org, pr.repo, pr.number) 369 if err != nil { 370 return fetchErr("review comments", err) 371 } 372 reviews, err := ghc.ListReviews(pr.org, pr.repo, pr.number) 373 if err != nil { 374 return fetchErr("reviews", err) 375 } 376 377 approversHandler := approvers.NewApprovers( 378 approvers.NewOwners( 379 log, 380 filenames, 381 repo, 382 int64(pr.number), 383 ), 384 ) 385 approversHandler.AssociatedIssue = findAssociatedIssue(pr.body) 386 approversHandler.RequireIssue = opts.IssueRequired 387 approversHandler.ManuallyApproved = humanAddedApproved(ghc, log, pr.org, pr.repo, pr.number, botName, hasApprovedLabel) 388 389 // Author implicitly approves their own PR if config allows it 390 if opts.ImplicitSelfApprove { 391 approversHandler.AddAuthorSelfApprover(pr.author, pr.htmlURL+"#", false) 392 } else { 393 // Treat the author as an assignee, and suggest them if possible 394 approversHandler.AddAssignees(pr.author) 395 } 396 397 commentsFromIssueComments := commentsFromIssueComments(issueComments) 398 comments := append(commentsFromReviewComments(reviewComments), commentsFromIssueComments...) 399 comments = append(comments, commentsFromReviews(reviews)...) 400 sort.SliceStable(comments, func(i, j int) bool { 401 return comments[i].CreatedAt.Before(comments[j].CreatedAt) 402 }) 403 approveComments := filterComments(comments, approvalMatcher(botName, opts.LgtmActsAsApprove, opts.ReviewActsAsApprove)) 404 addApprovers(&approversHandler, approveComments, pr.author, opts.ReviewActsAsApprove) 405 406 for _, user := range pr.assignees { 407 approversHandler.AddAssignees(user.Login) 408 } 409 410 notifications := filterComments(commentsFromIssueComments, notificationMatcher(botName)) 411 latestNotification := getLast(notifications) 412 newMessage := updateNotification(pr.org, pr.repo, pr.branch, latestNotification, approversHandler) 413 if newMessage != nil { 414 for _, notif := range notifications { 415 if err := ghc.DeleteComment(pr.org, pr.repo, notif.ID); err != nil { 416 log.WithError(err).Errorf("Failed to delete comment from %s/%s#%d, ID: %d.", pr.org, pr.repo, pr.number, notif.ID) 417 } 418 } 419 if err := ghc.CreateComment(pr.org, pr.repo, pr.number, *newMessage); err != nil { 420 log.WithError(err).Errorf("Failed to create comment on %s/%s#%d: %q.", pr.org, pr.repo, pr.number, *newMessage) 421 } 422 } 423 424 if !approversHandler.IsApproved() { 425 if hasApprovedLabel { 426 if err := ghc.RemoveLabel(pr.org, pr.repo, pr.number, approvedLabel); err != nil { 427 log.WithError(err).Errorf("Failed to remove %q label from %s/%s#%d.", approvedLabel, pr.org, pr.repo, pr.number) 428 } 429 } 430 } else if !hasApprovedLabel { 431 if err := ghc.AddLabel(pr.org, pr.repo, pr.number, approvedLabel); err != nil { 432 log.WithError(err).Errorf("Failed to add %q label to %s/%s#%d.", approvedLabel, pr.org, pr.repo, pr.number) 433 } 434 } 435 return nil 436 } 437 438 func humanAddedApproved(ghc githubClient, log *logrus.Entry, org, repo string, number int, botName string, hasLabel bool) func() bool { 439 findOut := func() bool { 440 if !hasLabel { 441 return false 442 } 443 events, err := ghc.ListIssueEvents(org, repo, number) 444 if err != nil { 445 log.WithError(err).Errorf("Failed to list issue events for %s/%s#%d.", org, repo, number) 446 return false 447 } 448 var lastAdded github.ListedIssueEvent 449 for _, event := range events { 450 // Only consider "approved" label added events. 451 if event.Event != github.IssueActionLabeled || event.Label.Name != approvedLabel { 452 continue 453 } 454 lastAdded = event 455 } 456 457 if lastAdded.Actor.Login == "" || lastAdded.Actor.Login == botName || isDeprecatedBot(lastAdded.Actor.Login) { 458 return false 459 } 460 return true 461 } 462 463 var cache *bool 464 return func() bool { 465 if cache == nil { 466 val := findOut() 467 cache = &val 468 } 469 return *cache 470 } 471 } 472 473 func approvalMatcher(botName string, lgtmActsAsApprove, reviewActsAsApprove bool) func(*comment) bool { 474 return func(c *comment) bool { 475 return isApprovalCommand(botName, lgtmActsAsApprove, c) || isApprovalState(botName, reviewActsAsApprove, c) 476 } 477 } 478 479 func isApprovalCommand(botName string, lgtmActsAsApprove bool, c *comment) bool { 480 if c.Author == botName || isDeprecatedBot(c.Author) { 481 return false 482 } 483 484 for _, match := range commandRegex.FindAllStringSubmatch(c.Body, -1) { 485 cmd := strings.ToUpper(match[1]) 486 if (cmd == lgtmCommand && lgtmActsAsApprove) || cmd == approveCommand { 487 return true 488 } 489 } 490 return false 491 } 492 493 func isApprovalState(botName string, reviewActsAsApprove bool, c *comment) bool { 494 if c.Author == botName || isDeprecatedBot(c.Author) { 495 return false 496 } 497 498 // consider reviews in either approved OR requested changes states as 499 // approval commands. Reviews in requested changes states will be 500 // interpreted as cancelled approvals. 501 if reviewActsAsApprove && (c.ReviewState == github.ReviewStateApproved || c.ReviewState == github.ReviewStateChangesRequested) { 502 return true 503 } 504 return false 505 } 506 507 func notificationMatcher(botName string) func(*comment) bool { 508 return func(c *comment) bool { 509 if c.Author != botName && !isDeprecatedBot(c.Author) { 510 return false 511 } 512 match := notificationRegex.FindStringSubmatch(c.Body) 513 return len(match) > 0 514 } 515 } 516 517 func updateNotification(org, project, branch string, latestNotification *comment, approversHandler approvers.Approvers) *string { 518 message := approvers.GetMessage(approversHandler, org, project, branch) 519 if message == nil || (latestNotification != nil && strings.Contains(latestNotification.Body, *message)) { 520 return nil 521 } 522 return message 523 } 524 525 // addApprovers iterates through the list of comments on a PR 526 // and identifies all of the people that have said /approve and adds 527 // them to the Approvers. The function uses the latest approve or cancel comment 528 // to determine the Users intention. A review in requested changes state is 529 // considered a cancel. 530 func addApprovers(approversHandler *approvers.Approvers, approveComments []*comment, author string, reviewActsAsApprove bool) { 531 for _, c := range approveComments { 532 if c.Author == "" { 533 continue 534 } 535 536 if reviewActsAsApprove && c.ReviewState == github.ReviewStateApproved { 537 approversHandler.AddApprover( 538 c.Author, 539 c.HTMLURL, 540 false, 541 ) 542 } 543 if reviewActsAsApprove && c.ReviewState == github.ReviewStateChangesRequested { 544 approversHandler.RemoveApprover(c.Author) 545 } 546 547 for _, match := range commandRegex.FindAllStringSubmatch(c.Body, -1) { 548 name := strings.ToUpper(match[1]) 549 if name != approveCommand && name != lgtmCommand { 550 continue 551 } 552 args := strings.ToLower(strings.TrimSpace(match[2])) 553 if strings.Contains(args, cancelArgument) { 554 approversHandler.RemoveApprover(c.Author) 555 continue 556 } 557 558 if c.Author == author { 559 approversHandler.AddAuthorSelfApprover( 560 c.Author, 561 c.HTMLURL, 562 args == noIssueArgument, 563 ) 564 } 565 566 if name == approveCommand { 567 approversHandler.AddApprover( 568 c.Author, 569 c.HTMLURL, 570 args == noIssueArgument, 571 ) 572 } else { 573 approversHandler.AddLGTMer( 574 c.Author, 575 c.HTMLURL, 576 args == noIssueArgument, 577 ) 578 } 579 580 } 581 } 582 } 583 584 // optionsForRepo gets the plugins.Approve struct that is applicable to the indicated repo. 585 func optionsForRepo(config *plugins.Configuration, org, repo string) *plugins.Approve { 586 fullName := fmt.Sprintf("%s/%s", org, repo) 587 for i := range config.Approve { 588 if !strInSlice(org, config.Approve[i].Repos) && !strInSlice(fullName, config.Approve[i].Repos) { 589 continue 590 } 591 return &config.Approve[i] 592 } 593 // Default to no issue required and no implicit self approval. 594 return &plugins.Approve{} 595 } 596 597 func strInSlice(str string, slice []string) bool { 598 for _, elem := range slice { 599 if elem == str { 600 return true 601 } 602 } 603 return false 604 } 605 606 type comment struct { 607 Body string 608 Author string 609 CreatedAt time.Time 610 HTMLURL string 611 ID int 612 ReviewState github.ReviewState 613 } 614 615 func commentFromIssueComment(ic *github.IssueComment) *comment { 616 if ic == nil { 617 return nil 618 } 619 return &comment{ 620 Body: ic.Body, 621 Author: ic.User.Login, 622 CreatedAt: ic.CreatedAt, 623 HTMLURL: ic.HTMLURL, 624 ID: ic.ID, 625 } 626 } 627 628 func commentsFromIssueComments(ics []github.IssueComment) []*comment { 629 comments := []*comment{} 630 for i := range ics { 631 comments = append(comments, commentFromIssueComment(&ics[i])) 632 } 633 return comments 634 } 635 636 func commentFromReviewComment(rc *github.ReviewComment) *comment { 637 if rc == nil { 638 return nil 639 } 640 return &comment{ 641 Body: rc.Body, 642 Author: rc.User.Login, 643 CreatedAt: rc.CreatedAt, 644 HTMLURL: rc.HTMLURL, 645 ID: rc.ID, 646 } 647 } 648 649 func commentsFromReviewComments(rcs []github.ReviewComment) []*comment { 650 comments := []*comment{} 651 for i := range rcs { 652 comments = append(comments, commentFromReviewComment(&rcs[i])) 653 } 654 return comments 655 } 656 657 func commentFromReview(review *github.Review) *comment { 658 if review == nil { 659 return nil 660 } 661 return &comment{ 662 Body: review.Body, 663 Author: review.User.Login, 664 CreatedAt: review.SubmittedAt, 665 HTMLURL: review.HTMLURL, 666 ID: review.ID, 667 ReviewState: review.State, 668 } 669 } 670 671 func commentsFromReviews(reviews []github.Review) []*comment { 672 comments := []*comment{} 673 for i := range reviews { 674 comments = append(comments, commentFromReview(&reviews[i])) 675 } 676 return comments 677 } 678 679 func filterComments(comments []*comment, filter func(*comment) bool) []*comment { 680 var filtered []*comment 681 for _, c := range comments { 682 if filter(c) { 683 filtered = append(filtered, c) 684 } 685 } 686 return filtered 687 } 688 689 func getLast(cs []*comment) *comment { 690 if len(cs) == 0 { 691 return nil 692 } 693 return cs[len(cs)-1] 694 } 695 696 func isDeprecatedBot(login string) bool { 697 for _, deprecated := range deprecatedBotNames { 698 if deprecated == login { 699 return true 700 } 701 } 702 return false 703 }