diff --git a/common/mock/obs_utils.go b/common/mock/obs_utils.go index 5cfc383..2e0d6db 100644 --- a/common/mock/obs_utils.go +++ b/common/mock/obs_utils.go @@ -83,3 +83,260 @@ func (c *MockObsStatusFetcherWithStateBuildStatusWithStateCall) DoAndReturn(f fu c.Call = c.Call.DoAndReturn(f) return c } + +// MockObsClientInterface is a mock of ObsClientInterface interface. +type MockObsClientInterface struct { + ctrl *gomock.Controller + recorder *MockObsClientInterfaceMockRecorder + isgomock struct{} +} + +// MockObsClientInterfaceMockRecorder is the mock recorder for MockObsClientInterface. +type MockObsClientInterfaceMockRecorder struct { + mock *MockObsClientInterface +} + +// NewMockObsClientInterface creates a new mock instance. +func NewMockObsClientInterface(ctrl *gomock.Controller) *MockObsClientInterface { + mock := &MockObsClientInterface{ctrl: ctrl} + mock.recorder = &MockObsClientInterfaceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockObsClientInterface) EXPECT() *MockObsClientInterfaceMockRecorder { + return m.recorder +} + +// BuildStatus mocks base method. +func (m *MockObsClientInterface) BuildStatus(project string, packages ...string) (*common.BuildResultList, error) { + m.ctrl.T.Helper() + varargs := []any{project} + for _, a := range packages { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "BuildStatus", varargs...) + ret0, _ := ret[0].(*common.BuildResultList) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// BuildStatus indicates an expected call of BuildStatus. +func (mr *MockObsClientInterfaceMockRecorder) BuildStatus(project any, packages ...any) *MockObsClientInterfaceBuildStatusCall { + mr.mock.ctrl.T.Helper() + varargs := append([]any{project}, packages...) + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildStatus", reflect.TypeOf((*MockObsClientInterface)(nil).BuildStatus), varargs...) + return &MockObsClientInterfaceBuildStatusCall{Call: call} +} + +// MockObsClientInterfaceBuildStatusCall wrap *gomock.Call +type MockObsClientInterfaceBuildStatusCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObsClientInterfaceBuildStatusCall) Return(arg0 *common.BuildResultList, arg1 error) *MockObsClientInterfaceBuildStatusCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObsClientInterfaceBuildStatusCall) Do(f func(string, ...string) (*common.BuildResultList, error)) *MockObsClientInterfaceBuildStatusCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObsClientInterfaceBuildStatusCall) DoAndReturn(f func(string, ...string) (*common.BuildResultList, error)) *MockObsClientInterfaceBuildStatusCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// DeleteProject mocks base method. +func (m *MockObsClientInterface) DeleteProject(project string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteProject", project) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteProject indicates an expected call of DeleteProject. +func (mr *MockObsClientInterfaceMockRecorder) DeleteProject(project any) *MockObsClientInterfaceDeleteProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteProject", reflect.TypeOf((*MockObsClientInterface)(nil).DeleteProject), project) + return &MockObsClientInterfaceDeleteProjectCall{Call: call} +} + +// MockObsClientInterfaceDeleteProjectCall wrap *gomock.Call +type MockObsClientInterfaceDeleteProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObsClientInterfaceDeleteProjectCall) Return(arg0 error) *MockObsClientInterfaceDeleteProjectCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObsClientInterfaceDeleteProjectCall) Do(f func(string) error) *MockObsClientInterfaceDeleteProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObsClientInterfaceDeleteProjectCall) DoAndReturn(f func(string) error) *MockObsClientInterfaceDeleteProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// GetHomeProject mocks base method. +func (m *MockObsClientInterface) GetHomeProject() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetHomeProject") + ret0, _ := ret[0].(string) + return ret0 +} + +// GetHomeProject indicates an expected call of GetHomeProject. +func (mr *MockObsClientInterfaceMockRecorder) GetHomeProject() *MockObsClientInterfaceGetHomeProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHomeProject", reflect.TypeOf((*MockObsClientInterface)(nil).GetHomeProject)) + return &MockObsClientInterfaceGetHomeProjectCall{Call: call} +} + +// MockObsClientInterfaceGetHomeProjectCall wrap *gomock.Call +type MockObsClientInterfaceGetHomeProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObsClientInterfaceGetHomeProjectCall) Return(arg0 string) *MockObsClientInterfaceGetHomeProjectCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObsClientInterfaceGetHomeProjectCall) Do(f func() string) *MockObsClientInterfaceGetHomeProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObsClientInterfaceGetHomeProjectCall) DoAndReturn(f func() string) *MockObsClientInterfaceGetHomeProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// GetProjectMeta mocks base method. +func (m *MockObsClientInterface) GetProjectMeta(project string) (*common.ProjectMeta, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetProjectMeta", project) + ret0, _ := ret[0].(*common.ProjectMeta) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProjectMeta indicates an expected call of GetProjectMeta. +func (mr *MockObsClientInterfaceMockRecorder) GetProjectMeta(project any) *MockObsClientInterfaceGetProjectMetaCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProjectMeta", reflect.TypeOf((*MockObsClientInterface)(nil).GetProjectMeta), project) + return &MockObsClientInterfaceGetProjectMetaCall{Call: call} +} + +// MockObsClientInterfaceGetProjectMetaCall wrap *gomock.Call +type MockObsClientInterfaceGetProjectMetaCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObsClientInterfaceGetProjectMetaCall) Return(arg0 *common.ProjectMeta, arg1 error) *MockObsClientInterfaceGetProjectMetaCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObsClientInterfaceGetProjectMetaCall) Do(f func(string) (*common.ProjectMeta, error)) *MockObsClientInterfaceGetProjectMetaCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObsClientInterfaceGetProjectMetaCall) DoAndReturn(f func(string) (*common.ProjectMeta, error)) *MockObsClientInterfaceGetProjectMetaCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SetHomeProject mocks base method. +func (m *MockObsClientInterface) SetHomeProject(project string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetHomeProject", project) +} + +// SetHomeProject indicates an expected call of SetHomeProject. +func (mr *MockObsClientInterfaceMockRecorder) SetHomeProject(project any) *MockObsClientInterfaceSetHomeProjectCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetHomeProject", reflect.TypeOf((*MockObsClientInterface)(nil).SetHomeProject), project) + return &MockObsClientInterfaceSetHomeProjectCall{Call: call} +} + +// MockObsClientInterfaceSetHomeProjectCall wrap *gomock.Call +type MockObsClientInterfaceSetHomeProjectCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObsClientInterfaceSetHomeProjectCall) Return() *MockObsClientInterfaceSetHomeProjectCall { + c.Call = c.Call.Return() + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObsClientInterfaceSetHomeProjectCall) Do(f func(string)) *MockObsClientInterfaceSetHomeProjectCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObsClientInterfaceSetHomeProjectCall) DoAndReturn(f func(string)) *MockObsClientInterfaceSetHomeProjectCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// SetProjectMeta mocks base method. +func (m *MockObsClientInterface) SetProjectMeta(meta *common.ProjectMeta) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetProjectMeta", meta) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetProjectMeta indicates an expected call of SetProjectMeta. +func (mr *MockObsClientInterfaceMockRecorder) SetProjectMeta(meta any) *MockObsClientInterfaceSetProjectMetaCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetProjectMeta", reflect.TypeOf((*MockObsClientInterface)(nil).SetProjectMeta), meta) + return &MockObsClientInterfaceSetProjectMetaCall{Call: call} +} + +// MockObsClientInterfaceSetProjectMetaCall wrap *gomock.Call +type MockObsClientInterfaceSetProjectMetaCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockObsClientInterfaceSetProjectMetaCall) Return(arg0 error) *MockObsClientInterfaceSetProjectMetaCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockObsClientInterfaceSetProjectMetaCall) Do(f func(*common.ProjectMeta) error) *MockObsClientInterfaceSetProjectMetaCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockObsClientInterfaceSetProjectMetaCall) DoAndReturn(f func(*common.ProjectMeta) error) *MockObsClientInterfaceSetProjectMetaCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/common/obs_utils.go b/common/obs_utils.go index 0417968..3a0bb33 100644 --- a/common/obs_utils.go +++ b/common/obs_utils.go @@ -46,6 +46,15 @@ type ObsStatusFetcherWithState interface { BuildStatusWithState(project string, opts *BuildResultOptions, packages ...string) (*BuildResultList, error) } +type ObsClientInterface interface { + GetProjectMeta(project string) (*ProjectMeta, error) + SetProjectMeta(meta *ProjectMeta) error + DeleteProject(project string) error + BuildStatus(project string, packages ...string) (*BuildResultList, error) + GetHomeProject() string + SetHomeProject(project string) +} + type ObsClient struct { baseUrl *url.URL client *http.Client @@ -57,6 +66,14 @@ type ObsClient struct { HomeProject string } +func (c *ObsClient) GetHomeProject() string { + return c.HomeProject +} + +func (c *ObsClient) SetHomeProject(project string) { + c.HomeProject = project +} + func NewObsClient(host string) (*ObsClient, error) { baseUrl, err := url.Parse(host) if err != nil { diff --git a/obs-staging-bot/main.go b/obs-staging-bot/main.go index dfe836f..37435e5 100644 --- a/obs-staging-bot/main.go +++ b/obs-staging-bot/main.go @@ -50,6 +50,10 @@ const ( var runId uint +var GitWorkTreeAllocate func(string, string, string) (common.GitHandlerGenerator, error) = func(basePath, gitAuthor, email string) (common.GitHandlerGenerator, error) { + return common.AllocateGitWorkTree(basePath, gitAuthor, email) +} + func FetchPrGit(git common.Git, pr *models.PullRequest) error { // clone PR head via base (target) repo cloneURL := pr.Base.Repo.CloneURL @@ -144,9 +148,9 @@ func ProcessBuildStatus(project *common.BuildResultList) BuildStatusSummary { func ProcessRepoBuildStatus(results []*common.PackageBuildStatus) (status BuildStatusSummary) { - PackageBuildStatusSorter := func(a, b *common.PackageBuildStatus) int { - return strings.Compare(a.Package, b.Package) - } + PackageBuildStatusSorter := func(a, b *common.PackageBuildStatus) int { + return strings.Compare(a.Package, b.Package) + } common.LogDebug("******* RESULTS: ") data, _ := xml.MarshalIndent(results, "", " ") @@ -191,24 +195,23 @@ func GetPackageBuildStatus(project *common.BuildResultList, packageName string) return true, BuildStatusSummaryUnknown // true for 'missing' } - // Check for any failures + // Check for any unfinished builds for _, pkgStatus := range packageStatuses { res, ok := common.ObsBuildStatusDetails[pkgStatus.Code] if !ok { common.LogInfo("unknown package result code:", pkgStatus.Code, "for package:", pkgStatus.Package) return false, BuildStatusSummaryUnknown } - if !res.Success { - return false, BuildStatusSummaryFailed + if !res.Finished { + return false, BuildStatusSummaryBuilding } } - // Check for any unfinished builds + // Check for any failures for _, pkgStatus := range packageStatuses { res, _ := common.ObsBuildStatusDetails[pkgStatus.Code] - // 'ok' is already checked in the loop above - if !res.Finished { - return false, BuildStatusSummaryBuilding + if !res.Success { + return false, BuildStatusSummaryFailed } } @@ -216,7 +219,7 @@ func GetPackageBuildStatus(project *common.BuildResultList, packageName string) return false, BuildStatusSummarySuccess } -func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingPrj, buildPrj string, stagingMasterPrj string) (*common.ProjectMeta, error) { +func GenerateObsPrjMeta(obs common.ObsClientInterface, git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingPrj, buildPrj string, stagingMasterPrj string) (*common.ProjectMeta, error) { common.LogDebug("repo content fetching ...") err := FetchPrGit(git, pr) if err != nil { @@ -260,13 +263,13 @@ func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullReque } common.LogDebug("Trying first staging master project: ", stagingMasterPrj) - meta, err := ObsClient.GetProjectMeta(stagingMasterPrj) + meta, err := obs.GetProjectMeta(stagingMasterPrj) if err == nil { // success, so we use that staging master project as our build project buildPrj = stagingMasterPrj } else { common.LogInfo("error fetching project meta for ", stagingMasterPrj, ". Fall Back to ", buildPrj) - meta, err = ObsClient.GetProjectMeta(buildPrj) + meta, err = obs.GetProjectMeta(buildPrj) } if err != nil { common.LogError("error fetching project meta for", buildPrj, ". Err:", err) @@ -330,10 +333,10 @@ func GenerateObsPrjMeta(git common.Git, gitea common.Gitea, pr *models.PullReque // stagingProject:$buildProject // ^- stagingProject:$buildProject:$subProjectName (based on templateProject) -func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingProject, templateProject, subProjectName string, buildDisableRepos []string) error { +func CreateQASubProject(obs common.ObsClientInterface, stagingConfig *common.StagingConfig, git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingProject, templateProject, subProjectName string, buildDisableRepos []string) error { common.LogDebug("Setup QA sub projects") common.LogDebug("reading templateProject ", templateProject) - templateMeta, err := ObsClient.GetProjectMeta(templateProject) + templateMeta, err := obs.GetProjectMeta(templateProject) if err != nil { common.LogError("error fetching template project meta for", templateProject, ":", err) return err @@ -343,10 +346,10 @@ func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, git templateMeta.Name = stagingProject + ":" + subProjectName // freeze tag for now if len(templateMeta.ScmSync) > 0 { - repository, err := url.Parse(templateMeta.ScmSync) - if err != nil { - panic(err) - } + repository, err := url.Parse(templateMeta.ScmSync) + if err != nil { + panic(err) + } common.LogDebug("getting data for ", repository.EscapedPath()) split := strings.Split(repository.EscapedPath(), "/") @@ -354,12 +357,12 @@ func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, git common.LogDebug("getting commit for ", org, " repo ", repo, " fragment ", repository.Fragment) branch, err := gitea.GetCommit(org, repo, repository.Fragment) - if err != nil { - panic(err) - } + if err != nil { + panic(err) + } // set expanded commit url - repository.Fragment = branch.SHA + repository.Fragment = branch.SHA templateMeta.ScmSync = repository.String() common.LogDebug("Setting scmsync url to ", templateMeta.ScmSync) } @@ -406,11 +409,11 @@ func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, git templateMeta.Repositories[idx].Paths[pidx].Project = templateMeta.Name } else // Check for path prefixes against a template project inside of template project area - if strings.HasPrefix(path.Project, stagingConfig.StagingProject + ":") { + if strings.HasPrefix(path.Project, stagingConfig.StagingProject+":") { newProjectName := stagingProject // find project name for _, setup := range stagingConfig.QA { - if setup.Origin == path.Project { + if setup.Origin == path.Project { common.LogDebug(" Match:", setup.Origin) newProjectName = newProjectName + ":" + setup.Name common.LogDebug(" New:", newProjectName) @@ -418,14 +421,14 @@ func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, git } } templateMeta.Repositories[idx].Paths[pidx].Project = newProjectName - common.LogDebug(" Matched prefix") + common.LogDebug(" Matched prefix") } common.LogDebug(" Path using project ", templateMeta.Repositories[idx].Paths[pidx].Project) } } if !IsDryRun { - err = ObsClient.SetProjectMeta(templateMeta) + err = obs.SetProjectMeta(templateMeta) if err != nil { common.LogError("cannot create project:", templateMeta.Name, err) x, _ := xml.MarshalIndent(templateMeta, "", " ") @@ -439,10 +442,10 @@ func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, git return nil } -func StartOrUpdateBuild(config *common.StagingConfig, git common.Git, gitea common.Gitea, pr *models.PullRequest) (RequestModification, error) { +func StartOrUpdateBuild(obs common.ObsClientInterface, config *common.StagingConfig, git common.Git, gitea common.Gitea, pr *models.PullRequest) (RequestModification, error) { common.LogDebug("fetching OBS project Meta") - obsPrProject := GetObsProjectAssociatedWithPr(config, ObsClient.HomeProject, pr) - meta, err := ObsClient.GetProjectMeta(obsPrProject) + obsPrProject := GetObsProjectAssociatedWithPr(config, obs.GetHomeProject(), pr) + meta, err := obs.GetProjectMeta(obsPrProject) if err != nil { common.LogError("error fetching project meta for", obsPrProject, ":", err) return RequestModificationNoChange, err @@ -467,7 +470,7 @@ func StartOrUpdateBuild(config *common.StagingConfig, git common.Git, gitea comm if meta == nil { // new build common.LogDebug(" Staging master:", config.StagingProject) - meta, err = GenerateObsPrjMeta(git, gitea, pr, obsPrProject, config.ObsProject, config.StagingProject) + meta, err = GenerateObsPrjMeta(obs, git, gitea, pr, obsPrProject, config.ObsProject, config.StagingProject) if err != nil { return RequestModificationNoChange, err } @@ -479,7 +482,7 @@ func StartOrUpdateBuild(config *common.StagingConfig, git common.Git, gitea comm common.LogDebug("Creating build project:") common.LogDebug(" meta:", string(x)) } else { - err = ObsClient.SetProjectMeta(meta) + err = obs.SetProjectMeta(meta) if err != nil { x, _ := xml.MarshalIndent(meta, "", " ") common.LogDebug(" meta:", string(x)) @@ -550,7 +553,7 @@ func ParseNotificationToPR(thread *models.NotificationThread) (org string, repo return } -func ProcessPullNotification(gitea common.Gitea, thread *models.NotificationThread) { +func ProcessPullNotification(obs common.ObsClientInterface, gitea common.Gitea, thread *models.NotificationThread) { defer func() { err := recover() if err != nil { @@ -566,7 +569,7 @@ func ProcessPullNotification(gitea common.Gitea, thread *models.NotificationThre } common.LogInfo("processing PR:", org, "/", repo, "#", num) - done, err := ProcessPullRequest(gitea, org, repo, num) + done, err := ProcessPullRequest(obs, gitea, org, repo, num) if !IsDryRun && err == nil && done { gitea.SetNotificationRead(thread.ID) } else if err != nil { @@ -576,7 +579,7 @@ func ProcessPullNotification(gitea common.Gitea, thread *models.NotificationThre var CleanedUpIssues []int64 = []int64{} -func CleanupPullNotification(gitea common.Gitea, thread *models.NotificationThread) (CleanupComplete bool) { +func CleanupPullNotification(obs common.ObsClientInterface, gitea common.Gitea, thread *models.NotificationThread) (CleanupComplete bool) { defer func() { err := recover() if err != nil { @@ -643,8 +646,8 @@ func CleanupPullNotification(gitea common.Gitea, thread *models.NotificationThre return false } - stagingProject := GetObsProjectAssociatedWithPr(config, ObsClient.HomeProject, pr) - if prj, err := ObsClient.GetProjectMeta(stagingProject); err != nil { + stagingProject := GetObsProjectAssociatedWithPr(config, obs.GetHomeProject(), pr) + if prj, err := obs.GetProjectMeta(stagingProject); err != nil { common.LogError("Failed fetching meta for project:", stagingProject, ". Not cleaning up") return false } else if prj == nil && err == nil { @@ -658,13 +661,13 @@ func CleanupPullNotification(gitea common.Gitea, thread *models.NotificationThre project := stagingProject + ":" + qa.Name common.LogDebug("Cleaning up QA staging", project) if !IsDryRun { - if err := ObsClient.DeleteProject(project); err != nil { + if err := obs.DeleteProject(project); err != nil { common.LogError("Failed to cleanup QA staging", project, err) } } } if !IsDryRun { - if err := ObsClient.DeleteProject(stagingProject); err != nil { + if err := obs.DeleteProject(stagingProject); err != nil { common.LogError("Failed to cleanup staging", stagingProject, err) } } @@ -685,7 +688,7 @@ func SetStatus(gitea common.Gitea, org, repo, hash string, status *models.Commit return err } -func commentOnPackagePR(gitea common.Gitea, org string, repo string, prNum int64, msg string) { +func CommentPROnce(gitea common.Gitea, org string, repo string, prNum int64, msg string) { if IsDryRun { common.LogInfo("Would comment on package PR %s/%s#%d: %s", org, repo, prNum, msg) return @@ -697,6 +700,18 @@ func commentOnPackagePR(gitea common.Gitea, org string, repo string, prNum int64 return } + timeline, err := gitea.GetTimeline(org, repo, prNum) + if err != nil { + common.LogError("Failed to get timeline for PR %s/%s#%d: %v", org, repo, prNum, err) + return + } + + for _, t := range timeline { + if t.User != nil && t.User.UserName == BotUser && t.Type == common.TimelineCommentType_Comment && t.Body == msg { + return + } + } + err = gitea.AddComment(pr, msg) if err != nil { common.LogError("Failed to comment on package PR %s/%s#%d: %v", org, repo, prNum, err) @@ -704,20 +719,21 @@ func commentOnPackagePR(gitea common.Gitea, org string, repo string, prNum int64 } // Create and remove QA projects -func ProcessQaProjects(stagingConfig *common.StagingConfig, git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingProject string) []string { +func ProcessQaProjects(obs common.ObsClientInterface, stagingConfig *common.StagingConfig, git common.Git, gitea common.Gitea, pr *models.PullRequest, stagingProject string) ([]string, string) { usedQAprojects := make([]string, 0) prLabelNames := make(map[string]int) for _, label := range pr.Labels { prLabelNames[label.Name] = 1 } msg := "" + var qa_projects []string for _, setup := range stagingConfig.QA { QAproject := stagingProject + ":" + setup.Name if len(setup.Label) > 0 { if _, ok := prLabelNames[setup.Label]; !ok { if !IsDryRun { // blindly remove, will fail when not existing - ObsClient.DeleteProject(QAproject) + obs.DeleteProject(QAproject) } common.LogInfo("QA project ", setup.Name, "has no matching Label") continue @@ -726,24 +742,25 @@ func ProcessQaProjects(stagingConfig *common.StagingConfig, git common.Git, gite usedQAprojects = append(usedQAprojects, QAproject) // check for existens first, no error, but no meta is a 404 - if meta, err := ObsClient.GetProjectMeta(QAproject); meta == nil && err == nil { + if meta, err := obs.GetProjectMeta(QAproject); meta == nil && err == nil { common.LogInfo("Create QA project ", QAproject) - CreateQASubProject(stagingConfig, git, gitea, pr, + CreateQASubProject(obs, stagingConfig, git, gitea, pr, stagingProject, setup.Origin, setup.Name, setup.BuildDisableRepos) - msg = msg + "QA Project added: " + ObsWebHost + "/project/show/" + - QAproject + "\n" + qa_projects = append(qa_projects, ObsWebHost+"/project/show/"+QAproject) } } - if len(msg) > 1 { - gitea.AddComment(pr, msg) + + if len(qa_projects) > 0 { + msg = "Additional QA builds:\n" + strings.Join(qa_projects, "\n") } - return usedQAprojects + + return usedQAprojects, msg } -func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, error) { +func ProcessPullRequest(obs common.ObsClientInterface, gitea common.Gitea, org, repo string, id int64) (bool, error) { dir, err := os.MkdirTemp(os.TempDir(), BotName) common.PanicOnError(err) if IsDryRun { @@ -752,7 +769,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e defer os.RemoveAll(dir) } - gh, err := common.AllocateGitWorkTree(dir, GitAuthor, "noaddress@suse.de") + gh, err := GitWorkTreeAllocate(dir, GitAuthor, "noaddress@suse.de") common.PanicOnError(err) git, err := gh.CreateGitHandler(org) @@ -797,7 +814,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e if err != nil { common.LogError("Staging config", common.StagingConfigFile, "not found in PR to the project. Aborting.") if !IsDryRun { - _, err = gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Cannot find project config in PR: "+common.ProjectConfigFile) + _, _ = gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Cannot find project config in PR: "+common.ProjectConfigFile) } return true, err } @@ -817,7 +834,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e return true, nil } - meta, err := ObsClient.GetProjectMeta(stagingConfig.ObsProject) + meta, err := obs.GetProjectMeta(stagingConfig.ObsProject) if err != nil || meta == nil { common.LogError("Cannot find reference project meta:", stagingConfig.ObsProject, err) if !IsDryRun && err == nil { @@ -946,8 +963,8 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e } common.LogDebug("ObsProject:", stagingConfig.ObsProject) - stagingProject := GetObsProjectAssociatedWithPr(stagingConfig, ObsClient.HomeProject, pr) - change, err := StartOrUpdateBuild(stagingConfig, git, gitea, pr) + stagingProject := GetObsProjectAssociatedWithPr(stagingConfig, obs.GetHomeProject(), pr) + change, err := StartOrUpdateBuild(obs, stagingConfig, git, gitea, pr) status := &models.CommitStatus{ Context: BotName, Description: "OBS Staging build", @@ -978,11 +995,8 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e SetStatus(gitea, org, repo, pr.Head.Sha, status) } - if change != RequestModificationNoChange && !IsDryRun { - gitea.AddComment(pr, msg) - } - stagingResult, err := ObsClient.BuildStatus(stagingProject) + stagingResult, err := obs.BuildStatus(stagingProject) if err != nil { common.LogError("failed fetching stage project status for", stagingProject, ":", err) } @@ -990,7 +1004,14 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e _, packagePRs := common.ExtractDescriptionAndPRs(bufio.NewScanner(strings.NewReader(pr.Body))) // always update QA projects because Labels can change - qaProjects := ProcessQaProjects(stagingConfig, git, gitea, pr, stagingProject) + qaProjects, qaProjectMsg := ProcessQaProjects(obs, stagingConfig, git, gitea, pr, stagingProject) + + if change != RequestModificationNoChange && !IsDryRun { + if len(qaProjectMsg) > 0 { + msg += "\n" + qaProjectMsg + } + CommentPROnce(gitea, org, repo, id, msg) + } done := false overallBuildStatus := ProcessBuildStatus(stagingResult) @@ -998,7 +1019,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e if len(qaProjects) > 0 && overallBuildStatus == BuildStatusSummarySuccess { seperator := " in " for _, qaProject := range qaProjects { - qaResult, err := ObsClient.BuildStatus(qaProject) + qaResult, err := obs.BuildStatus(qaProject) if err != nil { common.LogError("failed fetching stage project status for", qaProject, ":", err) } @@ -1058,7 +1079,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e default: continue } - commentOnPackagePR(gitea, packagePR.Org, packagePR.Repo, packagePR.Num, msg) + CommentPROnce(gitea, packagePR.Org, packagePR.Repo, packagePR.Num, msg) } if len(missingPkgs) > 0 { @@ -1068,10 +1089,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e msg = msg + " - " + pkg + "\n" } common.LogInfo(msg) - err := gitea.AddComment(pr, msg) - if err != nil { - common.LogError(err) - } + CommentPROnce(gitea, org, repo, id, msg) } } @@ -1090,8 +1108,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e return false, nil } -func PollWorkNotifications(giteaUrl string) { - gitea := common.AllocateGiteaTransport(giteaUrl) +func PollWorkNotifications(obs common.ObsClientInterface, gitea common.Gitea) { data, err := gitea.GetNotifications(common.GiteaNotificationType_Pull, nil) if err != nil { @@ -1107,7 +1124,7 @@ func PollWorkNotifications(giteaUrl string) { if !ListPullNotificationsOnly { switch notification.Subject.Type { case "Pull": - ProcessPullNotification(gitea, notification) + ProcessPullNotification(obs, gitea, notification) default: if !IsDryRun { gitea.SetNotificationRead(notification.ID) @@ -1130,7 +1147,7 @@ func PollWorkNotifications(giteaUrl string) { continue } - cleanupFinished = CleanupPullNotification(gitea, n) && cleanupFinished + cleanupFinished = CleanupPullNotification(obs, gitea, n) && cleanupFinished } } else if err != nil { common.LogError(err) @@ -1144,7 +1161,8 @@ var ObsApiHost string var ObsWebHost string var IsDryRun bool var ProcessPROnly string -var ObsClient *common.ObsClient +var ObsClient common.ObsClientInterface +var BotUser string func ObsWebHostFromApiHost(apihost string) string { u, err := url.Parse(apihost) @@ -1209,9 +1227,18 @@ func main() { } if len(*buildRoot) > 0 { - ObsClient.HomeProject = *buildRoot + ObsClient.SetHomeProject(*buildRoot) } + gitea := common.AllocateGiteaTransport(GiteaUrl) + + user, err := gitea.GetCurrentUser() + if err != nil { + common.LogError("Cannot fetch current user:", err) + return + } + BotUser = user.UserName + if len(*ProcessPROnly) > 0 { rx := regexp.MustCompile("^([^/#]+)/([^/#]+)#([0-9]+)$") m := rx.FindStringSubmatch(*ProcessPROnly) @@ -1220,15 +1247,14 @@ func main() { return } - gitea := common.AllocateGiteaTransport(GiteaUrl) id, _ := strconv.ParseInt(m[3], 10, 64) - ProcessPullRequest(gitea, m[1], m[2], id) + ProcessPullRequest(ObsClient, gitea, m[1], m[2], id) return } for { - PollWorkNotifications(GiteaUrl) + PollWorkNotifications(ObsClient, gitea) common.LogInfo("Poll cycle finished") time.Sleep(5 * time.Minute) } diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index 77c77f1..40721f0 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -1,14 +1,34 @@ package main import ( + "errors" + "os" "strconv" "strings" "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_common "src.opensuse.org/autogits/common/mock" ) +func TestSampleWithMocks(t *testing.T) { + ctrl := gomock.NewController(t) + + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + + mockObs.EXPECT().GetHomeProject().Return("home:testuser") + if mockObs.GetHomeProject() != "home:testuser" { + t.Error("Mock did not return expected value") + } + + _ = mockGitea +} + func TestObsAPIHostFromWebHost(t *testing.T) { tests := []struct { name string @@ -85,6 +105,21 @@ func TestPRtoObsProjectMapping(t *testing.T) { pr: "_some_thing/_PrjX/14", expectedProject: "staging:project:Pull_Request:14", }, + { + name: "colon in repo name", + pr: "org/:repo/15", + expectedProject: "home:foo:org:Xrepo:PR:15", + }, + { + name: "dot in repo name", + pr: "org/.repo/16", + expectedProject: "home:foo:org:Xrepo:PR:16", + }, + { + name: "DefaultGitPrj repo name", + pr: "org/_ObsPrj/17", + expectedProject: "home:foo:org:PR:17", + }, } for _, test := range tests { @@ -114,3 +149,1534 @@ func TestPRtoObsProjectMapping(t *testing.T) { func TestStatusCodeResults(t *testing.T) { } + +func TestProcessRepoBuildStatus(t *testing.T) { + tests := []struct { + name string + results []*common.PackageBuildStatus + expected BuildStatusSummary + }{ + { + name: "All succeeded", + results: []*common.PackageBuildStatus{ + {Package: "pkg1", Code: "succeeded"}, + {Package: "pkg2", Code: "succeeded"}, + }, + expected: BuildStatusSummarySuccess, + }, + { + name: "One failed", + results: []*common.PackageBuildStatus{ + {Package: "pkg1", Code: "succeeded"}, + {Package: "pkg2", Code: "failed"}, + }, + expected: BuildStatusSummaryFailed, + }, + { + name: "One building", + results: []*common.PackageBuildStatus{ + {Package: "pkg1", Code: "succeeded"}, + {Package: "pkg2", Code: "building"}, + }, + expected: BuildStatusSummaryBuilding, + }, + { + name: "Unknown code", + results: []*common.PackageBuildStatus{ + {Package: "pkg1", Code: "weird_code"}, + }, + expected: BuildStatusSummaryUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ProcessRepoBuildStatus(tt.results); got != tt.expected { + t.Errorf("ProcessRepoBuildStatus() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestProcessBuildStatus(t *testing.T) { + tests := []struct { + name string + project *common.BuildResultList + expected BuildStatusSummary + }{ + { + name: "All successful", + project: &common.BuildResultList{ + Result: []*common.BuildResult{ + { + Repository: "repo1", Arch: "x86_64", Code: "published", + Status: []*common.PackageBuildStatus{ + {Package: "pkg1", Code: "succeeded"}, + }, + }, + }, + }, + expected: BuildStatusSummarySuccess, + }, + { + name: "Still building", + project: &common.BuildResultList{ + Result: []*common.BuildResult{ + { + Repository: "repo1", Arch: "x86_64", Code: "building", + Status: []*common.PackageBuildStatus{ + {Package: "pkg1", Code: "building"}, + }, + }, + }, + }, + expected: BuildStatusSummaryBuilding, + }, + { + name: "Failed", + project: &common.BuildResultList{ + Result: []*common.BuildResult{ + { + Repository: "repo1", Arch: "x86_64", Code: "broken", + Status: []*common.PackageBuildStatus{ + {Package: "pkg1", Code: "failed"}, + }, + }, + }, + }, + expected: BuildStatusSummaryFailed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ProcessBuildStatus(tt.project); got != tt.expected { + t.Errorf("ProcessBuildStatus() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestGetPackageBuildStatus(t *testing.T) { + project := &common.BuildResultList{ + Result: []*common.BuildResult{ + { + Status: []*common.PackageBuildStatus{ + {Package: "pkg1", Code: "succeeded"}, + {Package: "pkg2", Code: "failed"}, + {Package: "pkg3", Code: "building"}, + }, + }, + }, + } + + tests := []struct { + name string + pkgName string + wantMissing bool + wantStatus BuildStatusSummary + }{ + {"Found success", "pkg1", false, BuildStatusSummarySuccess}, + {"Found failed", "pkg2", false, BuildStatusSummaryFailed}, + {"Found building", "pkg3", false, BuildStatusSummaryBuilding}, + {"Not found", "pkg4", true, BuildStatusSummaryUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMissing, gotStatus := GetPackageBuildStatus(project, tt.pkgName) + if gotMissing != tt.wantMissing { + t.Errorf("GetPackageBuildStatus() missing = %v, want %v", gotMissing, tt.wantMissing) + } + if gotStatus != tt.wantStatus { + t.Errorf("GetPackageBuildStatus() status = %v, want %v", gotStatus, tt.wantStatus) + } + }) + } +} + +func TestParseNotificationToPR(t *testing.T) { + tests := []struct { + name string + url string + wantOrg string + wantRepo string + wantNum int64 + wantErr bool + }{ + { + name: "Valid URL", + url: "https://gitea.example.com/api/v1/repos/myorg/myrepo/issues/123", + wantOrg: "myorg", + wantRepo: "myrepo", + wantNum: 123, + wantErr: false, + }, + { + name: "Invalid URL", + url: "https://gitea.example.com/api/v1/repos/myorg/myrepo/something/123", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + thread := &models.NotificationThread{ + Subject: &models.NotificationSubject{ + URL: tt.url, + }, + } + org, repo, num, err := ParseNotificationToPR(thread) + if (err != nil) != tt.wantErr { + t.Errorf("ParseNotificationToPR() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if org != tt.wantOrg || repo != tt.wantRepo || num != tt.wantNum { + t.Errorf("ParseNotificationToPR() = %v, %v, %v, want %v, %v, %v", org, repo, num, tt.wantOrg, tt.wantRepo, tt.wantNum) + } + } + }) + } +} + +func TestIsReviewerRequested(t *testing.T) { + tests := []struct { + name string + reviewers []*models.User + want bool + }{ + { + name: "Bot is requested", + reviewers: []*models.User{ + {UserName: "someuser"}, + {UserName: Username}, + }, + want: true, + }, + { + name: "Bot is not requested", + reviewers: []*models.User{ + {UserName: "someuser"}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pr := &models.PullRequest{ + RequestedReviewers: tt.reviewers, + } + if got := IsReviewerRequested(pr); got != tt.want { + t.Errorf("IsReviewerRequested() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFetchOurLatestActionableReview(t *testing.T) { + ctrl := gomock.NewController(t) + mockGitea := mock_common.NewMockGitea(ctrl) + + org, repo := "org", "repo" + var id int64 = 1 + + tests := []struct { + name string + reviews []*models.PullReview + want *models.PullReview + wantErr error + }{ + { + name: "Latest is actionable", + reviews: []*models.PullReview{ + { + User: &models.User{UserName: Username}, + State: common.ReviewStateApproved, + Submitted: strfmt.DateTime(time.Now().Add(-1 * time.Hour)), + }, + { + User: &models.User{UserName: Username}, + State: common.ReviewStatePending, + Submitted: strfmt.DateTime(time.Now()), + }, + }, + want: &models.PullReview{State: common.ReviewStatePending}, + }, + { + name: "Latest is non-actionable", + reviews: []*models.PullReview{ + { + User: &models.User{UserName: Username}, + State: common.ReviewStatePending, + Submitted: strfmt.DateTime(time.Now().Add(-1 * time.Hour)), + }, + { + User: &models.User{UserName: Username}, + State: common.ReviewStateApproved, + Submitted: strfmt.DateTime(time.Now()), + }, + }, + wantErr: NonActionableReviewError, + }, + { + name: "No reviews for bot", + reviews: []*models.PullReview{ + { + User: &models.User{UserName: "otheruser"}, + }, + }, + wantErr: NoReviewsFoundError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockGitea.EXPECT().GetPullRequestReviews(org, repo, id).Return(tt.reviews, nil) + got, err := FetchOurLatestActionableReview(mockGitea, org, repo, id) + if !errors.Is(err, tt.wantErr) { + t.Errorf("FetchOurLatestActionableReview() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr == nil && got.State != tt.want.State { + t.Errorf("FetchOurLatestActionableReview() got state = %v, want %v", got.State, tt.want.State) + } + }) + } +} + +func TestStartOrUpdateBuild(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + + config := &common.StagingConfig{ + ObsProject: "BaseProject", + StagingProject: "StagingProject", + } + pr := &models.PullRequest{ + Index: 123, + Base: &models.PRBranchInfo{ + Repo: &models.Repository{ + Name: "Repo", + Owner: &models.User{UserName: "Org"}, + CloneURL: "http://headrepo.git", + }, + }, + Head: &models.PRBranchInfo{ + Sha: "headsha", + Repo: &models.Repository{CloneURL: "http://headrepo.git"}, + }, + MergeBase: "base-sha", + } + + obsPrProject := "StagingProject:123" + + t.Run("Build in progress - no change", func(t *testing.T) { + mockObs.EXPECT().GetHomeProject().Return("home:bot") + mockObs.EXPECT().GetProjectMeta(obsPrProject).Return(&common.ProjectMeta{ + ScmSync: "http://headrepo.git#headsha", + }, nil) + + got, err := StartOrUpdateBuild(mockObs, config, mockGit, mockGitea, pr) + if err != nil || got != RequestModificationNoChange { + t.Errorf("StartOrUpdateBuild() = %v, %v; want %v, nil", got, err, RequestModificationNoChange) + } + }) + + t.Run("New build - project created", func(t *testing.T) { + os.Setenv("GITEA_TOKEN", "fake-token") + defer os.Unsetenv("GITEA_TOKEN") + + mockObs.EXPECT().GetHomeProject().Return("home:bot") + mockObs.EXPECT().GetProjectMeta(obsPrProject).Return(nil, nil) + + // Mocking GenerateObsPrjMeta dependencies + mockGit.EXPECT().GetPath().Return("/tmp/git") + // FetchPrGit calls GitExec + mockGit.EXPECT().GitExec("", "clone", "--depth", "1", gomock.Any(), "headsha").Return(nil) + mockGit.EXPECT().GitExec("headsha", "fetch", "--depth", "1", "origin", "headsha", "base-sha").Return(nil) + + mockGit.EXPECT().GitSubmoduleList(gomock.Any(), "headsha").Return(nil, nil) + mockGit.EXPECT().GitSubmoduleList(gomock.Any(), "base-sha").Return(nil, nil) + mockGit.EXPECT().GitDirectoryList(gomock.Any(), "headsha").Return(nil, nil) + mockGit.EXPECT().GitDirectoryList(gomock.Any(), "base-sha").Return(nil, nil) + + mockObs.EXPECT().GetProjectMeta("StagingProject").Return(&common.ProjectMeta{ + Name: "StagingProject", + Repositories: []common.RepositoryMeta{{Name: "repo"}}, + }, nil) + + mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(nil) + + got, err := StartOrUpdateBuild(mockObs, config, mockGit, mockGitea, pr) + if err != nil || got != RequestModificationProjectCreated { + t.Errorf("StartOrUpdateBuild() = %v, %v; want %v, nil", got, err, RequestModificationProjectCreated) + } + }) +} + +func TestGenerateObsPrjMeta(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + + pr := &models.PullRequest{ + Index: 123, + Base: &models.PRBranchInfo{ + Name: "master", + Repo: &models.Repository{ + Name: "Repo", + Owner: &models.User{UserName: "Org"}, + }, + }, + Head: &models.PRBranchInfo{ + Sha: "headsha", + Repo: &models.Repository{CloneURL: "http://headrepo.git"}, + }, + MergeBase: "base-sha", + } + + os.Setenv("GITEA_TOKEN", "fake-token") + + mockGit.EXPECT().GetPath().Return("/tmp/git") + mockGit.EXPECT().GitExec("", "clone", "--depth", "1", gomock.Any(), "headsha").Return(nil) + mockGit.EXPECT().GitExec("headsha", "fetch", "--depth", "1", "origin", "headsha", "base-sha").Return(nil) + + mockGit.EXPECT().GitSubmoduleList(gomock.Any(), "headsha").Return(map[string]string{"pkg1": "sha1"}, nil) + mockGit.EXPECT().GitSubmoduleList(gomock.Any(), "base-sha").Return(map[string]string{"pkg1": "sha0"}, nil) + mockGit.EXPECT().GitDirectoryList(gomock.Any(), "headsha").Return(nil, nil) + mockGit.EXPECT().GitDirectoryList(gomock.Any(), "base-sha").Return(nil, nil) + + mockObs.EXPECT().GetProjectMeta("StagingMaster").Return(&common.ProjectMeta{ + Name: "StagingMaster", + Repositories: []common.RepositoryMeta{ + {Name: "repo1", Paths: []common.RepositoryPathMeta{{Project: "BuildPrj", Repository: "repo1"}}}, + }, + }, nil) + + meta, err := GenerateObsPrjMeta(mockObs, mockGit, mockGitea, pr, "StagingPrj", "BuildPrj", "StagingMaster") + if err != nil { + t.Errorf("GenerateObsPrjMeta() error = %v", err) + } + if meta.Name != "StagingPrj" { + t.Errorf("Expected meta.Name StagingPrj, got %v", meta.Name) + } + if !strings.Contains(meta.ScmSync, "onlybuild=pkg1") { + t.Errorf("Expected ScmSync to contain onlybuild=pkg1, got %v", meta.ScmSync) + } +} + +func TestCreateQASubProject(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + + stagingConfig := &common.StagingConfig{ + ObsProject: "BaseProject", + StagingProject: "StagingProject", + QA: []common.QAConfig{ + {Name: "OtherQA", Origin: "StagingProject:OtherQAOrigin"}, + }, + } + pr := &models.PullRequest{} + + templateMeta := &common.ProjectMeta{ + Name: "TemplateProject", + ScmSync: "http://gitea/org/repo#branch", + Link: []common.ProjectLinkMeta{ + {Project: "StagingProject"}, + }, + Repositories: []common.RepositoryMeta{ + { + Name: "repo1", + Paths: []common.RepositoryPathMeta{ + {Project: "BaseProject", Repository: "repo1"}, + {Project: "TemplateProject", Repository: "repo1"}, + {Project: "StagingProject:OtherQAOrigin", Repository: "repo1"}, + }, + }, + }, + } + + mockObs.EXPECT().GetProjectMeta("TemplateProject").Return(templateMeta, nil) + mockGitea.EXPECT().GetCommit("org", "repo", "branch").Return(&models.Commit{SHA: "fullsha"}, nil) + mockObs.EXPECT().SetProjectMeta(gomock.Any()).DoAndReturn(func(meta *common.ProjectMeta) error { + if meta.Link[0].Project != "ActualStaging" { + t.Errorf("Expected link project ActualStaging, got %v", meta.Link[0].Project) + } + // Paths: + // 1. BaseProject -> ActualStaging + // 2. TemplateProject -> ActualStaging:QAName + // 3. StagingProject:OtherQAOrigin -> ActualStaging:OtherQA + if meta.Repositories[0].Paths[0].Project != "ActualStaging" { + t.Errorf("Path 0 mismatch: %v", meta.Repositories[0].Paths[0].Project) + } + if meta.Repositories[0].Paths[1].Project != "ActualStaging:QAName" { + t.Errorf("Path 1 mismatch: %v", meta.Repositories[0].Paths[1].Project) + } + if meta.Repositories[0].Paths[2].Project != "ActualStaging:OtherQA" { + t.Errorf("Path 2 mismatch: %v", meta.Repositories[0].Paths[2].Project) + } + if !strings.Contains(meta.BuildFlags.Contents, "disable") { + t.Error("Expected disable flags in build flags") + } + return nil + }) + + err := CreateQASubProject(mockObs, stagingConfig, mockGit, mockGitea, pr, "ActualStaging", "TemplateProject", "QAName", []string{"repo-to-disable"}) + if err != nil { + t.Errorf("CreateQASubProject() error = %v", err) + } + + t.Run("SetProjectMeta error", func(t *testing.T) { + mockObs.EXPECT().GetProjectMeta("TemplateProject").Return(templateMeta, nil) + mockGitea.EXPECT().GetCommit(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Commit{SHA: "sha"}, nil) + mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(errors.New("API Error")) + + err := CreateQASubProject(mockObs, stagingConfig, mockGit, mockGitea, pr, "Staging", "TemplateProject", "QA", nil) + if err == nil { + t.Error("Expected error from CreateQASubProject, got nil") + } + }) + + t.Run("Dry run", func(t *testing.T) { + oldDryRun := IsDryRun + IsDryRun = true + defer func() { IsDryRun = oldDryRun }() + + mockObs.EXPECT().GetProjectMeta("TemplateProject").Return(templateMeta, nil) + mockGitea.EXPECT().GetCommit(gomock.Any(), gomock.Any(), gomock.Any()).Return(&models.Commit{SHA: "sha"}, nil) + // SetProjectMeta should NOT be called + + err := CreateQASubProject(mockObs, stagingConfig, mockGit, mockGitea, pr, "Staging", "TemplateProject", "QA", nil) + if err != nil { + t.Errorf("CreateQASubProject() dry run error = %v", err) + } + }) +} + +func TestCleanupPullNotification(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + + thread := &models.NotificationThread{ + ID: 1, + Subject: &models.NotificationSubject{ + URL: "https://gitea/api/v1/repos/org/repo/issues/123", + }, + } + + pr := &models.PullRequest{ + State: "closed", + Index: 123, + Base: &models.PRBranchInfo{ + Repo: &models.Repository{ + Name: "repo", + Owner: &models.User{UserName: "org"}, + }, + }, + Head: &models.PRBranchInfo{Sha: "headsha"}, + } + + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil) + // Mock config file fetching + mockGitea.EXPECT().GetRepositoryFileContent("org", "repo", "headsha", common.StagingConfigFile).Return([]byte(`{"obs_project": "Base"}`), "", nil) + mockObs.EXPECT().GetHomeProject().Return("home:bot") + mockObs.EXPECT().GetProjectMeta("home:bot:org:repo:PR:123").Return(&common.ProjectMeta{Name: "PRProject"}, nil) + mockObs.EXPECT().DeleteProject("home:bot:org:repo:PR:123").Return(nil) + + CleanupPullNotification(mockObs, mockGitea, thread) +} + +func TestProcessPullRequest(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + // Inject GitWorkTreeAllocate + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha", Repo: &models.Repository{CloneURL: "http://headrepo.git"}}, + Base: &models.PRBranchInfo{Name: "master", Repo: &models.Repository{CloneURL: "http://headrepo.git", Owner: &models.User{UserName: "org"}, Name: "repo"}}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil) + + // FetchOurLatestActionableReview and rebuild check + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil).AnyTimes() + + // Issue comments for rebuild check + mockGitea.EXPECT().GetIssueComments("org", "repo", int64(123)).Return(nil, nil).AnyTimes() + + mockGit.EXPECT().GetPath().Return("/tmp/git") + mockGit.EXPECT().GitExec("", "clone", "--depth", "1", gomock.Any(), "headsha").Return(nil) + mockGit.EXPECT().GitExec("headsha", "fetch", "--depth", "1", "origin", "headsha", gomock.Any()).Return(nil) + + // config fetching + mockGit.EXPECT().GitCatFile("headsha", "headsha", common.StagingConfigFile).Return([]byte(`{"ObsProject": "Base", "StagingProject": "BaseProject"}`), nil) + + mockObs.EXPECT().GetProjectMeta("Base").Return(&common.ProjectMeta{ + Name: "Base", + ScmSync: "http://headrepo.git", + Repositories: []common.RepositoryMeta{{Name: "repo"}}, + }, nil).AnyTimes() + + mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + // StartOrUpdateBuild inner calls + mockObs.EXPECT().GetHomeProject().Return("home:bot").AnyTimes() + mockObs.EXPECT().GetProjectMeta("BaseProject:123").Return(nil, nil).AnyTimes() + // GenerateObsPrjMeta inner calls + mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(nil).AnyTimes() + + // Status updates + mockGitea.EXPECT().SetCommitStatus("org", "repo", "headsha", gomock.Any()).Return(nil, nil).AnyTimes() + mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + // Build status + mockObs.EXPECT().BuildStatus("BaseProject:123").Return(&common.BuildResultList{ + Result: []*common.BuildResult{{Code: "published", Status: []*common.PackageBuildStatus{{Package: "pkg", Code: "succeeded"}}}}, + }, nil).AnyTimes() + + mockGitea.EXPECT().AddReviewComment(gomock.Any(), common.ReviewStateApproved, gomock.Any()).Return(nil, nil) + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } +} + +func TestProcessPullRequest_Closed(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(&models.PullRequest{State: "closed"}, nil) + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } +} + +func TestProcessPullRequest_NoReviewer(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: "other"}}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil) + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } +} + +func TestProcessPullRequest_MissingConfig(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha"}, + Base: &models.PRBranchInfo{Repo: &models.Repository{CloneURL: "http://repo.git"}}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil) + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil) + + mockGit.EXPECT().GetPath().Return("/tmp/git") + // clone: git.GitExec("", "clone", "--depth", "1", gomock.Any(), "headsha") + mockGit.EXPECT().GitExec("", "clone", "--depth", "1", gomock.Any(), "headsha").Return(nil) + // fetch: git.GitExec(pr.Head.Sha, "fetch", "--depth", "1", "origin", pr.Head.Sha, pr.MergeBase) + mockGit.EXPECT().GitExec("headsha", "fetch", "--depth", "1", "origin", "headsha", gomock.Any()).Return(nil) + + mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, errors.New("not found")) + mockGitea.EXPECT().AddReviewComment(gomock.Any(), common.ReviewStateRequestChanges, gomock.Any()).Return(nil, nil) + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err == nil { + t.Error("Expected error from missing config, got nil") + } + if !done { + t.Error("Expected done=true for missing config error") + } +} + +func TestProcessPullRequest_ObsMetaMissing(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha"}, + Base: &models.PRBranchInfo{Repo: &models.Repository{CloneURL: "http://repo.git"}}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil) + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil) + + mockGit.EXPECT().GetPath().Return("/tmp/git") + // clone: git.GitExec("", "clone", "--depth", "1", gomock.Any(), "headsha") + mockGit.EXPECT().GitExec("", "clone", "--depth", "1", gomock.Any(), "headsha").Return(nil) + // fetch: git.GitExec(pr.Head.Sha, "fetch", "--depth", "1", "origin", pr.Head.Sha, pr.MergeBase) + mockGit.EXPECT().GitExec("headsha", "fetch", "--depth", "1", "origin", "headsha", gomock.Any()).Return(nil) + + mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(`{"ObsProject": "Base"}`), nil) + + mockObs.EXPECT().GetProjectMeta("Base").Return(nil, nil) + mockGitea.EXPECT().AddReviewComment(gomock.Any(), common.ReviewStateRequestChanges, gomock.Any()).Return(nil, nil) + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil { + t.Errorf("ProcessPullRequest() error = %v, want nil", err) + } + if !done { + t.Error("Expected done=true for missing OBS meta case") + } +} + +func TestProcessPullRequest_ScmSyncMismatch(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha"}, + Base: &models.PRBranchInfo{Repo: &models.Repository{CloneURL: "http://gitea/org/repo.git"}}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil) + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil) + + mockGit.EXPECT().GetPath().Return("/tmp/git") + mockGit.EXPECT().GitExec("", "clone", "--depth", "1", gomock.Any(), "headsha").Return(nil) + mockGit.EXPECT().GitExec("headsha", "fetch", "--depth", "1", "origin", "headsha", gomock.Any()).Return(nil) + mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(`{"ObsProject": "Base"}`), nil) + + mockObs.EXPECT().GetProjectMeta("Base").Return(&common.ProjectMeta{ + Name: "Base", + ScmSync: "http://OTHER/repo.git", + }, nil) + mockGitea.EXPECT().AddReviewComment(gomock.Any(), common.ReviewStateRequestChanges, gomock.Any()).Return(nil, nil) + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } +} + +func TestProcessPullRequest_BuildFailed(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha", Repo: &models.Repository{CloneURL: "http://gitea/org/repo.git"}}, + Base: &models.PRBranchInfo{Name: "master", Repo: &models.Repository{CloneURL: "http://gitea/org/repo.git", Owner: &models.User{UserName: "org"}, Name: "repo"}}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil) + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil).AnyTimes() + mockGitea.EXPECT().GetIssueComments("org", "repo", int64(123)).Return(nil, nil).AnyTimes() + + mockGit.EXPECT().GetPath().Return("/tmp/git").AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(`{"ObsProject": "Base", "StagingProject": "Base:Staging"}`), nil) + + mockObs.EXPECT().GetProjectMeta("Base").Return(&common.ProjectMeta{ + Name: "Base", + ScmSync: "http://gitea/org/repo.git", + Repositories: []common.RepositoryMeta{{Name: "repo"}}, + }, nil).AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging").Return(&common.ProjectMeta{Name: "Base:Staging"}, nil).AnyTimes() + + mockGit.EXPECT().GitSubmoduleList(gomock.Any(), "headsha").Return(map[string]string{"pkg": "sha1"}, nil).AnyTimes() + mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockGit.EXPECT().GitDirectoryList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + mockObs.EXPECT().GetHomeProject().Return("home:bot").AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging:123").Return(nil, nil).AnyTimes() + mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(nil).AnyTimes() + mockGitea.EXPECT().SetCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockGitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(pr, nil).AnyTimes() + + // Build failure + mockObs.EXPECT().BuildStatus("Base:Staging:123").Return(&common.BuildResultList{ + Result: []*common.BuildResult{{Code: "broken", Status: []*common.PackageBuildStatus{{Package: "pkg", Code: "failed"}}}}, + }, nil) + + mockGitea.EXPECT().AddReviewComment(gomock.Any(), common.ReviewStateRequestChanges, gomock.Any()).Return(nil, nil) + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } +} + +func TestProcessPullRequest_StagingWithQA(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha", Repo: &models.Repository{CloneURL: "http://headrepo.git"}}, + Base: &models.PRBranchInfo{Name: "master", Repo: &models.Repository{CloneURL: "http://headrepo.git", Owner: &models.User{UserName: "org"}, Name: "repo"}}, + Labels: []*models.Label{ + {Name: "qa-label1"}, + {Name: "qa-label2"}, + {Name: "qa-label3"}, + }, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil).AnyTimes() + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil).AnyTimes() + mockGitea.EXPECT().GetIssueComments("org", "repo", int64(123)).Return(nil, nil).AnyTimes() + + mockGit.EXPECT().GetPath().Return("/tmp/git").AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(`{ + "ObsProject": "Base", + "StagingProject": "Base:Staging", + "QA": [ + {"Name": "QA1", "Origin": "Origin1", "Label": "qa-label1"}, + {"Name": "QA2", "Origin": "Origin2", "Label": "qa-label2"}, + {"Name": "QA3", "Origin": "Origin3", "Label": "qa-label3"} + ] + }`), nil) + + mockObs.EXPECT().GetProjectMeta("Base").Return(&common.ProjectMeta{ + Name: "Base", + ScmSync: "http://headrepo.git", + Repositories: []common.RepositoryMeta{{Name: "repo"}}, + }, nil).AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging").Return(&common.ProjectMeta{Name: "Base:Staging"}, nil).AnyTimes() + + mockGit.EXPECT().GitSubmoduleList("headsha", "headsha").Return(map[string]string{"pkg": "sha1"}, nil).AnyTimes() + mockGit.EXPECT().GitSubmoduleList("headsha", gomock.Any()).Return(nil, nil).AnyTimes() + mockGit.EXPECT().GitDirectoryList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + mockObs.EXPECT().GetHomeProject().Return("home:bot").AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging:123").Return(nil, nil).AnyTimes() + + // Mocking QA project existence check (404) and creation + mockObs.EXPECT().GetProjectMeta("Base:Staging:123:QA1").Return(nil, nil) + mockObs.EXPECT().GetProjectMeta("Base:Staging:123:QA2").Return(nil, nil) + mockObs.EXPECT().GetProjectMeta("Base:Staging:123:QA3").Return(nil, nil) + mockObs.EXPECT().GetProjectMeta("Origin1").Return(&common.ProjectMeta{Name: "Origin1"}, nil) + mockObs.EXPECT().GetProjectMeta("Origin2").Return(&common.ProjectMeta{Name: "Origin2"}, nil) + mockObs.EXPECT().GetProjectMeta("Origin3").Return(&common.ProjectMeta{Name: "Origin3"}, nil) + + // mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(nil).Times(4) // 1 for staging, 3 for QA + mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(nil).AnyTimes() + + mockGitea.EXPECT().SetCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockGitea.EXPECT().GetTimeline("org", "repo", int64(123)).Return(nil, nil).AnyTimes() + + // Initialize global variables used in the function + oldObsWebHost := ObsWebHost + ObsWebHost = "https://build.opensuse.org" + defer func() { ObsWebHost = oldObsWebHost }() + + // Capture the setup message to verify its content + var capturedMsg string + mockGitea.EXPECT().AddComment(pr, gomock.Any()).DoAndReturn(func(pr *models.PullRequest, msg string) error { + capturedMsg = msg + return nil + }).AnyTimes() + + // Return successful staging and QA project status + mockObs.EXPECT().BuildStatus(gomock.Any()).Return(&common.BuildResultList{ + Result: []*common.BuildResult{{Code: "published", Status: []*common.PackageBuildStatus{{Package: "pkg", Code: "succeeded"}}}}, + }, nil).AnyTimes() + + mockGitea.EXPECT().AddReviewComment(gomock.Any(), common.ReviewStateApproved, gomock.Any()).Return(nil, nil).AnyTimes() + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } + + expectedMsg := "Build is started in https://build.opensuse.org/project/show/Base:Staging:123 .\n\n" + + "Additional QA builds:\n" + + "https://build.opensuse.org/project/show/Base:Staging:123:QA1\n" + + "https://build.opensuse.org/project/show/Base:Staging:123:QA2\n" + + "https://build.opensuse.org/project/show/Base:Staging:123:QA3" + + if capturedMsg != expectedMsg { + t.Errorf("Setup message mismatch.\nGot: %q\nWant: %q", capturedMsg, expectedMsg) + } +} + +func TestProcessPullRequest_Idempotency(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha", Repo: &models.Repository{CloneURL: "http://headrepo.git"}}, + Base: &models.PRBranchInfo{Name: "master", Repo: &models.Repository{CloneURL: "http://headrepo.git", Owner: &models.User{UserName: "org"}, Name: "repo"}}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil).AnyTimes() + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil).AnyTimes() + mockGitea.EXPECT().GetIssueComments("org", "repo", int64(123)).Return(nil, nil).AnyTimes() + + mockGit.EXPECT().GetPath().Return("/tmp/git").AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(`{"ObsProject": "Base", "StagingProject": "Base:Staging"}`), nil) + + mockObs.EXPECT().GetProjectMeta("Base").Return(&common.ProjectMeta{ + Name: "Base", + ScmSync: "http://headrepo.git", + Repositories: []common.RepositoryMeta{{Name: "repo"}}, + }, nil).AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging").Return(&common.ProjectMeta{Name: "Base:Staging"}, nil).AnyTimes() + + mockGit.EXPECT().GitSubmoduleList("headsha", "headsha").Return(map[string]string{"pkg": "sha1"}, nil).AnyTimes() + mockGit.EXPECT().GitSubmoduleList("headsha", gomock.Any()).Return(map[string]string{"pkg": "sha1"}, nil).AnyTimes() + mockGit.EXPECT().GitDirectoryList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + mockObs.EXPECT().GetHomeProject().Return("home:bot").AnyTimes() + // Simulate existing projects + mockObs.EXPECT().GetProjectMeta("Base:Staging:123").Return(&common.ProjectMeta{Name: "Base:Staging:123"}, nil).AnyTimes() + // StartOrUpdateBuild will detect no change and return RequestModificationNoChange (1) + + // Idempotency check: Even if CommentPROnce was called, it would skip if the comment exists. + // But in the current logic, change == NoChange already skips it at the caller level. + oldComment := "Build is started in https://build.opensuse.org/project/show/Base:Staging:123 .\n" + mockGitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{{ + User: &models.User{UserName: "bot"}, + Type: common.TimelineCommentType_Comment, + Body: oldComment, + }}, nil).AnyTimes() + + mockGitea.EXPECT().SetCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockObs.EXPECT().BuildStatus(gomock.Any()).Return(&common.BuildResultList{ + Result: []*common.BuildResult{{Code: "published", Status: []*common.PackageBuildStatus{{Package: "pkg", Code: "succeeded"}}}}, + }, nil).AnyTimes() + mockGitea.EXPECT().AddReviewComment(gomock.Any(), common.ReviewStateApproved, gomock.Any()).Return(nil, nil).AnyTimes() + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } +} + +func TestProcessPullRequest_QAUpdate(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + oldBotUser := BotUser + BotUser = "bot" + defer func() { BotUser = oldBotUser }() + + oldObsWebHost := ObsWebHost + ObsWebHost = "https://build.opensuse.org" + defer func() { ObsWebHost = oldObsWebHost }() + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha", Repo: &models.Repository{CloneURL: "http://headrepo.git"}}, + Base: &models.PRBranchInfo{Name: "master", Repo: &models.Repository{CloneURL: "http://headrepo.git", Owner: &models.User{UserName: "org"}, Name: "repo"}}, + Labels: []*models.Label{ + {Name: "qa-label1"}, // Existing label + {Name: "qa-label2"}, // NEW LABEL + }, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil).AnyTimes() + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil).AnyTimes() + mockGitea.EXPECT().GetIssueComments("org", "repo", int64(123)).Return(nil, nil).AnyTimes() + + mockGit.EXPECT().GetPath().Return("/tmp/git").AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(`{ + "ObsProject": "Base", + "StagingProject": "Base:Staging", + "QA": [ + {"Name": "QA1", "Origin": "Origin1", "Label": "qa-label1"}, + {"Name": "QA2", "Origin": "Origin2", "Label": "qa-label2"} + ] + }`), nil) + + mockObs.EXPECT().GetProjectMeta("Base").Return(&common.ProjectMeta{ + Name: "Base", + ScmSync: "http://headrepo.git", + Repositories: []common.RepositoryMeta{{Name: "repo"}}, + }, nil).AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging").Return(&common.ProjectMeta{Name: "Base:Staging"}, nil).AnyTimes() + + // Code changes detected to ensure StartOrUpdateBuild proceeds (returns RequestModificationSourceChanged = 3) + mockGit.EXPECT().GitSubmoduleList("headsha", "headsha").Return(map[string]string{"pkg": "headsha"}, nil).AnyTimes() + mockGit.EXPECT().GitSubmoduleList("headsha", gomock.Any()).Return(map[string]string{"pkg": "basesha"}, nil).AnyTimes() + mockGit.EXPECT().GitDirectoryList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + mockObs.EXPECT().GetHomeProject().Return("home:bot").AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging:123").Return(&common.ProjectMeta{Name: "Base:Staging:123"}, nil).AnyTimes() + + // QA1 already exists + mockObs.EXPECT().GetProjectMeta("Base:Staging:123:QA1").Return(&common.ProjectMeta{Name: "Base:Staging:123:QA1"}, nil) + // QA2 is NEW + mockObs.EXPECT().GetProjectMeta("Base:Staging:123:QA2").Return(nil, nil) + mockObs.EXPECT().GetProjectMeta("Origin2").Return(&common.ProjectMeta{Name: "Origin2"}, nil) + mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(nil).AnyTimes() + + mockGitea.EXPECT().SetCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockObs.EXPECT().BuildStatus(gomock.Any()).Return(&common.BuildResultList{ + Result: []*common.BuildResult{{Code: "published", Status: []*common.PackageBuildStatus{{Package: "pkg", Code: "succeeded"}}}}, + }, nil).AnyTimes() + mockGitea.EXPECT().AddReviewComment(gomock.Any(), common.ReviewStateApproved, gomock.Any()).Return(nil, nil).AnyTimes() + + // Explicitly specify the old and new comments + oldComment := "Build is started in https://build.opensuse.org/project/show/Base:Staging:123 .\n" + newComment := "Build is started in https://build.opensuse.org/project/show/Base:Staging:123 .\n\n" + + "Additional QA builds:\n" + + "https://build.opensuse.org/project/show/Base:Staging:123:QA2" + + // Timeline contains the old comment + mockGitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return([]*models.TimelineComment{{ + User: &models.User{UserName: "bot"}, + Type: common.TimelineCommentType_Comment, + Body: oldComment, + }}, nil).AnyTimes() + + // We expect CommentPROnce to be called with the NEW comment because it's an update + mockGitea.EXPECT().AddComment(pr, newComment).Return(nil) + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } +} + +func TestProcessPullRequest_MissingPackages(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha", Repo: &models.Repository{CloneURL: "http://gitea/org/repo.git"}}, + Base: &models.PRBranchInfo{Name: "master", Repo: &models.Repository{CloneURL: "http://gitea/org/repo.git", Owner: &models.User{UserName: "org"}, Name: "repo"}}, + Body: "PR: org/missing_pkg#456", + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil).AnyTimes() + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil).AnyTimes() + mockGitea.EXPECT().GetIssueComments("org", "repo", int64(123)).Return(nil, nil).AnyTimes() + + mockGit.EXPECT().GetPath().Return("/tmp/git").AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(`{"ObsProject": "Base", "StagingProject": "Base:Staging"}`), nil) + + mockObs.EXPECT().GetProjectMeta("Base").Return(&common.ProjectMeta{ + Name: "Base", + ScmSync: "http://gitea/org/repo.git", + Repositories: []common.RepositoryMeta{{Name: "repo"}}, + }, nil).AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging").Return(&common.ProjectMeta{Name: "Base:Staging"}, nil).AnyTimes() + + mockGit.EXPECT().GitSubmoduleList("headsha", "headsha").Return(map[string]string{"pkg": "sha1"}, nil).AnyTimes() + mockGit.EXPECT().GitSubmoduleList("headsha", gomock.Any()).Return(nil, nil).AnyTimes() + mockGit.EXPECT().GitDirectoryList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + mockObs.EXPECT().GetHomeProject().Return("home:bot").AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging:123").Return(nil, nil).AnyTimes() + mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(nil).AnyTimes() + mockGitea.EXPECT().SetCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + // Return successful staging project status, but it doesn't contain 'missing_pkg' + mockObs.EXPECT().BuildStatus("Base:Staging:123").Return(&common.BuildResultList{ + Result: []*common.BuildResult{{Code: "published", Status: []*common.PackageBuildStatus{{Package: "other_pkg", Code: "succeeded"}}}}, + }, nil).AnyTimes() + + mockGitea.EXPECT().AddReviewComment(gomock.Any(), common.ReviewStateApproved, "Build successful").Return(nil, nil) + + // We expect CommentPROnce to be called for the missing package notification + // CommentPROnce will call GetPullRequest and GetTimeline, which are already mocked AnyTimes above + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } +} + +func TestProcessPullRequest_DryRun(t *testing.T) { + oldDryRun := IsDryRun + IsDryRun = true + defer func() { IsDryRun = oldDryRun }() + + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha", Repo: &models.Repository{CloneURL: "http://gitea/org/repo.git"}}, + Base: &models.PRBranchInfo{Repo: &models.Repository{CloneURL: "http://gitea/org/repo.git", Owner: &models.User{UserName: "org"}, Name: "repo"}}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil) + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil).AnyTimes() + mockGitea.EXPECT().GetIssueComments("org", "repo", int64(123)).Return(nil, nil).AnyTimes() + + mockGit.EXPECT().GetPath().Return("/tmp/git").AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + + mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(`{"ObsProject": "Base", "StagingProject": "Base:Staging"}`), nil) + + mockObs.EXPECT().GetProjectMeta("Base").Return(&common.ProjectMeta{ + Name: "Base", + ScmSync: "http://gitea/org/repo.git", + Repositories: []common.RepositoryMeta{{Name: "repo"}}, + }, nil).AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging").Return(&common.ProjectMeta{Name: "Base:Staging"}, nil).AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging:123").Return(nil, nil).AnyTimes() + + mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockGit.EXPECT().GitDirectoryList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + mockObs.EXPECT().GetHomeProject().Return("home:bot").AnyTimes() + mockObs.EXPECT().BuildStatus(gomock.Any()).Return(&common.BuildResultList{}, nil).AnyTimes() + + // In dry run, SetProjectMeta, AddReviewComment, SetCommitStatus, AddComment should NOT be called if logic is strictly DRY. + // But some might be called if code doesn't check IsDryRun everywhere. + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } +} + +func TestProcessPullRequest_RebuildAll(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGitHandler := mock_common.NewMockGitHandlerGenerator(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + oldGitWorkTreeAllocate := GitWorkTreeAllocate + defer func() { GitWorkTreeAllocate = oldGitWorkTreeAllocate }() + GitWorkTreeAllocate = func(dir, author, email string) (common.GitHandlerGenerator, error) { + return mockGitHandler, nil + } + + mockGitHandler.EXPECT().CreateGitHandler("org").Return(mockGit, nil) + pr := &models.PullRequest{ + Index: 123, + State: "open", + RequestedReviewers: []*models.User{{UserName: Username}}, + Head: &models.PRBranchInfo{Sha: "headsha", Repo: &models.Repository{CloneURL: "http://gitea/org/repo.git"}}, + Base: &models.PRBranchInfo{Repo: &models.Repository{CloneURL: "http://gitea/org/repo.git", Owner: &models.User{UserName: "org"}, Name: "repo"}}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(pr, nil) + + // Review with "rebuild all" comment + mockGitea.EXPECT().GetPullRequestReviews("org", "repo", int64(123)).Return([]*models.PullReview{ + {User: &models.User{UserName: "maintainer"}, Body: "@autogits_obs_staging_bot rebuild all", Submitted: strfmt.DateTime(time.Now())}, + {User: &models.User{UserName: Username}, State: common.ReviewStatePending}, + }, nil).AnyTimes() + mockGitea.EXPECT().GetIssueComments("org", "repo", int64(123)).Return(nil, nil).AnyTimes() + + mockGit.EXPECT().GetPath().Return("/tmp/git").AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGit.EXPECT().GitExec(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGit.EXPECT().GitCatFile(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte(`{"ObsProject": "Base", "StagingProject": "Base:Staging"}`), nil) + + mockObs.EXPECT().GetProjectMeta("Base").Return(&common.ProjectMeta{ + Name: "Base", + ScmSync: "http://gitea/org/repo.git", + Repositories: []common.RepositoryMeta{{Name: "repo"}}, + }, nil).AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging").Return(&common.ProjectMeta{Name: "Base:Staging"}, nil).AnyTimes() + mockObs.EXPECT().GetProjectMeta("Base:Staging:123").Return(nil, nil).AnyTimes() + + mockGit.EXPECT().GitSubmoduleList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockGit.EXPECT().GitDirectoryList(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + + mockObs.EXPECT().GetHomeProject().Return("home:bot").AnyTimes() + mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(nil).AnyTimes() + mockGitea.EXPECT().SetCommitStatus(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockGitea.EXPECT().AddComment(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + mockGitea.EXPECT().GetTimeline(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes() + mockGitea.EXPECT().GetPullRequest(gomock.Any(), gomock.Any(), gomock.Any()).Return(pr, nil).AnyTimes() + mockObs.EXPECT().BuildStatus(gomock.Any()).Return(&common.BuildResultList{ + Result: []*common.BuildResult{{Code: "published", Status: []*common.PackageBuildStatus{{Package: "pkg", Code: "succeeded"}}}}, + }, nil).AnyTimes() + mockGitea.EXPECT().AddReviewComment(gomock.Any(), common.ReviewStateApproved, gomock.Any()).Return(nil, nil) + + done, err := ProcessPullRequest(mockObs, mockGitea, "org", "repo", 123) + if err != nil || !done { + t.Errorf("ProcessPullRequest() = %v, %v; want true, nil", done, err) + } +} + +func TestProcessPullNotification(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + + thread := &models.NotificationThread{ + ID: 1, + Subject: &models.NotificationSubject{ + URL: "https://gitea/api/v1/repos/org/repo/issues/123", + }, + } + + // Expectations for internal ProcessPullRequest call (mocking early exit for simplicity) + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(123)).Return(&models.PullRequest{State: "closed"}, nil) + mockGitea.EXPECT().SetNotificationRead(int64(1)).Return(nil) + + ProcessPullNotification(mockObs, mockGitea, thread) +} + +func TestProcessQaProjects(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + mockGit := mock_common.NewMockGit(ctrl) + + config := &common.StagingConfig{ + QA: []common.QAConfig{ + {Name: "QA1", Origin: "Origin1", Label: "qa-label"}, + }, + } + pr := &models.PullRequest{ + Labels: []*models.Label{{Name: "qa-label"}}, + } + + mockObs.EXPECT().GetProjectMeta("Staging:QA1").Return(nil, nil) + // CreateQASubProject will be called + mockObs.EXPECT().GetProjectMeta("Origin1").Return(&common.ProjectMeta{Name: "Origin1"}, nil) + mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(nil) + + projects, msg := ProcessQaProjects(mockObs, config, mockGit, mockGitea, pr, "Staging") + if len(projects) != 1 || projects[0] != "Staging:QA1" { + t.Errorf("ProcessQaProjects() = %v, want [Staging:QA1]", projects) + } + if !strings.Contains(msg, "Additional QA builds:") { + t.Errorf("ProcessQaProjects() msg = %q, want it to contain 'Additional QA builds:'", msg) + } +} + +func TestPollWorkNotifications(t *testing.T) { + ctrl := gomock.NewController(t) + mockObs := mock_common.NewMockObsClientInterface(ctrl) + mockGitea := mock_common.NewMockGitea(ctrl) + + // Mocking GetNotifications + notifications := []*models.NotificationThread{ + { + ID: 1, + Subject: &models.NotificationSubject{ + Type: "Pull", + URL: "https://gitea/api/v1/repos/org/repo/issues/1", + }, + }, + { + ID: 2, + Subject: &models.NotificationSubject{ + Type: "Issue", + URL: "https://gitea/api/v1/repos/org/repo/issues/2", + }, + }, + } + mockGitea.EXPECT().GetNotifications(common.GiteaNotificationType_Pull, nil).Return(notifications, nil) + + // Expectations for Processing Notification 1 (Pull) + // ProcessPullNotification calls ProcessPullRequest + pr1 := &models.PullRequest{ + State: "closed", + Index: 1, + Base: &models.PRBranchInfo{ + Repo: &models.Repository{ + Name: "repo", + Owner: &models.User{UserName: "org"}, + }, + }, + Head: &models.PRBranchInfo{Sha: "headsha1"}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(1)).Return(pr1, nil) + // ProcessPullNotification will call SetNotificationRead if done and no error + mockGitea.EXPECT().SetNotificationRead(int64(1)).Return(nil) + + // Expectations for Processing Notification 2 (Issue - marked as read) + mockGitea.EXPECT().SetNotificationRead(int64(2)).Return(nil) + + // Mocking Cleanup logic + // page 1 + doneNotifications := []*models.NotificationThread{ + { + ID: 3, + Subject: &models.NotificationSubject{ + URL: "https://gitea/api/v1/repos/org/repo/issues/3", + HTMLURL: "https://gitea/org/repo/issues/3", + }, + Unread: false, + }, + } + mockGitea.EXPECT().GetDoneNotifications(common.GiteaNotificationType_Pull, int64(1)).Return(doneNotifications, nil) + + // CleanupPullNotification calls for ID 3 + pr3 := &models.PullRequest{ + State: "closed", + Index: 3, + Base: &models.PRBranchInfo{ + Repo: &models.Repository{ + Name: "repo", + Owner: &models.User{UserName: "org"}, + }, + }, + Head: &models.PRBranchInfo{Sha: "headsha3"}, + } + mockGitea.EXPECT().GetPullRequest("org", "repo", int64(3)).Return(pr3, nil) + mockGitea.EXPECT().GetRepositoryFileContent("org", "repo", "headsha3", common.StagingConfigFile).Return([]byte(`{"ObsProject": "Base"}`), "", nil) + mockObs.EXPECT().GetHomeProject().Return("home:bot").AnyTimes() + mockObs.EXPECT().GetProjectMeta(gomock.Any()).Return(&common.ProjectMeta{Name: "Base"}, nil).AnyTimes() + mockObs.EXPECT().DeleteProject(gomock.Any()).Return(nil).AnyTimes() + + // page 2 (terminates loop) + mockGitea.EXPECT().GetDoneNotifications(common.GiteaNotificationType_Pull, int64(2)).Return(nil, nil) + + PollWorkNotifications(mockObs, mockGitea) +} + +func TestCommentPROnce(t *testing.T) { + ctrl := gomock.NewController(t) + mockGitea := mock_common.NewMockGitea(ctrl) + + org, repo, msg := "org", "repo", "test message" + var prNum int64 = 456 + oldBotUser := BotUser + BotUser = "bot" + defer func() { BotUser = oldBotUser }() + + t.Run("Dry run", func(t *testing.T) { + oldDryRun := IsDryRun + IsDryRun = true + defer func() { IsDryRun = oldDryRun }() + + // No expectations on mockGitea + CommentPROnce(mockGitea, org, repo, prNum, msg) + }) + + t.Run("Success path (no existing comment)", func(t *testing.T) { + pr := &models.PullRequest{Index: prNum} + mockGitea.EXPECT().GetPullRequest(org, repo, prNum).Return(pr, nil) + mockGitea.EXPECT().GetTimeline(org, repo, prNum).Return([]*models.TimelineComment{{ + Type: "other", + }}, nil) + mockGitea.EXPECT().AddComment(pr, msg).Return(nil) + + CommentPROnce(mockGitea, org, repo, prNum, msg) + }) + + t.Run("Skip if comment exists", func(t *testing.T) { + pr := &models.PullRequest{Index: prNum} + mockGitea.EXPECT().GetPullRequest(org, repo, prNum).Return(pr, nil) + mockGitea.EXPECT().GetTimeline(org, repo, prNum).Return([]*models.TimelineComment{{ + User: &models.User{UserName: "bot"}, + Type: common.TimelineCommentType_Comment, + Body: msg, + }}, nil) + // No AddComment expected + + CommentPROnce(mockGitea, org, repo, prNum, msg) + }) + + t.Run("GetPullRequest error", func(t *testing.T) { + mockGitea.EXPECT().GetPullRequest(org, repo, prNum).Return(nil, errors.New("API error")) + + CommentPROnce(mockGitea, org, repo, prNum, msg) + }) + + t.Run("GetTimeline error", func(t *testing.T) { + pr := &models.PullRequest{Index: prNum} + mockGitea.EXPECT().GetPullRequest(org, repo, prNum).Return(pr, nil) + mockGitea.EXPECT().GetTimeline(org, repo, prNum).Return(nil, errors.New("Timeline error")) + + CommentPROnce(mockGitea, org, repo, prNum, msg) + }) + + t.Run("AddComment error", func(t *testing.T) { + pr := &models.PullRequest{Index: prNum} + mockGitea.EXPECT().GetPullRequest(org, repo, prNum).Return(pr, nil) + mockGitea.EXPECT().GetTimeline(org, repo, prNum).Return(nil, nil) + mockGitea.EXPECT().AddComment(pr, msg).Return(errors.New("API error")) + + CommentPROnce(mockGitea, org, repo, prNum, msg) + }) +}