sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/plugins/lgtm/lgtm_test.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 lgtm 18 19 import ( 20 "fmt" 21 "os" 22 "path/filepath" 23 "regexp" 24 "strings" 25 "testing" 26 "time" 27 28 "github.com/sirupsen/logrus" 29 "sigs.k8s.io/yaml" 30 31 "k8s.io/apimachinery/pkg/api/equality" 32 "k8s.io/apimachinery/pkg/util/sets" 33 34 "sigs.k8s.io/prow/pkg/config" 35 "sigs.k8s.io/prow/pkg/github" 36 "sigs.k8s.io/prow/pkg/github/fakegithub" 37 "sigs.k8s.io/prow/pkg/layeredsets" 38 "sigs.k8s.io/prow/pkg/plugins" 39 "sigs.k8s.io/prow/pkg/plugins/ownersconfig" 40 "sigs.k8s.io/prow/pkg/repoowners" 41 ) 42 43 type fakeOwnersClient struct { 44 approvers map[string]layeredsets.String 45 reviewers map[string]layeredsets.String 46 } 47 48 func (f *fakeOwnersClient) LoadRepoOwnersSha(org, repo, base, sha string, updateCache bool) (repoowners.RepoOwner, error) { 49 return &fakeRepoOwners{approvers: f.approvers, reviewers: f.reviewers}, nil 50 } 51 52 var _ repoowners.Interface = &fakeOwnersClient{} 53 54 func (f *fakeOwnersClient) LoadRepoOwners(org, repo, base string) (repoowners.RepoOwner, error) { 55 return &fakeRepoOwners{approvers: f.approvers, reviewers: f.reviewers}, nil 56 } 57 58 func (f *fakeOwnersClient) WithFields(fields logrus.Fields) repoowners.Interface { 59 return f 60 } 61 62 func (f *fakeOwnersClient) WithGitHubClient(client github.Client) repoowners.Interface { 63 return f 64 } 65 66 func (f *fakeOwnersClient) ForPlugin(string) repoowners.Interface { 67 return f 68 } 69 70 func (f *fakeOwnersClient) Used() bool { 71 return true 72 } 73 74 type fakeRepoOwners struct { 75 approvers map[string]layeredsets.String 76 reviewers map[string]layeredsets.String 77 dirDenylist []*regexp.Regexp 78 } 79 80 func (f *fakeRepoOwners) AllApprovers() sets.Set[string] { 81 return sets.Set[string]{} 82 } 83 84 func (f *fakeRepoOwners) AllOwners() sets.Set[string] { 85 return sets.Set[string]{} 86 } 87 88 func (f *fakeRepoOwners) AllReviewers() sets.Set[string] { 89 return sets.Set[string]{} 90 } 91 92 func (f *fakeRepoOwners) Filenames() ownersconfig.Filenames { 93 return ownersconfig.FakeFilenames 94 } 95 96 type fakePruner struct { 97 GitHubClient *fakegithub.FakeClient 98 IssueComments []github.IssueComment 99 } 100 101 func (fp *fakePruner) PruneComments(shouldPrune func(github.IssueComment) bool) { 102 for _, comment := range fp.IssueComments { 103 if shouldPrune(comment) { 104 fp.GitHubClient.IssueCommentsDeleted = append(fp.GitHubClient.IssueCommentsDeleted, comment.Body) 105 } 106 } 107 } 108 109 var _ repoowners.RepoOwner = &fakeRepoOwners{} 110 111 func (f *fakeRepoOwners) FindApproverOwnersForFile(path string) string { return "" } 112 func (f *fakeRepoOwners) FindReviewersOwnersForFile(path string) string { return "" } 113 func (f *fakeRepoOwners) FindLabelsForFile(path string) sets.Set[string] { return nil } 114 func (f *fakeRepoOwners) IsNoParentOwners(path string) bool { return false } 115 func (f *fakeRepoOwners) IsAutoApproveUnownedSubfolders(path string) bool { return false } 116 func (f *fakeRepoOwners) LeafApprovers(path string) sets.Set[string] { return nil } 117 func (f *fakeRepoOwners) Approvers(path string) layeredsets.String { return f.approvers[path] } 118 func (f *fakeRepoOwners) LeafReviewers(path string) sets.Set[string] { return nil } 119 func (f *fakeRepoOwners) Reviewers(path string) layeredsets.String { return f.reviewers[path] } 120 func (f *fakeRepoOwners) RequiredReviewers(path string) sets.Set[string] { return nil } 121 func (f *fakeRepoOwners) TopLevelApprovers() sets.Set[string] { return nil } 122 123 func (f *fakeRepoOwners) ParseSimpleConfig(path string) (repoowners.SimpleConfig, error) { 124 dir := filepath.Dir(path) 125 for _, re := range f.dirDenylist { 126 if re.MatchString(dir) { 127 return repoowners.SimpleConfig{}, filepath.SkipDir 128 } 129 } 130 131 b, err := os.ReadFile(path) 132 if err != nil { 133 return repoowners.SimpleConfig{}, err 134 } 135 full := new(repoowners.SimpleConfig) 136 err = yaml.Unmarshal(b, full) 137 return *full, err 138 } 139 140 func (f *fakeRepoOwners) ParseFullConfig(path string) (repoowners.FullConfig, error) { 141 dir := filepath.Dir(path) 142 for _, re := range f.dirDenylist { 143 if re.MatchString(dir) { 144 return repoowners.FullConfig{}, filepath.SkipDir 145 } 146 } 147 148 b, err := os.ReadFile(path) 149 if err != nil { 150 return repoowners.FullConfig{}, err 151 } 152 full := new(repoowners.FullConfig) 153 err = yaml.Unmarshal(b, full) 154 return *full, err 155 } 156 157 var approvers = map[string]layeredsets.String{ 158 "doc/README.md": layeredsets.NewString("cjwagner", "jessica"), 159 } 160 161 var reviewers = map[string]layeredsets.String{ 162 "doc/README.md": layeredsets.NewString("alice", "bob", "mark", "sam"), 163 } 164 165 func TestLGTMComment(t *testing.T) { 166 var testcases = []struct { 167 name string 168 body string 169 commenter string 170 hasLGTM bool 171 shouldToggle bool 172 shouldComment bool 173 shouldAssign bool 174 skipCollab bool 175 storeTreeHash bool 176 shouldRequest bool 177 }{ 178 { 179 name: "non-lgtm comment", 180 body: "uh oh", 181 commenter: "collab2", 182 hasLGTM: false, 183 shouldToggle: false, 184 }, 185 { 186 name: "lgtm comment by reviewer, no lgtm on pr", 187 body: "/lgtm", 188 commenter: "collab1", 189 hasLGTM: false, 190 shouldToggle: true, 191 shouldComment: true, 192 }, 193 { 194 name: "LGTM comment by reviewer, no lgtm on pr", 195 body: "/LGTM", 196 commenter: "collab1", 197 hasLGTM: false, 198 shouldToggle: true, 199 shouldComment: true, 200 }, 201 { 202 name: "lgtm comment by reviewer, lgtm on pr", 203 body: "/lgtm", 204 commenter: "collab1", 205 hasLGTM: true, 206 shouldToggle: false, 207 }, 208 { 209 name: "lgtm comment by author", 210 body: "/lgtm", 211 commenter: "author", 212 hasLGTM: false, 213 shouldToggle: false, 214 shouldComment: true, 215 }, 216 { 217 name: "lgtm cancel by author", 218 body: "/lgtm cancel", 219 commenter: "author", 220 hasLGTM: true, 221 shouldToggle: true, 222 shouldAssign: false, 223 shouldComment: false, 224 shouldRequest: true, 225 }, 226 { 227 name: "remove lgtm by author", 228 body: "/remove-lgtm", 229 commenter: "author", 230 hasLGTM: true, 231 shouldToggle: true, 232 shouldAssign: false, 233 shouldComment: false, 234 shouldRequest: true, 235 }, 236 { 237 name: "lgtm comment by non-reviewer", 238 body: "/lgtm", 239 commenter: "collab2", 240 hasLGTM: false, 241 shouldToggle: true, 242 shouldComment: true, 243 shouldAssign: true, 244 }, 245 { 246 name: "lgtm comment by non-reviewer, with trailing space", 247 body: "/lgtm ", 248 commenter: "collab2", 249 hasLGTM: false, 250 shouldToggle: true, 251 shouldComment: true, 252 shouldAssign: true, 253 }, 254 { 255 name: "lgtm comment by non-reviewer, with no-issue", 256 body: "/lgtm no-issue", 257 commenter: "collab2", 258 hasLGTM: false, 259 shouldToggle: true, 260 shouldComment: true, 261 shouldAssign: true, 262 }, 263 { 264 name: "lgtm comment by non-reviewer, with no-issue and trailing space", 265 body: "/lgtm no-issue \r", 266 commenter: "collab2", 267 hasLGTM: false, 268 shouldToggle: true, 269 shouldComment: true, 270 shouldAssign: true, 271 }, 272 { 273 name: "lgtm comment by rando", 274 body: "/lgtm", 275 commenter: "not-in-the-org", 276 hasLGTM: false, 277 shouldToggle: false, 278 shouldComment: true, 279 shouldAssign: false, 280 }, 281 { 282 name: "lgtm cancel by non-reviewer", 283 body: "/lgtm cancel", 284 commenter: "collab2", 285 hasLGTM: true, 286 shouldToggle: true, 287 shouldComment: false, 288 shouldAssign: true, 289 shouldRequest: true, 290 }, 291 { 292 name: "remove lgtm by non-reviewer", 293 body: "/remove-lgtm", 294 commenter: "collab2", 295 hasLGTM: true, 296 shouldToggle: true, 297 shouldComment: false, 298 shouldAssign: true, 299 shouldRequest: true, 300 }, 301 { 302 name: "lgtm cancel by rando", 303 body: "/lgtm cancel", 304 commenter: "not-in-the-org", 305 hasLGTM: true, 306 shouldToggle: false, 307 shouldComment: true, 308 shouldAssign: false, 309 }, 310 { 311 name: "remove lgtm by rando", 312 body: "/remove-lgtm", 313 commenter: "not-in-the-org", 314 hasLGTM: true, 315 shouldToggle: false, 316 shouldComment: true, 317 shouldAssign: false, 318 }, 319 { 320 name: "lgtm cancel comment by reviewer", 321 body: "/lgtm cancel", 322 commenter: "collab1", 323 hasLGTM: true, 324 shouldToggle: true, 325 shouldRequest: true, 326 }, 327 { 328 name: "remove-lgtm comment by reviewer", 329 body: "/remove-lgtm", 330 commenter: "collab1", 331 hasLGTM: true, 332 shouldToggle: true, 333 shouldRequest: true, 334 }, 335 { 336 name: "lgtm cancel comment by reviewer, with trailing space", 337 body: "/lgtm cancel \r", 338 commenter: "collab1", 339 hasLGTM: true, 340 shouldToggle: true, 341 shouldRequest: true, 342 }, 343 { 344 name: "remove lgtm comment by reviewer, with trailing space", 345 body: "/remove-lgtm \r", 346 commenter: "collab1", 347 hasLGTM: true, 348 shouldToggle: true, 349 shouldRequest: true, 350 }, 351 { 352 name: "lgtm cancel comment by reviewer, no lgtm", 353 body: "/lgtm cancel", 354 commenter: "collab1", 355 hasLGTM: false, 356 shouldToggle: false, 357 }, 358 { 359 name: "remove lgtm comment by reviewer, no lgtm", 360 body: "/remove-lgtm", 361 commenter: "collab1", 362 hasLGTM: false, 363 shouldToggle: false, 364 }, 365 { 366 name: "lgtm comment, based off OWNERS only", 367 body: "/lgtm", 368 commenter: "sam", 369 hasLGTM: false, 370 shouldToggle: true, 371 shouldComment: true, 372 skipCollab: true, 373 }, 374 { 375 name: "lgtm comment by assignee, but not collab", 376 body: "/lgtm", 377 commenter: "assignee1", 378 hasLGTM: false, 379 shouldToggle: false, 380 shouldComment: true, 381 shouldAssign: false, 382 }, 383 } 384 SHA := "0bd3ed50c88cd53a09316bf7a298f900e9371652" 385 for _, tc := range testcases { 386 t.Logf("Running scenario %q", tc.name) 387 fc := fakegithub.NewFakeClient() 388 fc.IssueComments = make(map[int][]github.IssueComment) 389 fc.PullRequests = map[int]*github.PullRequest{ 390 5: { 391 Base: github.PullRequestBranch{ 392 Ref: "master", 393 }, 394 Head: github.PullRequestBranch{ 395 SHA: SHA, 396 }, 397 }, 398 } 399 fc.PullRequestChanges = map[int][]github.PullRequestChange{ 400 5: { 401 {Filename: "doc/README.md"}, 402 }, 403 } 404 fc.Collaborators = []string{"collab1", "collab2"} 405 e := &github.GenericCommentEvent{ 406 Action: github.GenericCommentActionCreated, 407 IssueState: "open", 408 IsPR: true, 409 Body: tc.body, 410 User: github.User{Login: tc.commenter}, 411 IssueAuthor: github.User{Login: "author"}, 412 Number: 5, 413 Assignees: []github.User{{Login: "collab1"}, {Login: "assignee1"}}, 414 Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"}, 415 HTMLURL: "<url>", 416 } 417 if tc.hasLGTM { 418 fc.IssueLabelsAdded = []string{"org/repo#5:" + LGTMLabel} 419 } 420 oc := &fakeOwnersClient{approvers: approvers, reviewers: reviewers} 421 pc := &plugins.Configuration{} 422 if tc.skipCollab { 423 pc.Owners.SkipCollaborators = []string{"org/repo"} 424 } 425 pc.Lgtm = append(pc.Lgtm, plugins.Lgtm{ 426 Repos: []string{"org/repo"}, 427 StoreTreeHash: true, 428 }) 429 fp := &fakePruner{ 430 GitHubClient: fc, 431 IssueComments: fc.IssueComments[5], 432 } 433 if err := handleGenericComment(fc, pc, oc, logrus.WithField("plugin", PluginName), fp, *e); err != nil { 434 t.Errorf("didn't expect error from lgtmComment: %v", err) 435 continue 436 } 437 if tc.shouldAssign { 438 found := false 439 for _, a := range fc.AssigneesAdded { 440 if a == fmt.Sprintf("%s/%s#%d:%s", "org", "repo", 5, tc.commenter) { 441 found = true 442 break 443 } 444 } 445 if !found || len(fc.AssigneesAdded) != 1 { 446 t.Errorf("should have assigned %s but added assignees are %s", tc.commenter, fc.AssigneesAdded) 447 } 448 } else if len(fc.AssigneesAdded) != 0 { 449 t.Errorf("should not have assigned anyone but assigned %s", fc.AssigneesAdded) 450 } 451 if tc.shouldToggle { 452 if tc.hasLGTM { 453 if len(fc.IssueLabelsRemoved) == 0 { 454 t.Error("should have removed LGTM.") 455 } else if len(fc.IssueLabelsAdded) > 1 { 456 t.Error("should not have added LGTM.") 457 } 458 } else { 459 if len(fc.IssueLabelsAdded) == 0 { 460 t.Error("should have added LGTM.") 461 } else if len(fc.IssueLabelsRemoved) > 0 { 462 t.Error("should not have removed LGTM.") 463 } 464 } 465 } else if len(fc.IssueLabelsRemoved) > 0 { 466 t.Error("should not have removed LGTM.") 467 } else if (tc.hasLGTM && len(fc.IssueLabelsAdded) > 1) || (!tc.hasLGTM && len(fc.IssueLabelsAdded) > 0) { 468 t.Error("should not have added LGTM.") 469 } 470 if tc.shouldComment && len(fc.IssueComments[5]) != 1 { 471 t.Error("should have commented.") 472 } else if !tc.shouldComment && len(fc.IssueComments[5]) != 0 { 473 t.Error("should not have commented.") 474 } 475 if tc.shouldRequest && len(fc.ReviewersRequested) == 0 { 476 t.Error("should have re-requested reviewers") 477 } 478 if !tc.shouldRequest && len(fc.ReviewersRequested) > 0 { 479 t.Errorf("should not have re-requested reviewers, but requested these reviewers %v", fc.ReviewersRequested) 480 } 481 } 482 } 483 484 func TestLGTMCommentWithLGTMNoti(t *testing.T) { 485 var testcases = []struct { 486 name string 487 body string 488 commenter string 489 shouldDelete bool 490 }{ 491 { 492 name: "non-lgtm comment", 493 body: "uh oh", 494 commenter: "collab2", 495 shouldDelete: false, 496 }, 497 { 498 name: "lgtm comment by reviewer, no lgtm on pr", 499 body: "/lgtm", 500 commenter: "collab1", 501 shouldDelete: true, 502 }, 503 { 504 name: "LGTM comment by reviewer, no lgtm on pr", 505 body: "/LGTM", 506 commenter: "collab1", 507 shouldDelete: true, 508 }, 509 { 510 name: "lgtm comment by author", 511 body: "/lgtm", 512 commenter: "author", 513 shouldDelete: false, 514 }, 515 { 516 name: "lgtm comment by non-reviewer", 517 body: "/lgtm", 518 commenter: "collab2", 519 shouldDelete: true, 520 }, 521 { 522 name: "lgtm comment by non-reviewer, with trailing space", 523 body: "/lgtm ", 524 commenter: "collab2", 525 shouldDelete: true, 526 }, 527 { 528 name: "lgtm comment by non-reviewer, with no-issue", 529 body: "/lgtm no-issue", 530 commenter: "collab2", 531 shouldDelete: true, 532 }, 533 { 534 name: "lgtm comment by non-reviewer, with no-issue and trailing space", 535 body: "/lgtm no-issue \r", 536 commenter: "collab2", 537 shouldDelete: true, 538 }, 539 { 540 name: "lgtm comment by rando", 541 body: "/lgtm", 542 commenter: "not-in-the-org", 543 shouldDelete: false, 544 }, 545 { 546 name: "lgtm cancel comment by reviewer, no lgtm", 547 body: "/lgtm cancel", 548 commenter: "collab1", 549 shouldDelete: false, 550 }, 551 { 552 name: "remove-lgtm comment by reviewer, no lgtm", 553 body: "/remove-lgtm", 554 commenter: "collab1", 555 shouldDelete: false, 556 }, 557 } 558 SHA := "0bd3ed50c88cd53a09316bf7a298f900e9371652" 559 for _, tc := range testcases { 560 fc := fakegithub.NewFakeClient() 561 fc.IssueComments = make(map[int][]github.IssueComment) 562 fc.PullRequests = map[int]*github.PullRequest{ 563 5: { 564 Head: github.PullRequestBranch{ 565 SHA: SHA, 566 }, 567 }, 568 } 569 fc.Collaborators = []string{"collab1", "collab2"} 570 e := &github.GenericCommentEvent{ 571 Action: github.GenericCommentActionCreated, 572 IssueState: "open", 573 IsPR: true, 574 Body: tc.body, 575 User: github.User{Login: tc.commenter}, 576 IssueAuthor: github.User{Login: "author"}, 577 Number: 5, 578 Assignees: []github.User{{Login: "collab1"}, {Login: "assignee1"}}, 579 Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"}, 580 HTMLURL: "<url>", 581 } 582 botUser, err := fc.BotUser() 583 if err != nil { 584 t.Fatalf("For case %s, could not get Bot nam", tc.name) 585 } 586 ic := github.IssueComment{ 587 User: github.User{ 588 Login: botUser.Login, 589 }, 590 Body: removeLGTMLabelNoti, 591 } 592 fc.IssueComments[5] = append(fc.IssueComments[5], ic) 593 oc := &fakeOwnersClient{approvers: approvers, reviewers: reviewers} 594 pc := &plugins.Configuration{} 595 fp := &fakePruner{ 596 GitHubClient: fc, 597 IssueComments: fc.IssueComments[5], 598 } 599 if err := handleGenericComment(fc, pc, oc, logrus.WithField("plugin", PluginName), fp, *e); err != nil { 600 t.Errorf("For case %s, didn't expect error from lgtmComment: %v", tc.name, err) 601 continue 602 } 603 deleted := false 604 for _, body := range fc.IssueCommentsDeleted { 605 if body == removeLGTMLabelNoti { 606 deleted = true 607 break 608 } 609 } 610 if tc.shouldDelete { 611 if !deleted { 612 t.Errorf("For case %s, LGTM removed notification should have been deleted", tc.name) 613 } 614 } else { 615 if deleted { 616 t.Errorf("For case %s, LGTM removed notification should not have been deleted", tc.name) 617 } 618 } 619 } 620 } 621 622 func TestLGTMFromApproveReview(t *testing.T) { 623 var testcases = []struct { 624 name string 625 state github.ReviewState 626 action github.ReviewEventAction 627 body string 628 reviewer string 629 hasLGTM bool 630 shouldToggle bool 631 shouldComment bool 632 shouldAssign bool 633 storeTreeHash bool 634 }{ 635 { 636 name: "Edit approve review by reviewer, no lgtm on pr", 637 state: github.ReviewStateApproved, 638 action: github.ReviewActionEdited, 639 reviewer: "collab1", 640 hasLGTM: false, 641 shouldToggle: false, 642 storeTreeHash: true, 643 }, 644 { 645 name: "Dismiss approve review by reviewer, no lgtm on pr", 646 state: github.ReviewStateApproved, 647 action: github.ReviewActionDismissed, 648 reviewer: "collab1", 649 hasLGTM: false, 650 shouldToggle: false, 651 storeTreeHash: true, 652 }, 653 { 654 name: "Request changes review by reviewer, no lgtm on pr", 655 state: github.ReviewStateChangesRequested, 656 action: github.ReviewActionSubmitted, 657 reviewer: "collab1", 658 hasLGTM: false, 659 shouldToggle: false, 660 shouldAssign: false, 661 shouldComment: false, 662 }, 663 { 664 name: "Request changes review by reviewer, lgtm on pr", 665 state: github.ReviewStateChangesRequested, 666 action: github.ReviewActionSubmitted, 667 reviewer: "collab1", 668 hasLGTM: true, 669 shouldToggle: true, 670 shouldAssign: false, 671 }, 672 { 673 name: "Approve review by reviewer, no lgtm on pr", 674 state: github.ReviewStateApproved, 675 action: github.ReviewActionSubmitted, 676 reviewer: "collab1", 677 hasLGTM: false, 678 shouldToggle: true, 679 shouldComment: true, 680 storeTreeHash: true, 681 }, 682 { 683 name: "Approve review by reviewer, no lgtm on pr, do not store tree_hash", 684 state: github.ReviewStateApproved, 685 action: github.ReviewActionSubmitted, 686 reviewer: "collab1", 687 hasLGTM: false, 688 shouldToggle: true, 689 shouldComment: false, 690 }, 691 { 692 name: "Approve review by reviewer, lgtm on pr", 693 state: github.ReviewStateApproved, 694 action: github.ReviewActionSubmitted, 695 reviewer: "collab1", 696 hasLGTM: true, 697 shouldToggle: false, 698 shouldAssign: false, 699 }, 700 { 701 name: "Approve review by non-reviewer, no lgtm on pr", 702 state: github.ReviewStateApproved, 703 action: github.ReviewActionSubmitted, 704 reviewer: "collab2", 705 hasLGTM: false, 706 shouldToggle: true, 707 shouldComment: true, 708 shouldAssign: true, 709 storeTreeHash: true, 710 }, 711 { 712 name: "Request changes review by non-reviewer, no lgtm on pr", 713 state: github.ReviewStateChangesRequested, 714 action: github.ReviewActionSubmitted, 715 reviewer: "collab2", 716 hasLGTM: false, 717 shouldToggle: false, 718 shouldComment: false, 719 shouldAssign: true, 720 }, 721 { 722 name: "Approve review by rando", 723 state: github.ReviewStateApproved, 724 action: github.ReviewActionSubmitted, 725 reviewer: "not-in-the-org", 726 hasLGTM: false, 727 shouldToggle: false, 728 shouldComment: true, 729 shouldAssign: false, 730 }, 731 { 732 name: "Comment review by issue author, no lgtm on pr", 733 state: github.ReviewStateCommented, 734 action: github.ReviewActionSubmitted, 735 reviewer: "author", 736 hasLGTM: false, 737 shouldToggle: false, 738 shouldComment: false, 739 shouldAssign: false, 740 }, 741 { 742 name: "Comment body has /lgtm on Comment Review ", 743 state: github.ReviewStateCommented, 744 action: github.ReviewActionSubmitted, 745 reviewer: "collab1", 746 body: "/lgtm", 747 hasLGTM: false, 748 shouldToggle: false, 749 shouldComment: false, 750 shouldAssign: false, 751 }, 752 { 753 name: "Comment body has /lgtm cancel on Approve Review", 754 state: github.ReviewStateApproved, 755 action: github.ReviewActionSubmitted, 756 reviewer: "collab1", 757 body: "/lgtm cancel", 758 hasLGTM: false, 759 shouldToggle: false, 760 shouldComment: false, 761 shouldAssign: false, 762 }, 763 } 764 SHA := "0bd3ed50c88cd53a09316bf7a298f900e9371652" 765 for _, tc := range testcases { 766 fc := fakegithub.NewFakeClient() 767 fc.IssueComments = make(map[int][]github.IssueComment) 768 fc.IssueLabelsAdded = []string{} 769 fc.PullRequests = map[int]*github.PullRequest{ 770 5: { 771 Head: github.PullRequestBranch{ 772 SHA: SHA, 773 }, 774 }, 775 } 776 fc.Collaborators = []string{"collab1", "collab2"} 777 e := &github.ReviewEvent{ 778 Action: tc.action, 779 Review: github.Review{Body: tc.body, State: tc.state, HTMLURL: "<url>", User: github.User{Login: tc.reviewer}}, 780 PullRequest: github.PullRequest{User: github.User{Login: "author"}, Assignees: []github.User{{Login: "collab1"}, {Login: "assignee1"}}, Number: 5}, 781 Repo: github.Repo{Owner: github.User{Login: "org"}, Name: "repo"}, 782 } 783 if tc.hasLGTM { 784 fc.IssueLabelsAdded = append(fc.IssueLabelsAdded, "org/repo#5:"+LGTMLabel) 785 } 786 oc := &fakeOwnersClient{approvers: approvers, reviewers: reviewers} 787 pc := &plugins.Configuration{} 788 pc.Lgtm = append(pc.Lgtm, plugins.Lgtm{ 789 Repos: []string{"org/repo"}, 790 StoreTreeHash: tc.storeTreeHash, 791 }) 792 fp := &fakePruner{ 793 GitHubClient: fc, 794 IssueComments: fc.IssueComments[5], 795 } 796 if err := handlePullRequestReview(fc, pc, oc, logrus.WithField("plugin", PluginName), fp, *e); err != nil { 797 t.Errorf("For case %s, didn't expect error from pull request review: %v", tc.name, err) 798 continue 799 } 800 if tc.shouldAssign { 801 found := false 802 for _, a := range fc.AssigneesAdded { 803 if a == fmt.Sprintf("%s/%s#%d:%s", "org", "repo", 5, tc.reviewer) { 804 found = true 805 break 806 } 807 } 808 if !found || len(fc.AssigneesAdded) != 1 { 809 t.Errorf("For case %s, should have assigned %s but added assignees are %s", tc.name, tc.reviewer, fc.AssigneesAdded) 810 } 811 } else if len(fc.AssigneesAdded) != 0 { 812 t.Errorf("For case %s, should not have assigned anyone but assigned %s", tc.name, fc.AssigneesAdded) 813 } 814 if tc.shouldToggle { 815 if tc.hasLGTM { 816 if len(fc.IssueLabelsRemoved) == 0 { 817 t.Errorf("For case %s, should have removed LGTM.", tc.name) 818 } else if len(fc.IssueLabelsAdded) > 1 { 819 t.Errorf("For case %s, should not have added LGTM.", tc.name) 820 } 821 } else { 822 if len(fc.IssueLabelsAdded) == 0 { 823 t.Errorf("For case %s, should have added LGTM.", tc.name) 824 } else if len(fc.IssueLabelsRemoved) > 0 { 825 t.Errorf("For case %s, should not have removed LGTM.", tc.name) 826 } 827 } 828 } else if len(fc.IssueLabelsRemoved) > 0 { 829 t.Errorf("For case %s, should not have removed LGTM.", tc.name) 830 } else if (tc.hasLGTM && len(fc.IssueLabelsAdded) > 1) || (!tc.hasLGTM && len(fc.IssueLabelsAdded) > 0) { 831 t.Errorf("For case %s, should not have added LGTM.", tc.name) 832 } 833 if tc.shouldComment && len(fc.IssueComments[5]) != 1 { 834 t.Errorf("For case %s, should have commented.", tc.name) 835 } else if !tc.shouldComment && len(fc.IssueComments[5]) != 0 { 836 t.Errorf("For case %s, should not have commented.", tc.name) 837 } 838 } 839 } 840 841 func TestHandlePullRequest(t *testing.T) { 842 SHA := "0bd3ed50c88cd53a09316bf7a298f900e9371652" 843 treeSHA := "6dcb09b5b57875f334f61aebed695e2e4193db5e" 844 cases := []struct { 845 name string 846 event github.PullRequestEvent 847 removeLabelErr error 848 createCommentErr error 849 850 err error 851 IssueLabelsAdded []string 852 IssueLabelsRemoved []string 853 issueComments map[int][]github.IssueComment 854 trustedTeam string 855 856 expectNoComments bool 857 858 shouldRequest bool 859 }{ 860 { 861 name: "pr_synchronize, no RemoveLabel error", 862 event: github.PullRequestEvent{ 863 Action: github.PullRequestActionSynchronize, 864 PullRequest: github.PullRequest{ 865 Number: 101, 866 Base: github.PullRequestBranch{ 867 Repo: github.Repo{ 868 Owner: github.User{ 869 Login: "kubernetes", 870 }, 871 Name: "kubernetes", 872 }, 873 }, 874 Head: github.PullRequestBranch{ 875 SHA: SHA, 876 }, 877 Assignees: []github.User{ 878 { 879 Login: "TestReviewer", 880 }, 881 }, 882 }, 883 }, 884 IssueLabelsRemoved: []string{LGTMLabel}, 885 shouldRequest: true, 886 issueComments: map[int][]github.IssueComment{ 887 101: { 888 { 889 Body: removeLGTMLabelNoti, 890 User: github.User{Login: fakegithub.Bot}, 891 }, 892 }, 893 }, 894 expectNoComments: false, 895 }, 896 { 897 name: "Sticky LGTM for trusted team members", 898 event: github.PullRequestEvent{ 899 Action: github.PullRequestActionSynchronize, 900 PullRequest: github.PullRequest{ 901 Number: 101, 902 Base: github.PullRequestBranch{ 903 Repo: github.Repo{ 904 Owner: github.User{ 905 Login: "kubernetes", 906 }, 907 Name: "kubernetes", 908 }, 909 }, 910 User: github.User{ 911 Login: "sig-lead", 912 }, 913 MergeSHA: &SHA, 914 Assignees: []github.User{ 915 { 916 Login: "TestReviewer", 917 }, 918 }, 919 }, 920 }, 921 trustedTeam: "Leads", 922 expectNoComments: true, 923 }, 924 { 925 name: "LGTM not sticky for trusted user if disabled", 926 event: github.PullRequestEvent{ 927 Action: github.PullRequestActionSynchronize, 928 PullRequest: github.PullRequest{ 929 Number: 101, 930 Base: github.PullRequestBranch{ 931 Repo: github.Repo{ 932 Owner: github.User{ 933 Login: "kubernetes", 934 }, 935 Name: "kubernetes", 936 }, 937 }, 938 User: github.User{ 939 Login: "sig-lead", 940 }, 941 MergeSHA: &SHA, 942 Assignees: []github.User{ 943 { 944 Login: "TestReviewer", 945 }, 946 }, 947 }, 948 }, 949 IssueLabelsRemoved: []string{LGTMLabel}, 950 shouldRequest: true, 951 issueComments: map[int][]github.IssueComment{ 952 101: { 953 { 954 Body: removeLGTMLabelNoti, 955 User: github.User{Login: fakegithub.Bot}, 956 }, 957 }, 958 }, 959 expectNoComments: false, 960 }, 961 { 962 name: "LGTM not sticky for non trusted user", 963 event: github.PullRequestEvent{ 964 Action: github.PullRequestActionSynchronize, 965 PullRequest: github.PullRequest{ 966 Number: 101, 967 Base: github.PullRequestBranch{ 968 Repo: github.Repo{ 969 Owner: github.User{ 970 Login: "kubernetes", 971 }, 972 Name: "kubernetes", 973 }, 974 }, 975 User: github.User{ 976 Login: "sig-lead", 977 }, 978 MergeSHA: &SHA, 979 Assignees: []github.User{ 980 { 981 Login: "TestReviewer", 982 }, 983 }, 984 }, 985 }, 986 IssueLabelsRemoved: []string{LGTMLabel}, 987 shouldRequest: true, 988 issueComments: map[int][]github.IssueComment{ 989 101: { 990 { 991 Body: removeLGTMLabelNoti, 992 User: github.User{Login: fakegithub.Bot}, 993 }, 994 }, 995 }, 996 trustedTeam: "Committers", 997 expectNoComments: false, 998 }, 999 { 1000 name: "pr_assigned", 1001 event: github.PullRequestEvent{ 1002 Action: "assigned", 1003 }, 1004 expectNoComments: true, 1005 }, 1006 { 1007 name: "pr_synchronize, same tree-hash, keep label", 1008 event: github.PullRequestEvent{ 1009 Action: github.PullRequestActionSynchronize, 1010 PullRequest: github.PullRequest{ 1011 Number: 101, 1012 Base: github.PullRequestBranch{ 1013 Repo: github.Repo{ 1014 Owner: github.User{ 1015 Login: "kubernetes", 1016 }, 1017 Name: "kubernetes", 1018 }, 1019 }, 1020 Head: github.PullRequestBranch{ 1021 SHA: SHA, 1022 }, 1023 Assignees: []github.User{ 1024 { 1025 Login: "TestReviewer", 1026 }, 1027 }, 1028 }, 1029 }, 1030 issueComments: map[int][]github.IssueComment{ 1031 101: { 1032 { 1033 Body: fmt.Sprintf(addLGTMLabelNotification, treeSHA), 1034 User: github.User{Login: fakegithub.Bot}, 1035 }, 1036 }, 1037 }, 1038 expectNoComments: true, 1039 }, 1040 { 1041 name: "pr_synchronize, same tree-hash, keep label, edited comment", 1042 event: github.PullRequestEvent{ 1043 Action: github.PullRequestActionSynchronize, 1044 PullRequest: github.PullRequest{ 1045 Number: 101, 1046 Base: github.PullRequestBranch{ 1047 Repo: github.Repo{ 1048 Owner: github.User{ 1049 Login: "kubernetes", 1050 }, 1051 Name: "kubernetes", 1052 }, 1053 }, 1054 Head: github.PullRequestBranch{ 1055 SHA: SHA, 1056 }, 1057 Assignees: []github.User{ 1058 { 1059 Login: "TestReviewer", 1060 }, 1061 }, 1062 }, 1063 }, 1064 IssueLabelsRemoved: []string{LGTMLabel}, 1065 shouldRequest: true, 1066 issueComments: map[int][]github.IssueComment{ 1067 101: { 1068 { 1069 Body: fmt.Sprintf(addLGTMLabelNotification, treeSHA), 1070 User: github.User{Login: fakegithub.Bot}, 1071 CreatedAt: time.Date(1981, 2, 21, 12, 30, 0, 0, time.UTC), 1072 UpdatedAt: time.Date(1981, 2, 21, 12, 31, 0, 0, time.UTC), 1073 }, 1074 }, 1075 }, 1076 expectNoComments: false, 1077 }, 1078 { 1079 name: "pr_synchronize, 2 tree-hash comments, keep label", 1080 event: github.PullRequestEvent{ 1081 Action: github.PullRequestActionSynchronize, 1082 PullRequest: github.PullRequest{ 1083 Number: 101, 1084 Base: github.PullRequestBranch{ 1085 Repo: github.Repo{ 1086 Owner: github.User{ 1087 Login: "kubernetes", 1088 }, 1089 Name: "kubernetes", 1090 }, 1091 }, 1092 Head: github.PullRequestBranch{ 1093 SHA: SHA, 1094 }, 1095 Assignees: []github.User{ 1096 { 1097 Login: "TestReviewer", 1098 }, 1099 }, 1100 }, 1101 }, 1102 issueComments: map[int][]github.IssueComment{ 1103 101: { 1104 { 1105 Body: fmt.Sprintf(addLGTMLabelNotification, "older_treeSHA"), 1106 User: github.User{Login: fakegithub.Bot}, 1107 }, 1108 { 1109 Body: fmt.Sprintf(addLGTMLabelNotification, treeSHA), 1110 User: github.User{Login: fakegithub.Bot}, 1111 }, 1112 }, 1113 }, 1114 expectNoComments: true, 1115 }, 1116 { 1117 name: "pr_synchronize, no RemoveLabel error, no assignees; no requested reviewers", 1118 event: github.PullRequestEvent{ 1119 Action: github.PullRequestActionSynchronize, 1120 PullRequest: github.PullRequest{ 1121 Number: 101, 1122 Base: github.PullRequestBranch{ 1123 Repo: github.Repo{ 1124 Owner: github.User{ 1125 Login: "kubernetes", 1126 }, 1127 Name: "kubernetes", 1128 }, 1129 }, 1130 Head: github.PullRequestBranch{ 1131 SHA: SHA, 1132 }, 1133 }, 1134 }, 1135 IssueLabelsRemoved: []string{LGTMLabel}, 1136 shouldRequest: false, 1137 issueComments: map[int][]github.IssueComment{ 1138 101: { 1139 { 1140 Body: removeLGTMLabelNoti, 1141 User: github.User{Login: fakegithub.Bot}, 1142 }, 1143 }, 1144 }, 1145 expectNoComments: false, 1146 }, 1147 } 1148 for _, c := range cases { 1149 t.Run(c.name, func(t *testing.T) { 1150 fakeGitHub := fakegithub.NewFakeClient() 1151 fakeGitHub.IssueComments = c.issueComments 1152 fakeGitHub.PullRequests = map[int]*github.PullRequest{ 1153 101: { 1154 Base: github.PullRequestBranch{ 1155 Ref: "master", 1156 }, 1157 Head: github.PullRequestBranch{ 1158 SHA: SHA, 1159 }, 1160 }, 1161 } 1162 fakeGitHub.Commits = make(map[string]github.RepositoryCommit) 1163 fakeGitHub.Collaborators = []string{"collab"} 1164 fakeGitHub.IssueLabelsAdded = c.IssueLabelsAdded 1165 fakeGitHub.IssueLabelsAdded = append(fakeGitHub.IssueLabelsAdded, "kubernetes/kubernetes#101:lgtm") 1166 commit := github.RepositoryCommit{} 1167 commit.Commit.Tree.SHA = treeSHA 1168 fakeGitHub.Commits[SHA] = commit 1169 pc := &plugins.Configuration{} 1170 pc.Lgtm = append(pc.Lgtm, plugins.Lgtm{ 1171 Repos: []string{"kubernetes/kubernetes"}, 1172 StoreTreeHash: true, 1173 StickyLgtmTeam: c.trustedTeam, 1174 }) 1175 err := handlePullRequest( 1176 logrus.WithField("plugin", "approve"), 1177 fakeGitHub, 1178 pc, 1179 &c.event, 1180 ) 1181 1182 if err != nil && c.err == nil { 1183 t.Fatalf("handlePullRequest error: %v", err) 1184 } 1185 1186 if err == nil && c.err != nil { 1187 t.Fatalf("handlePullRequest wanted error: %v, got nil", c.err) 1188 } 1189 1190 if got, want := err, c.err; !equality.Semantic.DeepEqual(got, want) { 1191 t.Fatalf("handlePullRequest error mismatch: got %v, want %v", got, want) 1192 } 1193 1194 if got, want := len(fakeGitHub.IssueLabelsRemoved), len(c.IssueLabelsRemoved); got != want { 1195 t.Logf("IssueLabelsRemoved: got %v, want: %v", fakeGitHub.IssueLabelsRemoved, c.IssueLabelsRemoved) 1196 t.Fatalf("IssueLabelsRemoved length mismatch: got %d, want %d", got, want) 1197 } 1198 1199 if got, want := fakeGitHub.IssueComments, c.issueComments; !equality.Semantic.DeepEqual(got, want) { 1200 t.Fatalf("LGTM revmoved notifications mismatch: got %v, want %v", got, want) 1201 } 1202 if c.expectNoComments && len(fakeGitHub.IssueCommentsAdded) > 0 { 1203 t.Fatalf("expected no comments but got %v", fakeGitHub.IssueCommentsAdded) 1204 } 1205 if !c.expectNoComments && len(fakeGitHub.IssueCommentsAdded) == 0 { 1206 t.Fatalf("expected comments but got none") 1207 } 1208 if c.shouldRequest && len(fakeGitHub.ReviewersRequested) == 0 { 1209 t.Error("should have re-requested reviewers") 1210 } 1211 if !c.shouldRequest && len(fakeGitHub.ReviewersRequested) > 0 { 1212 t.Errorf("should not have re-requested reviewers, but requested these reviewers %v", fakeGitHub.ReviewersRequested) 1213 } 1214 }) 1215 } 1216 } 1217 1218 func TestAddTreeHashComment(t *testing.T) { 1219 cases := []struct { 1220 name string 1221 author string 1222 trustedTeam string 1223 expectTreeSha bool 1224 }{ 1225 { 1226 name: "Tree SHA added", 1227 author: "Bob", 1228 expectTreeSha: true, 1229 }, 1230 { 1231 name: "Tree SHA if sticky lgtm off", 1232 author: "sig-lead", 1233 expectTreeSha: true, 1234 }, 1235 { 1236 name: "No Tree SHA if sticky lgtm", 1237 author: "sig-lead", 1238 trustedTeam: "Leads", 1239 expectTreeSha: false, 1240 }, 1241 } 1242 1243 for _, c := range cases { 1244 t.Run(c.name, func(t *testing.T) { 1245 1246 SHA := "0bd3ed50c88cd53a09316bf7a298f900e9371652" 1247 treeSHA := "6dcb09b5b57875f334f61aebed695e2e4193db5e" 1248 pc := &plugins.Configuration{} 1249 pc.Lgtm = append(pc.Lgtm, plugins.Lgtm{ 1250 Repos: []string{"kubernetes/kubernetes"}, 1251 StoreTreeHash: true, 1252 StickyLgtmTeam: c.trustedTeam, 1253 }) 1254 rc := reviewCtx{ 1255 author: "collab1", 1256 issueAuthor: c.author, 1257 repo: github.Repo{ 1258 Owner: github.User{ 1259 Login: "kubernetes", 1260 }, 1261 Name: "kubernetes", 1262 }, 1263 number: 101, 1264 body: "/lgtm", 1265 } 1266 fc := fakegithub.NewFakeClient() 1267 fc.Commits = make(map[string]github.RepositoryCommit) 1268 fc.IssueComments = map[int][]github.IssueComment{} 1269 fc.PullRequests = map[int]*github.PullRequest{ 1270 101: { 1271 Base: github.PullRequestBranch{ 1272 Ref: "master", 1273 }, 1274 Head: github.PullRequestBranch{ 1275 SHA: SHA, 1276 }, 1277 }, 1278 } 1279 fc.Collaborators = []string{"collab1", "collab2"} 1280 commit := github.RepositoryCommit{} 1281 commit.Commit.Tree.SHA = treeSHA 1282 fc.Commits[SHA] = commit 1283 handle(true, pc, &fakeOwnersClient{}, rc, fc, logrus.WithField("plugin", PluginName), &fakePruner{}) 1284 found := false 1285 for _, body := range fc.IssueCommentsAdded { 1286 if addLGTMLabelNotificationRe.MatchString(body) { 1287 found = true 1288 break 1289 } 1290 } 1291 if c.expectTreeSha { 1292 if !found { 1293 t.Fatalf("expected tree_hash comment but got none") 1294 } 1295 } else { 1296 if found { 1297 t.Fatalf("expected no tree_hash comment but got one") 1298 } 1299 } 1300 }) 1301 } 1302 } 1303 1304 func TestRemoveTreeHashComment(t *testing.T) { 1305 treeSHA := "6dcb09b5b57875f334f61aebed695e2e4193db5e" 1306 pc := &plugins.Configuration{} 1307 pc.Lgtm = append(pc.Lgtm, plugins.Lgtm{ 1308 Repos: []string{"kubernetes/kubernetes"}, 1309 StoreTreeHash: true, 1310 }) 1311 rc := reviewCtx{ 1312 author: "collab1", 1313 issueAuthor: "bob", 1314 repo: github.Repo{ 1315 Owner: github.User{ 1316 Login: "kubernetes", 1317 }, 1318 Name: "kubernetes", 1319 }, 1320 assignees: []github.User{{Login: "alice"}}, 1321 number: 101, 1322 body: "/lgtm cancel", 1323 } 1324 fc := fakegithub.NewFakeClient() 1325 fc.IssueComments = map[int][]github.IssueComment{ 1326 101: { 1327 { 1328 Body: fmt.Sprintf(addLGTMLabelNotification, treeSHA), 1329 User: github.User{Login: fakegithub.Bot}, 1330 }, 1331 }, 1332 } 1333 fc.Collaborators = []string{"collab1", "collab2"} 1334 fc.IssueLabelsAdded = []string{"kubernetes/kubernetes#101:" + LGTMLabel} 1335 fp := &fakePruner{ 1336 GitHubClient: fc, 1337 IssueComments: fc.IssueComments[101], 1338 } 1339 handle(false, pc, &fakeOwnersClient{}, rc, fc, logrus.WithField("plugin", PluginName), fp) 1340 found := false 1341 for _, body := range fc.IssueCommentsDeleted { 1342 if addLGTMLabelNotificationRe.MatchString(body) { 1343 found = true 1344 break 1345 } 1346 } 1347 if !found { 1348 t.Fatalf("expected deleted tree_hash comment but got none") 1349 } 1350 } 1351 1352 func TestHelpProvider(t *testing.T) { 1353 enabledRepos := []config.OrgRepo{ 1354 {Org: "org1", Repo: "repo"}, 1355 {Org: "org2", Repo: "repo"}, 1356 } 1357 cases := []struct { 1358 name string 1359 config *plugins.Configuration 1360 enabledRepos []config.OrgRepo 1361 err bool 1362 configInfoIncludes []string 1363 configInfoExcludes []string 1364 }{ 1365 { 1366 name: "Empty config", 1367 config: &plugins.Configuration{}, 1368 enabledRepos: enabledRepos, 1369 configInfoExcludes: []string{configInfoReviewActsAsLgtm, configInfoStoreTreeHash, configInfoStickyLgtmTeam("team1")}, 1370 }, 1371 { 1372 name: "StoreTreeHash enabled", 1373 config: &plugins.Configuration{ 1374 Lgtm: []plugins.Lgtm{ 1375 { 1376 Repos: []string{"org2/repo"}, 1377 StoreTreeHash: true, 1378 }, 1379 }, 1380 }, 1381 enabledRepos: enabledRepos, 1382 configInfoExcludes: []string{configInfoReviewActsAsLgtm, configInfoStickyLgtmTeam("team1")}, 1383 configInfoIncludes: []string{configInfoStoreTreeHash}, 1384 }, 1385 { 1386 name: "All configs enabled", 1387 config: &plugins.Configuration{ 1388 Lgtm: []plugins.Lgtm{ 1389 { 1390 Repos: []string{"org2/repo"}, 1391 ReviewActsAsLgtm: true, 1392 StoreTreeHash: true, 1393 StickyLgtmTeam: "team1", 1394 }, 1395 }, 1396 }, 1397 enabledRepos: enabledRepos, 1398 configInfoIncludes: []string{configInfoReviewActsAsLgtm, configInfoStoreTreeHash, configInfoStickyLgtmTeam("team1")}, 1399 }, 1400 } 1401 for _, c := range cases { 1402 t.Run(c.name, func(t *testing.T) { 1403 pluginHelp, err := helpProvider(c.config, c.enabledRepos) 1404 if err != nil && !c.err { 1405 t.Fatalf("helpProvider error: %v", err) 1406 } 1407 for _, msg := range c.configInfoExcludes { 1408 if strings.Contains(pluginHelp.Config["org2/repo"], msg) { 1409 t.Fatalf("helpProvider.Config error mismatch: got %v, but didn't want it", msg) 1410 } 1411 } 1412 for _, msg := range c.configInfoIncludes { 1413 if !strings.Contains(pluginHelp.Config["org2/repo"], msg) { 1414 t.Fatalf("helpProvider.Config error mismatch: didn't get %v, but wanted it", msg) 1415 } 1416 } 1417 }) 1418 } 1419 }