From 06772ca662f68ae85b80f6df5b10826d6373f2c424d8c5d9dcb052a1163fde84 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 13:43:23 +0100 Subject: [PATCH 01/17] common: Add ObsClientInterface This allows for dependency injection for future unit tests. --- common/obs_utils.go | 17 +++++++++++++++++ obs-staging-bot/main.go | 10 +++++----- 2 files changed, 22 insertions(+), 5 deletions(-) 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..76edb2b 100644 --- a/obs-staging-bot/main.go +++ b/obs-staging-bot/main.go @@ -441,7 +441,7 @@ func CreateQASubProject(stagingConfig *common.StagingConfig, git common.Git, git func StartOrUpdateBuild(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) + obsPrProject := GetObsProjectAssociatedWithPr(config, ObsClient.GetHomeProject(), pr) meta, err := ObsClient.GetProjectMeta(obsPrProject) if err != nil { common.LogError("error fetching project meta for", obsPrProject, ":", err) @@ -643,7 +643,7 @@ func CleanupPullNotification(gitea common.Gitea, thread *models.NotificationThre return false } - stagingProject := GetObsProjectAssociatedWithPr(config, ObsClient.HomeProject, pr) + stagingProject := GetObsProjectAssociatedWithPr(config, ObsClient.GetHomeProject(), pr) if prj, err := ObsClient.GetProjectMeta(stagingProject); err != nil { common.LogError("Failed fetching meta for project:", stagingProject, ". Not cleaning up") return false @@ -946,7 +946,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e } common.LogDebug("ObsProject:", stagingConfig.ObsProject) - stagingProject := GetObsProjectAssociatedWithPr(stagingConfig, ObsClient.HomeProject, pr) + stagingProject := GetObsProjectAssociatedWithPr(stagingConfig, ObsClient.GetHomeProject(), pr) change, err := StartOrUpdateBuild(stagingConfig, git, gitea, pr) status := &models.CommitStatus{ Context: BotName, @@ -1144,7 +1144,7 @@ var ObsApiHost string var ObsWebHost string var IsDryRun bool var ProcessPROnly string -var ObsClient *common.ObsClient +var ObsClient common.ObsClientInterface func ObsWebHostFromApiHost(apihost string) string { u, err := url.Parse(apihost) @@ -1209,7 +1209,7 @@ func main() { } if len(*buildRoot) > 0 { - ObsClient.HomeProject = *buildRoot + ObsClient.SetHomeProject(*buildRoot) } if len(*ProcessPROnly) > 0 { -- 2.51.1 From 892064479228ff4f483629ce432ea819ca4a5a12b918c92163699db5f3b6cce5 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 14:09:41 +0100 Subject: [PATCH 02/17] staging: Use interfaces allowing dependency injection This includes also a few formatting changes --- obs-staging-bot/main.go | 108 ++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 53 deletions(-) diff --git a/obs-staging-bot/main.go b/obs-staging-bot/main.go index 76edb2b..0a71b2b 100644 --- a/obs-staging-bot/main.go +++ b/obs-staging-bot/main.go @@ -50,6 +50,8 @@ const ( var runId uint +var GitWorkTreeAllocate = common.AllocateGitWorkTree + func FetchPrGit(git common.Git, pr *models.PullRequest) error { // clone PR head via base (target) repo cloneURL := pr.Base.Repo.CloneURL @@ -144,9 +146,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, "", " ") @@ -216,7 +218,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 +262,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 +332,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 +345,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 +356,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 +408,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 +420,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 +441,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.GetHomeProject(), 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 +469,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 +481,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 +552,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 +568,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 +578,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 +645,8 @@ func CleanupPullNotification(gitea common.Gitea, thread *models.NotificationThre return false } - stagingProject := GetObsProjectAssociatedWithPr(config, ObsClient.GetHomeProject(), 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 +660,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) } } @@ -704,7 +706,7 @@ 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 { usedQAprojects := make([]string, 0) prLabelNames := make(map[string]int) for _, label := range pr.Labels { @@ -717,7 +719,7 @@ func ProcessQaProjects(stagingConfig *common.StagingConfig, git common.Git, gite 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,15 +728,15 @@ 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" + msg = msg + "QA Project added: " + ObsWebHost + "/project/show/" + + QAproject + "\n" } } if len(msg) > 1 { @@ -743,7 +745,7 @@ func ProcessQaProjects(stagingConfig *common.StagingConfig, git common.Git, gite return usedQAprojects } -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 +754,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) @@ -817,7 +819,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 +948,8 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e } common.LogDebug("ObsProject:", stagingConfig.ObsProject) - stagingProject := GetObsProjectAssociatedWithPr(stagingConfig, ObsClient.GetHomeProject(), 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", @@ -982,7 +984,7 @@ func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, e 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 +992,7 @@ 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 := ProcessQaProjects(obs, stagingConfig, git, gitea, pr, stagingProject) done := false overallBuildStatus := ProcessBuildStatus(stagingResult) @@ -998,7 +1000,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) } @@ -1090,8 +1092,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 +1108,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 +1131,7 @@ func PollWorkNotifications(giteaUrl string) { continue } - cleanupFinished = CleanupPullNotification(gitea, n) && cleanupFinished + cleanupFinished = CleanupPullNotification(obs, gitea, n) && cleanupFinished } } else if err != nil { common.LogError(err) @@ -1212,6 +1213,8 @@ func main() { ObsClient.SetHomeProject(*buildRoot) } + gitea := common.AllocateGiteaTransport(GiteaUrl) + if len(*ProcessPROnly) > 0 { rx := regexp.MustCompile("^([^/#]+)/([^/#]+)#([0-9]+)$") m := rx.FindStringSubmatch(*ProcessPROnly) @@ -1220,15 +1223,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) } -- 2.51.1 From 82d4e2ed5d5b36fae594ed2098f275b83d05fedca13da8ba82d06b06a442a769 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 14:17:53 +0100 Subject: [PATCH 03/17] staging: mock interface setup --- common/mock/obs_utils.go | 257 +++++++++++++++++++++++++++++++++++ obs-staging-bot/main_test.go | 16 +++ 2 files changed, 273 insertions(+) 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/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index 77c77f1..71dc85e 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -5,10 +5,26 @@ import ( "strings" "testing" + "go.uber.org/mock/gomock" "src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common/gitea-generated/models" + mock_common "src.opensuse.org/autogits/common/mock" ) +func 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 -- 2.51.1 From 6fa57fc4d4d1aeb9884669c09c973c23d05cda9d851a9683a2270322a9c99e96 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 14:33:51 +0100 Subject: [PATCH 04/17] staging: Fix logic error We need to report only once all building is finished, and not partial results. Partial results are not yet finalized, so we can only report that build is still in progress. Add unit tests to cover these scenarios --- obs-staging-bot/main.go | 13 ++-- obs-staging-bot/main_test.go | 145 +++++++++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+), 7 deletions(-) diff --git a/obs-staging-bot/main.go b/obs-staging-bot/main.go index 0a71b2b..4a54def 100644 --- a/obs-staging-bot/main.go +++ b/obs-staging-bot/main.go @@ -193,24 +193,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 } } diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index 71dc85e..029ddc5 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -130,3 +130,148 @@ 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) + } + }) + } +} -- 2.51.1 From fc4547f9a99a24fa498803ec4476cd751c5481372cebda867ef645b1eda4c67f Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 14:44:17 +0100 Subject: [PATCH 05/17] tests: sanitize check --- obs-staging-bot/main_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index 029ddc5..6d97423 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -101,6 +101,16 @@ 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", + }, } for _, test := range tests { -- 2.51.1 From d923db3f87dbff8cd4878d776a9a06b67d3055bf32a094b6fb6ec389fa37be98 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 14:47:51 +0100 Subject: [PATCH 06/17] staging: tests for Notification and Review handling --- obs-staging-bot/main_test.go | 154 +++++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index 6d97423..e0d1b13 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -1,10 +1,13 @@ package main import ( + "errors" "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" @@ -285,3 +288,154 @@ func TestGetPackageBuildStatus(t *testing.T) { }) } } + +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) + } + }) + } +} -- 2.51.1 From 57933915863100312e2d7a3917fc6a713f5730e4a8d81950068ace02869bec82 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 15:05:51 +0100 Subject: [PATCH 07/17] staging: add core logic unit tests --- obs-staging-bot/main.go | 4 +- obs-staging-bot/main_test.go | 310 +++++++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+), 1 deletion(-) diff --git a/obs-staging-bot/main.go b/obs-staging-bot/main.go index 4a54def..dbf4371 100644 --- a/obs-staging-bot/main.go +++ b/obs-staging-bot/main.go @@ -50,7 +50,9 @@ const ( var runId uint -var GitWorkTreeAllocate = common.AllocateGitWorkTree +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 diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index e0d1b13..c3649f6 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "os" "strconv" "strings" "testing" @@ -439,3 +440,312 @@ func TestFetchOurLatestActionableReview(t *testing.T) { }) } } + +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", + } + pr := &models.PullRequest{} + + templateMeta := &common.ProjectMeta{ + Name: "TemplateProject", + ScmSync: "http://gitea/org/repo#branch", + } + + 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 !strings.Contains(meta.ScmSync, "#fullsha") { + t.Errorf("Expected ScmSync to have fullsha, got %v", meta.ScmSync) + } + if meta.Name != "StagingProject:QAName" { + t.Errorf("Expected name StagingProject:QAName, got %v", meta.Name) + } + return nil + }) + + err := CreateQASubProject(mockObs, stagingConfig, mockGit, mockGitea, pr, "StagingProject", "TemplateProject", "QAName", nil) + if err != nil { + t.Errorf("CreateQASubProject() 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 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) + mockGitea.EXPECT().AddComment(pr, gomock.Any()).Return(nil) + + projects := ProcessQaProjects(mockObs, config, mockGit, mockGitea, pr, "Staging") + if len(projects) != 1 || projects[0] != "Staging:QA1" { + t.Errorf("ProcessQaProjects() = %v, want [Staging:QA1]", projects) + } +} -- 2.51.1 From 70bba5e239f0e36bbed0460e35de596f3eef00f3f6e8f11cbe2014a22251de8f Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 15:11:30 +0100 Subject: [PATCH 08/17] staging: improve CreateQASubProject unit coverage --- obs-staging-bot/main_test.go | 68 ++++++++++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index c3649f6..97260f2 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -572,31 +572,87 @@ func TestCreateQASubProject(t *testing.T) { mockGitea := mock_common.NewMockGitea(ctrl) stagingConfig := &common.StagingConfig{ - ObsProject: "BaseProject", + 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 !strings.Contains(meta.ScmSync, "#fullsha") { - t.Errorf("Expected ScmSync to have fullsha, got %v", meta.ScmSync) + if meta.Link[0].Project != "ActualStaging" { + t.Errorf("Expected link project ActualStaging, got %v", meta.Link[0].Project) } - if meta.Name != "StagingProject:QAName" { - t.Errorf("Expected name StagingProject:QAName, got %v", meta.Name) + // 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, "StagingProject", "TemplateProject", "QAName", 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) { -- 2.51.1 From bb5daebdfa40ed1e1bb23d32d8e8747240ac2b4e31ab73d690437aae52796d73 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 15:37:41 +0100 Subject: [PATCH 09/17] staging: return correct error Don't clobber our error before returning it --- obs-staging-bot/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/obs-staging-bot/main.go b/obs-staging-bot/main.go index dbf4371..b48bf38 100644 --- a/obs-staging-bot/main.go +++ b/obs-staging-bot/main.go @@ -800,7 +800,7 @@ func ProcessPullRequest(obs common.ObsClientInterface, gitea common.Gitea, org, 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 } -- 2.51.1 From 34a3a4795b3ab03ffe1229bcaeb1b7c8cf2477211a03f946132b91eb86d9daa6 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 15:39:02 +0100 Subject: [PATCH 10/17] staging: increase coverage of PulllRequest processing --- obs-staging-bot/main_test.go | 366 +++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index 97260f2..b128c4e 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -760,6 +760,372 @@ func TestProcessPullRequest(t *testing.T) { } } +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() + + // 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_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() + 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) -- 2.51.1 From 7a2f7a6ee794f783a1a7c1084dae2acc36a26f53179eb978e34638dcdbea0379 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 15:44:45 +0100 Subject: [PATCH 11/17] staging: test default projectgit repo --- obs-staging-bot/main_test.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index b128c4e..66c1ae6 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -115,6 +115,11 @@ func TestPRtoObsProjectMapping(t *testing.T) { 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 { -- 2.51.1 From d3d9d66797b7f18f1a92cab15ab939bdbf205506688280a6b8aa704710ee76b6 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 15:48:38 +0100 Subject: [PATCH 12/17] staging: add tests on commentOnPackagePR --- obs-staging-bot/main_test.go | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index 66c1ae6..94b7222 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -1176,3 +1176,42 @@ func TestProcessQaProjects(t *testing.T) { t.Errorf("ProcessQaProjects() = %v, want [Staging:QA1]", projects) } } + +func TestCommentOnPackagePR(t *testing.T) { + ctrl := gomock.NewController(t) + mockGitea := mock_common.NewMockGitea(ctrl) + + org, repo, msg := "org", "repo", "test message" + var prNum int64 = 456 + + t.Run("Dry run", func(t *testing.T) { + oldDryRun := IsDryRun + IsDryRun = true + defer func() { IsDryRun = oldDryRun }() + + // No expectations on mockGitea + commentOnPackagePR(mockGitea, org, repo, prNum, msg) + }) + + t.Run("Success path", func(t *testing.T) { + pr := &models.PullRequest{Index: prNum} + mockGitea.EXPECT().GetPullRequest(org, repo, prNum).Return(pr, nil) + mockGitea.EXPECT().AddComment(pr, msg).Return(nil) + + commentOnPackagePR(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")) + + commentOnPackagePR(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().AddComment(pr, msg).Return(errors.New("API error")) + + commentOnPackagePR(mockGitea, org, repo, prNum, msg) + }) +} -- 2.51.1 From 24a4a592a71231ccddb2d2fdf04f2c7900b17b92c992a3ce15917d4ff165398c Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 16:01:47 +0100 Subject: [PATCH 13/17] staging: add PollWorkNotifications coverage --- obs-staging-bot/main_test.go | 82 ++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index 94b7222..fba144e 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -1177,6 +1177,88 @@ func TestProcessQaProjects(t *testing.T) { } } +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 TestCommentOnPackagePR(t *testing.T) { ctrl := gomock.NewController(t) mockGitea := mock_common.NewMockGitea(ctrl) -- 2.51.1 From 59965e7b5c8e6966d1589fd6ddb13c53f397b6ee88525ea7083713da9adc31cb Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 17:37:01 +0100 Subject: [PATCH 14/17] staging: comment once on PRs using timeline We need to comment once on PRs and verify using issue timeline that only one comment is present Furthermore, staging and secondary QA links should be present in a single comment as tooling already expects this format. --- obs-staging-bot/main.go | 42 ++++++++++++++++++++++-------- obs-staging-bot/main_test.go | 50 ++++++++++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 19 deletions(-) diff --git a/obs-staging-bot/main.go b/obs-staging-bot/main.go index b48bf38..aaaf56e 100644 --- a/obs-staging-bot/main.go +++ b/obs-staging-bot/main.go @@ -688,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 @@ -700,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) @@ -707,7 +719,7 @@ func commentOnPackagePR(gitea common.Gitea, org string, repo string, prNum int64 } // Create and remove QA projects -func ProcessQaProjects(obs common.ObsClientInterface, 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 { @@ -740,10 +752,8 @@ func ProcessQaProjects(obs common.ObsClientInterface, stagingConfig *common.Stag QAproject + "\n" } } - if len(msg) > 1 { - gitea.AddComment(pr, msg) - } - return usedQAprojects + + return usedQAprojects, msg } func ProcessPullRequest(obs common.ObsClientInterface, gitea common.Gitea, org, repo string, id int64) (bool, error) { @@ -981,9 +991,6 @@ func ProcessPullRequest(obs common.ObsClientInterface, gitea common.Gitea, org, SetStatus(gitea, org, repo, pr.Head.Sha, status) } - if change != RequestModificationNoChange && !IsDryRun { - gitea.AddComment(pr, msg) - } stagingResult, err := obs.BuildStatus(stagingProject) if err != nil { @@ -993,7 +1000,12 @@ func ProcessPullRequest(obs common.ObsClientInterface, gitea common.Gitea, org, _, packagePRs := common.ExtractDescriptionAndPRs(bufio.NewScanner(strings.NewReader(pr.Body))) // always update QA projects because Labels can change - qaProjects := ProcessQaProjects(obs, stagingConfig, git, gitea, pr, stagingProject) + qaProjects, qaProjectMsg := ProcessQaProjects(obs, stagingConfig, git, gitea, pr, stagingProject) + + if change != RequestModificationNoChange && !IsDryRun { + msg += qaProjectMsg + CommentPROnce(gitea, org, repo, id, msg) + } done := false overallBuildStatus := ProcessBuildStatus(stagingResult) @@ -1061,7 +1073,7 @@ func ProcessPullRequest(obs common.ObsClientInterface, gitea common.Gitea, org, default: continue } - commentOnPackagePR(gitea, packagePR.Org, packagePR.Repo, packagePR.Num, msg) + CommentPROnce(gitea, packagePR.Org, packagePR.Repo, packagePR.Num, msg) } if len(missingPkgs) > 0 { @@ -1147,6 +1159,7 @@ var ObsWebHost string var IsDryRun bool var ProcessPROnly string var ObsClient common.ObsClientInterface +var BotUser string func ObsWebHostFromApiHost(apihost string) string { u, err := url.Parse(apihost) @@ -1216,6 +1229,13 @@ func main() { 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) diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index fba144e..d2dd2b9 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -996,6 +996,8 @@ func TestProcessPullRequest_BuildFailed(t *testing.T) { 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{ @@ -1120,6 +1122,8 @@ func TestProcessPullRequest_RebuildAll(t *testing.T) { 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() @@ -1169,12 +1173,14 @@ func TestProcessQaProjects(t *testing.T) { // CreateQASubProject will be called mockObs.EXPECT().GetProjectMeta("Origin1").Return(&common.ProjectMeta{Name: "Origin1"}, nil) mockObs.EXPECT().SetProjectMeta(gomock.Any()).Return(nil) - mockGitea.EXPECT().AddComment(pr, gomock.Any()).Return(nil) - projects := ProcessQaProjects(mockObs, config, mockGit, mockGitea, pr, "Staging") + 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, "QA Project added") { + t.Errorf("ProcessQaProjects() msg = %q, want it to contain 'QA Project added'", msg) + } } func TestPollWorkNotifications(t *testing.T) { @@ -1259,12 +1265,15 @@ func TestPollWorkNotifications(t *testing.T) { PollWorkNotifications(mockObs, mockGitea) } -func TestCommentOnPackagePR(t *testing.T) { +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 @@ -1272,28 +1281,53 @@ func TestCommentOnPackagePR(t *testing.T) { defer func() { IsDryRun = oldDryRun }() // No expectations on mockGitea - commentOnPackagePR(mockGitea, org, repo, prNum, msg) + CommentPROnce(mockGitea, org, repo, prNum, msg) }) - t.Run("Success path", func(t *testing.T) { + 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) - commentOnPackagePR(mockGitea, org, repo, prNum, msg) + 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")) - commentOnPackagePR(mockGitea, org, repo, prNum, msg) + 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")) - commentOnPackagePR(mockGitea, org, repo, prNum, msg) + CommentPROnce(mockGitea, org, repo, prNum, msg) }) } -- 2.51.1 From e1825dc6588a1902d0807f5d602209bdd5a8afc9a9e308ab15311f3ff4104687 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Mon, 23 Feb 2026 19:14:37 +0100 Subject: [PATCH 15/17] staging: CommentPROnce everywhere This replaces last usage of gitea.AddComment() where we do not check if the comment already exists. --- obs-staging-bot/main.go | 5 +-- obs-staging-bot/main_test.go | 68 ++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/obs-staging-bot/main.go b/obs-staging-bot/main.go index aaaf56e..2a4f9ce 100644 --- a/obs-staging-bot/main.go +++ b/obs-staging-bot/main.go @@ -1083,10 +1083,7 @@ func ProcessPullRequest(obs common.ObsClientInterface, gitea common.Gitea, org, msg = msg + " - " + pkg + "\n" } common.LogInfo(msg) - err := gitea.AddComment(pr, msg) - if err != nil { - common.LogError(err) - } + CommentPROnce(gitea, org, repo, id, msg) } } diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index d2dd2b9..5e15ab9 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -1012,6 +1012,74 @@ func TestProcessPullRequest_BuildFailed(t *testing.T) { } } +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 -- 2.51.1 From 913b8c8a4b48c94a8c091ca3bf544fb6f797973510a9749ee0528ecad51d5caa Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Tue, 24 Feb 2026 12:23:35 +0100 Subject: [PATCH 16/17] staging: Match previous message format Match changes in older message format. That is, Build is started in https://host/project/show/SUSE:SLFO:2.2:PullRequest:2162 . Additional QA builds: https://host/project/show/SUSE:SLFO:2.2:PullRequest:2162:SLES https://host/project/show/SUSE:SLFO:2.2:PullRequest:2162:SL-Micro Add unit test to verify this exact format. --- obs-staging-bot/main.go | 12 +++- obs-staging-bot/main_test.go | 113 ++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 5 deletions(-) diff --git a/obs-staging-bot/main.go b/obs-staging-bot/main.go index 2a4f9ce..37435e5 100644 --- a/obs-staging-bot/main.go +++ b/obs-staging-bot/main.go @@ -726,6 +726,7 @@ func ProcessQaProjects(obs common.ObsClientInterface, stagingConfig *common.Stag prLabelNames[label.Name] = 1 } msg := "" + var qa_projects []string for _, setup := range stagingConfig.QA { QAproject := stagingProject + ":" + setup.Name if len(setup.Label) > 0 { @@ -748,11 +749,14 @@ func ProcessQaProjects(obs common.ObsClientInterface, stagingConfig *common.Stag 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(qa_projects) > 0 { + msg = "Additional QA builds:\n" + strings.Join(qa_projects, "\n") + } + return usedQAprojects, msg } @@ -1003,7 +1007,9 @@ func ProcessPullRequest(obs common.ObsClientInterface, gitea common.Gitea, org, qaProjects, qaProjectMsg := ProcessQaProjects(obs, stagingConfig, git, gitea, pr, stagingProject) if change != RequestModificationNoChange && !IsDryRun { - msg += qaProjectMsg + if len(qaProjectMsg) > 0 { + msg += "\n" + qaProjectMsg + } CommentPROnce(gitea, org, repo, id, msg) } diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index 5e15ab9..a5c2a9a 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -1012,6 +1012,115 @@ func TestProcessPullRequest_BuildFailed(t *testing.T) { } } +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_MissingPackages(t *testing.T) { ctrl := gomock.NewController(t) mockObs := mock_common.NewMockObsClientInterface(ctrl) @@ -1246,8 +1355,8 @@ func TestProcessQaProjects(t *testing.T) { if len(projects) != 1 || projects[0] != "Staging:QA1" { t.Errorf("ProcessQaProjects() = %v, want [Staging:QA1]", projects) } - if !strings.Contains(msg, "QA Project added") { - t.Errorf("ProcessQaProjects() msg = %q, want it to contain 'QA Project added'", msg) + if !strings.Contains(msg, "Additional QA builds:") { + t.Errorf("ProcessQaProjects() msg = %q, want it to contain 'Additional QA builds:'", msg) } } -- 2.51.1 From 91d22f7eeaf880e0787b8b88b30f183c8b9debd735bc3beb58f476e7b078e8a0 Mon Sep 17 00:00:00 2001 From: Adam Majer Date: Tue, 24 Feb 2026 18:22:08 +0100 Subject: [PATCH 17/17] staging: add tests for idempotency and label changes We do not want duplicate comments. And if we do have label changes, new comments should be added. --- obs-staging-bot/main_test.go | 172 +++++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/obs-staging-bot/main_test.go b/obs-staging-bot/main_test.go index a5c2a9a..40721f0 100644 --- a/obs-staging-bot/main_test.go +++ b/obs-staging-bot/main_test.go @@ -1121,6 +1121,178 @@ func TestProcessPullRequest_StagingWithQA(t *testing.T) { } } +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) -- 2.51.1