Files
autogits/reparent-bot/bot_test.go

639 lines
19 KiB
Go

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)
}
}