package main import ( "fmt" "testing" "go.uber.org/mock/gomock" "src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common/gitea-generated/models" mock_common "src.opensuse.org/autogits/common/mock" ) func TestProcessPR(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockGitea := mock_common.NewMockGitea(ctrl) groupName := "testgroup" bot := &ReviewBot{ gitea: mockGitea, groupName: groupName, } bot.InitRegex(groupName) org := "myorg" repo := "myrepo" prIndex := int64(1) headSha := "abcdef123456" pr := &models.PullRequest{ Index: prIndex, URL: "http://gitea/pr/1", State: "open", Base: &models.PRBranchInfo{ Name: "main", Repo: &models.Repository{ Name: repo, Owner: &models.User{ UserName: org, }, }, }, Head: &models.PRBranchInfo{ Sha: headSha, }, User: &models.User{ UserName: "submitter", }, RequestedReviewers: []*models.User{ {UserName: groupName}, }, } prjConfig := &common.AutogitConfig{ GitProjectName: org + "/" + repo + "#main", ReviewGroups: []*common.ReviewGroup{ { Name: groupName, Reviewers: []string{"reviewer1", "reviewer2"}, }, }, } bot.configs = common.AutogitConfigs{prjConfig} t.Run("Review not requested for group", func(t *testing.T) { prNoRequest := *pr prNoRequest.RequestedReviewers = nil err := bot.ProcessPR(&prNoRequest) if err != nil { t.Errorf("Expected no error, got %v", err) } }) t.Run("PR is closed", func(t *testing.T) { prClosed := *pr prClosed.State = "closed" err := bot.ProcessPR(&prClosed) if err != nil { t.Errorf("Expected no error, got %v", err) } }) t.Run("Successful Approval", func(t *testing.T) { common.IsDryRun = false mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil) // reviewer1 approved in timeline timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "reviewer1"}, Body: "@" + groupName + ": approve", }, } mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil) expectedText := "reviewer1 approved a review on behalf of " + groupName mockGitea.EXPECT().AddReviewComment(pr, common.ReviewStateApproved, expectedText).Return(nil, nil) mockGitea.EXPECT().UnrequestReview(org, repo, prIndex, gomock.Any()).Return(nil) err := bot.ProcessPR(pr) if err != nil { t.Errorf("Expected nil error, got %v", err) } }) t.Run("Dry Run - No actions taken", func(t *testing.T) { common.IsDryRun = true mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil) timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "reviewer1"}, Body: "@" + groupName + ": approve", }, } mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil) // No AddReviewComment or UnrequestReview should be called err := bot.ProcessPR(pr) if err != nil { t.Errorf("Expected nil error, got %v", err) } }) t.Run("Approval already exists - No new comment", func(t *testing.T) { common.IsDryRun = false mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil) approvalText := "reviewer1 approved a review on behalf of " + groupName timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Review, User: &models.User{UserName: groupName}, Body: approvalText, }, { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "reviewer1"}, Body: "@" + groupName + ": approve", }, { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: groupName}, Body: "Help comment", }, } mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil) // No AddReviewComment, UnrequestReview, or AddComment should be called err := bot.ProcessPR(pr) if err != nil { t.Errorf("Expected nil error, got %v", err) } }) t.Run("Rejection already exists - No new comment", func(t *testing.T) { common.IsDryRun = false mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil) rejectionText := "reviewer1 requested changes on behalf of " + groupName + ". See http://gitea/comment/123" timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Review, User: &models.User{UserName: groupName}, Body: rejectionText, }, { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "reviewer1"}, Body: "@" + groupName + ": decline", HTMLURL: "http://gitea/comment/123", }, { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: groupName}, Body: "Help comment", }, } mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil) err := bot.ProcessPR(pr) if err != nil { t.Errorf("Expected nil error, got %v", err) } }) t.Run("Pending review - Help comment already exists", func(t *testing.T) { common.IsDryRun = false mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil) timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: groupName}, Body: "Some help comment", }, } mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil) // It will try to request reviews mockGitea.EXPECT().RequestReviews(pr, "reviewer1", "reviewer2").Return(nil, nil) // AddComment should NOT be called because bot already has a comment in timeline err := bot.ProcessPR(pr) if err != ReviewNotFinished { t.Errorf("Expected ReviewNotFinished error, got %v", err) } }) t.Run("Submitter is group member - Excluded from review request", func(t *testing.T) { common.IsDryRun = false prSubmitterMember := *pr prSubmitterMember.User = &models.User{UserName: "reviewer1"} mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil) mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(nil, nil) mockGitea.EXPECT().RequestReviews(&prSubmitterMember, "reviewer2").Return(nil, nil) mockGitea.EXPECT().AddComment(&prSubmitterMember, gomock.Any()).Return(nil) err := bot.ProcessPR(&prSubmitterMember) if err != ReviewNotFinished { t.Errorf("Expected ReviewNotFinished error, got %v", err) } }) t.Run("Successful Rejection", func(t *testing.T) { common.IsDryRun = false mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil) timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "reviewer2"}, Body: "@" + groupName + ": decline", HTMLURL: "http://gitea/comment/999", }, } mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil) expectedText := "reviewer2 requested changes on behalf of " + groupName + ". See http://gitea/comment/999" mockGitea.EXPECT().AddReviewComment(pr, common.ReviewStateRequestChanges, expectedText).Return(nil, nil) mockGitea.EXPECT().UnrequestReview(org, repo, prIndex, gomock.Any()).Return(nil) err := bot.ProcessPR(pr) if err != nil { t.Errorf("Expected nil error, got %v", err) } }) t.Run("Config not found", func(t *testing.T) { bot.configs = common.AutogitConfigs{} err := bot.ProcessPR(pr) if err == nil { t.Error("Expected error when config is missing, got nil") } }) t.Run("Gitea error in GetPullRequestReviews", func(t *testing.T) { bot.configs = common.AutogitConfigs{prjConfig} mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, fmt.Errorf("gitea error")) err := bot.ProcessPR(pr) if err == nil { t.Error("Expected error from gitea, got nil") } }) } func TestProcessNotifications(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockGitea := mock_common.NewMockGitea(ctrl) groupName := "testgroup" bot := &ReviewBot{ gitea: mockGitea, groupName: groupName, } bot.InitRegex(groupName) org := "myorg" repo := "myrepo" prIndex := int64(123) notificationID := int64(456) notification := &models.NotificationThread{ ID: notificationID, Subject: &models.NotificationSubject{ URL: fmt.Sprintf("http://gitea/api/v1/repos/%s/%s/pulls/%d", org, repo, prIndex), }, } t.Run("Notification Success", func(t *testing.T) { common.IsDryRun = false pr := &models.PullRequest{ Index: prIndex, Base: &models.PRBranchInfo{ Name: "main", Repo: &models.Repository{ Name: repo, Owner: &models.User{UserName: org}, }, }, Head: &models.PRBranchInfo{ Sha: "headsha", Repo: &models.Repository{ Name: repo, Owner: &models.User{UserName: org}, }, }, User: &models.User{UserName: "submitter"}, RequestedReviewers: []*models.User{{UserName: groupName}}, } mockGitea.EXPECT().GetPullRequest(org, repo, prIndex).Return(pr, nil) prjConfig := &common.AutogitConfig{ GitProjectName: org + "/" + repo + "#main", ReviewGroups: []*common.ReviewGroup{{Name: groupName, Reviewers: []string{"r1"}}}, } bot.configs = common.AutogitConfigs{prjConfig} mockGitea.EXPECT().GetPullRequestReviews(org, repo, prIndex).Return(nil, nil) timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "r1"}, Body: "@" + groupName + ": approve", }, } mockGitea.EXPECT().GetTimeline(org, repo, prIndex).Return(timeline, nil) expectedText := "r1 approved a review on behalf of " + groupName mockGitea.EXPECT().AddReviewComment(pr, common.ReviewStateApproved, expectedText).Return(nil, nil) mockGitea.EXPECT().UnrequestReview(org, repo, prIndex, gomock.Any()).Return(nil) mockGitea.EXPECT().SetNotificationRead(notificationID).Return(nil) bot.ProcessNotifications(notification) }) t.Run("Invalid Notification URL", func(t *testing.T) { badNotification := &models.NotificationThread{ Subject: &models.NotificationSubject{ URL: "http://gitea/invalid/url", }, } bot.ProcessNotifications(badNotification) }) t.Run("Gitea error in GetPullRequest", func(t *testing.T) { mockGitea.EXPECT().GetPullRequest(org, repo, prIndex).Return(nil, fmt.Errorf("gitea error")) bot.ProcessNotifications(notification) }) } func TestReviewApprovalCheck(t *testing.T) { tests := []struct { Name string GroupName string InString string Approved bool Rejected bool }{ { Name: "Empty String", GroupName: "group", InString: "", }, { Name: "Random Text", GroupName: "group", InString: "some things LGTM", }, { Name: "Group name with Random Text means disapproval", GroupName: "group", InString: "@group: some things LGTM", Rejected: true, }, { Name: "Bad name with Approval", GroupName: "group2", InString: "@group: LGTM", }, { Name: "Bad name with Approval", GroupName: "group2", InString: "@group: LGTM", }, { Name: "LGTM approval", GroupName: "group2", InString: "@group2: LGTM", Approved: true, }, { Name: "approval", GroupName: "group2", InString: "@group2: approved", Approved: true, }, { Name: "approval", GroupName: "group2", InString: "@group2: approve", Approved: true, }, { Name: "disapproval", GroupName: "group2", InString: "@group2: disapprove", Rejected: true, }, { Name: "Whitespace before colon", GroupName: "group", InString: "@group : LGTM", Approved: true, }, { Name: "No whitespace after colon", GroupName: "group", InString: "@group:LGTM", Approved: true, }, { Name: "Leading and trailing whitespace on line", GroupName: "group", InString: " @group: LGTM ", Approved: true, }, { Name: "Multiline: Approved on second line", GroupName: "group", InString: "Random noise\n@group: approved", Approved: true, }, { Name: "Multiline: Multiple group mentions, first wins", GroupName: "group", InString: "@group: decline\n@group: approve", Rejected: true, }, { Name: "Multiline: Approved on second line", GroupName: "group", InString: "noise\n@group: approve\nmore noise", Approved: true, }, { Name: "Not at start of line (even with whitespace)", GroupName: "group", InString: "Hello @group: approve", Approved: false, }, { Name: "Rejecting with reason", GroupName: "group", InString: "@group: decline because of X, Y and Z", Rejected: true, }, { Name: "No colon after group", GroupName: "group", InString: "@group LGTM", Approved: false, Rejected: false, }, { Name: "Invalid char after group", GroupName: "group", InString: "@group! LGTM", Approved: false, Rejected: false, }, } for _, test := range tests { t.Run(test.Name, func(t *testing.T) { bot := &ReviewBot{} bot.InitRegex(test.GroupName) if r := bot.ReviewAccepted(test.InString); r != test.Approved { t.Error("ReviewAccepted() returned", r, "expecting", test.Approved) } if r := bot.ReviewRejected(test.InString); r != test.Rejected { t.Error("ReviewRejected() returned", r, "expecting", test.Rejected) } }) } }