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