package main import ( "errors" "testing" "time" "github.com/go-openapi/strfmt" "go.uber.org/mock/gomock" "src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common/gitea-generated/models" mock "src.opensuse.org/autogits/common/mock" "src.opensuse.org/autogits/common/test_utils" ) type MockMaintainershipFetcher struct { data common.MaintainershipData err error } func (m *MockMaintainershipFetcher) FetchProjectMaintainershipData(gitea common.GiteaMaintainershipReader, config *common.AutogitConfig) (common.MaintainershipData, error) { return m.data, m.err } type MockMaintainershipData struct { maintainers []string } func (m *MockMaintainershipData) ListProjectMaintainers(groups []*common.ReviewGroup) []string { return m.maintainers } func (m *MockMaintainershipData) ListPackageMaintainers(pkg string, groups []*common.ReviewGroup) []string { return m.maintainers } func (m *MockMaintainershipData) IsApproved(pkg string, reviews []*models.PullReview, submitter string, groups []*common.ReviewGroup) bool { return true } func TestParseSourceReposFromIssue(t *testing.T) { bot := &ReparentBot{} tests := []struct { name string body string expected []RepoInfo }{ { name: "single repo", body: "https://src.opensuse.org/owner/repo", expected: []RepoInfo{ {Owner: "owner", Name: "repo", Branch: ""}, }, }, { name: "repo with branch", body: "https://src.opensuse.org/owner/repo#branch", expected: []RepoInfo{ {Owner: "owner", Name: "repo", Branch: "branch"}, }, }, { name: "multiple repos", body: "Check these out:\nhttps://src.opensuse.org/o1/r1\nhttps://src.opensuse.org/o2/r2#b2", expected: []RepoInfo{ {Owner: "o1", Name: "r1", Branch: ""}, {Owner: "o2", Name: "r2", Branch: "b2"}, }, }, { name: "no repos", body: "Nothing here", expected: nil, }, { name: "not matching url", body: "invalid link", expected: nil, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := bot.ParseSourceReposFromIssue(&models.Issue{Body: tc.body}) if len(got) != len(tc.expected) { t.Fatalf("expected %d repos, got %d", len(tc.expected), len(got)) } for i := range got { if got[i] != tc.expected[i] { t.Errorf("at index %d: expected %+v, got %+v", i, tc.expected[i], got[i]) } } }) } } func TestIsMaintainer(t *testing.T) { bot := &ReparentBot{} maintainers := []string{"alice", "bob"} tests := []struct { user string expected bool }{ {"alice", true}, {"bob", true}, {"charlie", false}, } for _, tc := range tests { t.Run(tc.user, func(t *testing.T) { if got := bot.IsMaintainer(tc.user, maintainers); got != tc.expected { t.Errorf("expected %v for %s, got %v", tc.expected, tc.user, got) } }) } } func TestIsApproval(t *testing.T) { bot := &ReparentBot{} tests := []struct { body string expected bool }{ {"approved", true}, {"LGTM", true}, {"Looks good to me, approved!", true}, {"not yet", false}, {"", false}, } for _, tc := range tests { t.Run(tc.body, func(t *testing.T) { if got := bot.IsApproval(tc.body); got != tc.expected { t.Errorf("expected %v for %s, got %v", tc.expected, tc.body, got) } }) } } func TestHasComment(t *testing.T) { bot := &ReparentBot{botUser: "bot"} timeline := []*models.TimelineComment{ {Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "user"}, Body: "hello"}, {Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "bot"}, Body: "ping"}, } tests := []struct { name string msg string expected bool }{ {"exists", "ping", true}, {"exists with spaces", " ping ", true}, {"not exists", "pong", false}, {"other user", "hello", false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := bot.HasComment(timeline, tc.msg); got != tc.expected { t.Errorf("expected %v for %s, got %v", tc.expected, tc.msg, got) } }) } } func TestProcessIssue(t *testing.T) { var mockGitea *mock.MockGitea mockFetcher := &MockMaintainershipFetcher{} bot := &ReparentBot{ botUser: "bot", maintainershipFetcher: mockFetcher, configs: common.AutogitConfigs{ &common.AutogitConfig{Organization: "org", Branch: "master"}, }, } tests := []struct { name string issue *models.Issue dryRun bool setupMock func() wantErr bool }{ { name: "closed issue", issue: &models.Issue{ State: "closed", }, setupMock: func() {}, wantErr: false, }, { name: "no [ADD] prefix", issue: &models.Issue{ State: "open", Title: "Just a comment", }, setupMock: func() {}, wantErr: false, }, { name: "no NewRepository label", issue: &models.Issue{ State: "open", Title: "[ADD] My Repo", }, setupMock: func() {}, wantErr: false, }, { name: "timeline fetch error", issue: &models.Issue{ Index: 10, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, }, setupMock: func() { mockGitea.EXPECT().GetTimeline("org", "repo", int64(10)).Return(nil, errors.New("error")) }, wantErr: true, }, { name: "no source repos parsed", issue: &models.Issue{ Index: 11, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Body: "No links here", }, setupMock: func() { mockGitea.EXPECT().GetTimeline("org", "repo", int64(11)).Return(nil, nil) }, wantErr: false, }, { name: "missing config", issue: &models.Issue{ Index: 1, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/wrong-branch", Body: "https://src.opensuse.org/owner/repo", }, setupMock: func() { mockGitea.EXPECT().GetTimeline("org", "repo", int64(1)).Return(nil, nil) }, wantErr: true, }, { name: "config found in loop", issue: &models.Issue{ Index: 6, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo", }, setupMock: func() { mockGitea.EXPECT().GetTimeline("org", "repo", int64(6)).Return(nil, nil) mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil mockGitea.EXPECT().GetRepository("owner", "repo").Return(&models.Repository{DefaultBranch: "master"}, nil) mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil) mockGitea.EXPECT().UpdateIssue("org", "repo", int64(6), gomock.Any()).Return(nil, nil) mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil) }, wantErr: false, }, { name: "maintainer fetch error", issue: &models.Issue{ Index: 12, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo", }, setupMock: func() { mockGitea.EXPECT().GetTimeline("org", "repo", int64(12)).Return(nil, nil) mockFetcher.err = errors.New("error") }, wantErr: true, }, { name: "no maintainers found", issue: &models.Issue{ Index: 13, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo", }, setupMock: func() { mockGitea.EXPECT().GetTimeline("org", "repo", int64(13)).Return(nil, nil) mockFetcher.data = &MockMaintainershipData{maintainers: nil} mockFetcher.err = nil }, wantErr: true, }, { name: "not approved - requests review", issue: &models.Issue{ Index: 2, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo", }, setupMock: func() { mockGitea.EXPECT().GetTimeline("org", "repo", int64(2)).Return(nil, nil) mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil mockGitea.EXPECT().GetRepository("owner", "repo").Return(&models.Repository{DefaultBranch: "master"}, nil) mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil) mockGitea.EXPECT().UpdateIssue("org", "repo", int64(2), gomock.Any()).Return(nil, nil) mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil) }, wantErr: false, }, { name: "approved - processes repos", issue: &models.Issue{ Index: 3, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo", User: &models.User{UserName: "owner"}, }, setupMock: func() { timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "m1"}, Body: "approved", }, } mockGitea.EXPECT().GetTimeline("org", "repo", int64(3)).Return(timeline, nil) mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil repo := &models.Repository{Name: "repo", Owner: &models.User{UserName: "owner"}, DefaultBranch: "master"} mockGitea.EXPECT().GetRepository("owner", "repo").Return(repo, nil) mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil) mockGitea.EXPECT().ReparentRepository("owner", "repo", "org").Return(nil, nil) mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil) }, wantErr: false, }, { name: "approved - dry run", dryRun: true, issue: &models.Issue{ Index: 3, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo", User: &models.User{UserName: "owner"}, }, setupMock: func() { timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "m1"}, Body: "approved", }, } mockGitea.EXPECT().GetTimeline("org", "repo", int64(3)).Return(timeline, nil) mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil mockGitea.EXPECT().GetRepository("owner", "repo").Return(&models.Repository{DefaultBranch: "master"}, nil) mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil) }, wantErr: false, }, { name: "approved - source repo fetch error", issue: &models.Issue{ Index: 3, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo", User: &models.User{UserName: "owner"}, }, setupMock: func() { timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "m1"}, Body: "approved", }, } mockGitea.EXPECT().GetTimeline("org", "repo", int64(3)).Return(timeline, nil) mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil mockGitea.EXPECT().GetRepository("owner", "repo").Return(nil, errors.New("error")) }, wantErr: false, }, { name: "approved - source repo not found", issue: &models.Issue{ Index: 3, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo", User: &models.User{UserName: "owner"}, }, setupMock: func() { timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "m1"}, Body: "approved", }, } mockGitea.EXPECT().GetTimeline("org", "repo", int64(3)).Return(timeline, nil) mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil mockGitea.EXPECT().GetRepository("owner", "repo").Return(nil, nil) mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil) }, wantErr: false, }, { name: "approved - reparent error", issue: &models.Issue{ Index: 3, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo", User: &models.User{UserName: "owner"}, }, setupMock: func() { timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "m1"}, Body: "approved", }, } mockGitea.EXPECT().GetTimeline("org", "repo", int64(3)).Return(timeline, nil) mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil repo := &models.Repository{Name: "repo", Owner: &models.User{UserName: "owner"}, DefaultBranch: "master"} mockGitea.EXPECT().GetRepository("owner", "repo").Return(repo, nil) mockGitea.EXPECT().GetRepository("org", "repo").Return(nil, nil) mockGitea.EXPECT().ReparentRepository("owner", "repo", "org").Return(nil, errors.New("error")) }, wantErr: false, }, { name: "user nil panic", issue: &models.Issue{ Index: 99, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo", User: nil, }, setupMock: func() { mockGitea.EXPECT().GetTimeline("org", "repo", int64(99)).Return(nil, nil) mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil mockGitea.EXPECT().GetRepository("owner", "repo").Return(&models.Repository{DefaultBranch: "master", Fork: true}, nil) mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil) }, wantErr: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { ctrl := test_utils.NewController(t) defer ctrl.Finish() mockGitea = mock.NewMockGitea(ctrl) mockGitea.EXPECT().ResetTimelineCache(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() bot.gitea = mockGitea common.IsDryRun = tc.dryRun if tc.issue != nil { if tc.issue.Repository == nil { tc.issue.Repository = &models.RepositoryMeta{Owner: "org", Name: "repo"} } if tc.issue.User == nil && tc.name != "user nil panic" { tc.issue.User = &models.User{UserName: "owner"} } } tc.setupMock() err := bot.ProcessIssue("org", "repo", tc.issue) if (err != nil) != tc.wantErr { t.Errorf("ProcessIssue() error = %v, wantErr %v", err, tc.wantErr) } }) } common.IsDryRun = false } func TestGetMaintainers(t *testing.T) { mockFetcher := &MockMaintainershipFetcher{} bot := &ReparentBot{maintainershipFetcher: mockFetcher} config := &common.AutogitConfig{} t.Run("success", func(t *testing.T) { mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil got, err := bot.GetMaintainers(config) if err != nil { t.Errorf("unexpected error: %v", err) } if len(got) != 1 || got[0] != "m1" { t.Errorf("expected [m1], got %v", got) } }) t.Run("error", func(t *testing.T) { mockFetcher.err = errors.New("error") _, err := bot.GetMaintainers(config) if err == nil { t.Error("expected error, got nil") } }) } func TestAddCommentOnce(t *testing.T) { ctrl := test_utils.NewController(t) defer ctrl.Finish() mockGitea := mock.NewMockGitea(ctrl) bot := &ReparentBot{gitea: mockGitea, botUser: "bot"} timeline := []*models.TimelineComment{ {Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "bot"}, Body: "already there"}, } t.Run("already exists", func(t *testing.T) { bot.AddCommentOnce("org", "repo", 1, timeline, "already there") // No expectation means it should NOT call AddComment }) t.Run("new comment", func(t *testing.T) { mockGitea.EXPECT().AddComment(gomock.Any(), "new").Return(nil) bot.AddCommentOnce("org", "repo", 1, timeline, "new") }) } func TestProcessIssue_ApprovalReuse(t *testing.T) { // This test verifies that an approval for a previous version of the issue body (e.g. repo1) // is IGNORED for a new version (e.g. repo2). // We simulate an issue that was updated AFTER the approval comment. ctrl := test_utils.NewController(t) defer ctrl.Finish() mockGitea := mock.NewMockGitea(ctrl) mockFetcher := &MockMaintainershipFetcher{} bot := &ReparentBot{ botUser: "bot", maintainershipFetcher: mockFetcher, configs: common.AutogitConfigs{ &common.AutogitConfig{Organization: "org", Branch: "master"}, }, gitea: mockGitea, } // Timestamps approvalTime := time.Now().Add(-2 * time.Hour) issueUpdateTime := time.Now().Add(-1 * time.Hour) issue := &models.Issue{ Index: 100, State: "open", Title: "[ADD] My Repo", Labels: []*models.Label{{Name: common.Label_NewRepository}}, Ref: "refs/heads/master", Body: "https://src.opensuse.org/owner/repo2", // Changed to repo2 User: &models.User{UserName: "owner"}, Updated: strfmt.DateTime(issueUpdateTime), Repository: &models.RepositoryMeta{Owner: "org", Name: "repo"}, } // Setup expectations mockGitea.EXPECT().ResetTimelineCache("org", "repo", int64(100)).AnyTimes() // Timeline has an OLD approval timeline := []*models.TimelineComment{ { Type: common.TimelineCommentType_Comment, User: &models.User{UserName: "m1"}, Body: "approved", Created: strfmt.DateTime(approvalTime), Updated: strfmt.DateTime(approvalTime), }, } mockGitea.EXPECT().GetTimeline("org", "repo", int64(100)).Return(timeline, nil) // Maintainers mockFetcher.data = &MockMaintainershipData{maintainers: []string{"m1"}} mockFetcher.err = nil // Repo info fetching repo2 := &models.Repository{Name: "repo2", Owner: &models.User{UserName: "owner"}, DefaultBranch: "master"} mockGitea.EXPECT().GetRepository("owner", "repo2").Return(repo2, nil) // Check for existing repo in target org mockGitea.EXPECT().GetRepository("org", "repo2").Return(nil, nil) // CRITICAL: We expect ReparentRepository to NOT be called because the approval is stale. mockGitea.EXPECT().ReparentRepository(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) // It should request review instead mockGitea.EXPECT().UpdateIssue(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).AnyTimes() // Execute common.IsDryRun = false err := bot.ProcessIssue("org", "repo", issue) if err != nil { t.Errorf("ProcessIssue() unexpected error: %v", err) } }