package main /* * This file is part of Autogits. * * Copyright © 2024 SUSE LLC * * Autogits is free software: you can redistribute it and/or modify it under * the terms of the GNU General Public License as published by the Free Software * Foundation, either version 2 of the License, or (at your option) any later * version. * * Autogits is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with * Foobar. If not, see . */ import ( "encoding/xml" "errors" "flag" "fmt" "net/url" "os" "path" "regexp" "runtime/debug" "slices" "strconv" "strings" "time" "github.com/opentracing/opentracing-go/log" "src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common/gitea-generated/client/repository" "src.opensuse.org/autogits/common/gitea-generated/models" ) const ( GitAuthor = "GiteaBot - Obs Staging" BotName = "ObsStaging" ObsBuildBot = "/obsbuild" Username = "autogits_obs_staging_bot" ) var GiteaToken string var runId uint func FetchPrGit(git common.Git, pr *models.PullRequest) error { // clone PR head and base and return path cloneURL := pr.Head.Repo.CloneURL if GiteaUseSshClone { cloneURL = pr.Head.Repo.SSHURL } if _, err := os.Stat(path.Join(git.GetPath(), pr.Head.Sha)); os.IsNotExist(err) { common.PanicOnError(git.GitExec("", "clone", "--depth", "1", cloneURL, pr.Head.Sha)) common.PanicOnError(git.GitExec(pr.Head.Sha, "fetch", "--depth", "1", "origin", pr.Head.Sha, pr.MergeBase)) } else if err != nil { return err } return nil } func GetObsProjectAssociatedWithPr(config *common.StagingConfig, baseProject string, pr *models.PullRequest) string { if config.StagingProject != "" { return fmt.Sprintf("%s:%d", config.StagingProject, pr.Index) } if pr.Base.Repo.Name == common.DefaultGitPrj { // if default project git, we don't need it in project name to be unique return fmt.Sprintf( "%s:%s:PR:%d", baseProject, common.ObsSafeProjectName(pr.Base.Repo.Owner.UserName), pr.Index, ) } return fmt.Sprintf( "%s:%s:%s:PR:%d", baseProject, common.ObsSafeProjectName(pr.Base.Repo.Owner.UserName), common.ObsSafeProjectName(pr.Base.Repo.Name), pr.Index, ) } type RequestModification int const ( RequestModificationNoChange = 1 RequestModificationProjectCreated = 2 RequestModificationSourceChanged = 3 ) type BuildStatusSummary int const ( BuildStatusSummarySuccess = 1 BuildStatusSummaryFailed = 2 BuildStatusSummaryBuilding = 3 BuildStatusSummaryUnknown = 4 ) func ProcessBuildStatus(project, refProject *common.BuildResultList) BuildStatusSummary { if _, finished := refProject.BuildResultSummary(); !finished { common.LogDebug("refProject not finished building??") return BuildStatusSummaryUnknown } if _, finished := project.BuildResultSummary(); !finished { common.LogDebug("Still building...") return BuildStatusSummaryBuilding } // the repositories should be setup equally between the projects. We // need to verify that packages that are building in `refProject` are not // failing in the `project` BuildResultSorter := func(a, b *common.BuildResult) int { if c := strings.Compare(a.Repository, b.Repository); c != 0 { return c } if c := strings.Compare(a.Arch, b.Arch); c != 0 { return c } panic("Should not happen -- BuiltResultSorter equal repos?") } slices.SortFunc(project.Result, BuildResultSorter) if refProject == nil { // just return if buid finished and have some successes, since new package common.LogInfo("New package. Only need some success...") SomeSuccess := false for i := 0; i < len(project.Result); i++ { repoRes := project.Result[i] repoResStatus, ok := common.ObsRepoStatusDetails[repoRes.Code] if !ok { common.LogDebug("cannot find code:", repoRes.Code) return BuildStatusSummaryUnknown } if !repoResStatus.Finished { return BuildStatusSummaryBuilding } for _, pkg := range repoRes.Status { pkgStatus, ok := common.ObsBuildStatusDetails[pkg.Code] if !ok { common.LogInfo("Unknown package build status:", pkg.Code, "for", pkg.Package) common.LogDebug("Details:", pkg.Details) } if pkgStatus.Success { SomeSuccess = true } } } if SomeSuccess { return BuildStatusSummarySuccess } return BuildStatusSummaryFailed } slices.SortFunc(refProject.Result, BuildResultSorter) common.LogDebug("comparing results", len(project.Result), "vs. ref", len(refProject.Result)) SomeSuccess := false for i := 0; i < len(project.Result); i++ { common.LogDebug("searching for", project.Result[i].Repository, "/", project.Result[i].Arch) j := 0 found: for ; j < len(refProject.Result); j++ { if project.Result[i].Repository != refProject.Result[j].Repository || project.Result[i].Arch != refProject.Result[j].Arch { continue } common.LogDebug(" found match for @ idx:", j) res, success := ProcessRepoBuildStatus(project.Result[i].Status, refProject.Result[j].Status) switch res { case BuildStatusSummarySuccess: SomeSuccess = SomeSuccess || success break found default: return res } } if j >= len(refProject.Result) { common.LogDebug("Cannot find results...") common.LogDebug(project.Result[i]) common.LogDebug(refProject.Result) return BuildStatusSummaryUnknown } } if SomeSuccess { return BuildStatusSummarySuccess } return BuildStatusSummaryFailed } func ProcessRepoBuildStatus(results, ref []*common.PackageBuildStatus) (status BuildStatusSummary, SomeSuccess bool) { PackageBuildStatusSorter := func(a, b *common.PackageBuildStatus) int { return strings.Compare(a.Package, b.Package) } common.LogDebug("******** REF: ") data, _ := xml.MarshalIndent(ref, "", " ") common.LogDebug(string(data)) common.LogDebug("******* RESULTS: ") data, _ = xml.MarshalIndent(results, "", " ") common.LogDebug(string(data)) common.LogDebug("*******") // compare build result slices.SortFunc(results, PackageBuildStatusSorter) slices.SortFunc(ref, PackageBuildStatusSorter) j := 0 SomeSuccess = false for i := 0; i < len(results); i++ { res, ok := common.ObsBuildStatusDetails[results[i].Code] if !ok { common.LogInfo("unknown package result code:", results[i].Code, "for package:", results[i].Package) return BuildStatusSummaryUnknown, SomeSuccess } if !res.Finished { return BuildStatusSummaryBuilding, SomeSuccess } if !res.Success { // not failed if reference project also failed for same package here for ; j < len(results) && strings.Compare(results[i].Package, ref[j].Package) < 0; j++ { } if j < len(results) && results[i].Package == ref[j].Package { refRes, ok := common.ObsBuildStatusDetails[ref[j].Code] if !ok { common.LogInfo("unknown ref package result code:", ref[j].Code, "package:", ref[j].Package) return BuildStatusSummaryUnknown, SomeSuccess } if !refRes.Finished { common.LogDebug("Not finished building in reference project?") } if refRes.Success { return BuildStatusSummaryFailed, SomeSuccess } } } else { SomeSuccess = true } } return BuildStatusSummarySuccess, SomeSuccess } func GenerateObsPrjMeta(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 { common.LogError("Cannot fetch PR git:", pr.URL) return nil, err } // find modified submodules and new submodules -- build them dir := pr.Head.Sha headSubmodules, err := git.GitSubmoduleList(dir, pr.Head.Sha) if err != nil { return nil, err } baseSubmodules, err := git.GitSubmoduleList(dir, pr.MergeBase) if err != nil { return nil, err } modifiedOrNew := make([]string, 0, 16) for pkg, headOid := range headSubmodules { if baseOid, exists := baseSubmodules[pkg]; !exists || baseOid != headOid { modifiedOrNew = append(modifiedOrNew, pkg) } } common.LogDebug("Trying first staging master project: ", stagingMasterPrj) meta, err := ObsClient.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) } if err != nil { common.LogError("error fetching project meta for", buildPrj, ". Err:", err) return nil, err } // generate new project with paths pointinig back to original repos // disable publishing meta.Name = stagingPrj meta.Description = fmt.Sprintf(`Pull request build job PR#%d to branch %s of %s/%s`, pr.Index, pr.Base.Name, pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name) meta.Url = fmt.Sprintf( "%s/%s/%s/pulls/%d", GiteaUrl, url.PathEscape(pr.Base.Repo.Owner.UserName), url.PathEscape(pr.Base.Repo.Name), pr.Index, ) urlPkg := make([]string, 0, len(modifiedOrNew)) for _, pkg := range modifiedOrNew { urlPkg = append(urlPkg, "onlybuild="+url.QueryEscape(pkg)) } meta.ScmSync = pr.Head.Repo.CloneURL + "?" + strings.Join(urlPkg, "&") + "#" + pr.Head.Sha meta.Title = fmt.Sprintf("PR#%d to %s", pr.Index, pr.Base.Name) // QE wants it published ... also we should not hardcode it here, since // it is configurable via the :PullRequest project // meta.PublicFlags = common.Flags{Contents: ""} meta.Groups = nil meta.Persons = nil // set paths to parent project for idx, r := range meta.Repositories { meta.Repositories[idx].ReleaseTargets = nil localRepository := false for pidx, path := range r.Paths { // Check for path building against a repo in template project itself if path.Project == buildPrj { meta.Repositories[idx].Paths[pidx].Project = meta.Name localRepository = true } } if localRepository != true { meta.Repositories[idx].Paths = []common.RepositoryPathMeta{{ Project: buildPrj, Repository: r.Name, }} } } return meta, nil } // buildProject // ^- templateProject // // 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) error { common.LogDebug("Setup QA sub projects") templateMeta, err := ObsClient.GetProjectMeta(templateProject) if err != nil { common.LogError("error fetching template project meta for", templateProject, ":", err) return err } // patch baseMeta to become the new project templateMeta.Name = stagingProject + ":" + subProjectName // Cleanup ReleaseTarget and modify affected path entries for idx, r := range templateMeta.Repositories { templateMeta.Repositories[idx].ReleaseTargets = nil for pidx, path := range r.Paths { // Check for path building against code stream if path.Project == stagingConfig.ObsProject { templateMeta.Repositories[idx].Paths[pidx].Project = stagingProject } // Check for path building against a repo in template project itself if path.Project == templateProject { templateMeta.Repositories[idx].Paths[pidx].Project = templateMeta.Name } } } if !IsDryRun { err = ObsClient.SetProjectMeta(templateMeta) if err != nil { common.LogError("cannot create project:", templateMeta.Name, err) return err } } else { common.LogDebug("Create project:", templateMeta.Name) common.LogDebug(templateMeta) } return nil } 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) meta, err := ObsClient.GetProjectMeta(obsPrProject) if err != nil { common.LogError("error fetching project meta for", obsPrProject, ":", err) return RequestModificationNoChange, err } if meta != nil { path, err := url.Parse(meta.ScmSync) if err != nil { common.LogError("Cannot parse SCMSYNC url: '", meta.ScmSync, "' .. regenerating build") meta = nil } else { if path.Fragment == pr.Head.Sha { // build in progress return RequestModificationNoChange, nil } // build needs update common.LogInfo("Detected Head update... regenerating build...") meta = nil } } var state RequestModification = RequestModificationSourceChanged if meta == nil { // new build common.LogDebug(" Staging master:", config.StagingProject) meta, err = GenerateObsPrjMeta(git, gitea, pr, obsPrProject, config.ObsProject, config.StagingProject) if err != nil { return RequestModificationNoChange, err } state = RequestModificationProjectCreated } if IsDryRun { x, _ := xml.MarshalIndent(meta, "", " ") common.LogDebug("Creating build project:") common.LogDebug(" meta:", string(x)) } else { err = ObsClient.SetProjectMeta(meta) if err != nil { x, _ := xml.MarshalIndent(meta, "", " ") common.LogDebug(" meta:", string(x)) common.LogError("cannot create meta project:", err) return RequestModificationNoChange, err } } return state, nil } func IsReviewerRequested(pr *models.PullRequest) bool { for _, reviewer := range pr.RequestedReviewers { if reviewer != nil && reviewer.UserName == Username { return true } } return false } var NonActionableReviewError = errors.New("Ignoring non-actionable review request. Marking as done.") var NoReviewsFoundError = errors.New("No review requests found for the review user. Marking as done.") func FetchOurLatestActionableReview(gitea common.Gitea, org, repo string, id int64) (*models.PullReview, error) { reviews, err := gitea.GetPullRequestReviews(org, repo, id) if err != nil { return nil, err } slices.SortFunc(reviews, func(a, b *models.PullReview) int { return time.Time(a.Submitted).Compare(time.Time(b.Submitted)) }) for idx := len(reviews) - 1; idx >= 0; idx-- { review := reviews[idx] if review.User == nil || review.User.UserName == Username { if IsDryRun { // for purposes of moving forward a no-op check return review, nil } switch review.State { default: // non-actionable state, mark as done common.LogInfo("Ignoring non-actionable review request. Marking as done.") return nil, NonActionableReviewError case common.ReviewStatePending, common.ReviewStateRequestReview: return review, nil } } } return nil, NoReviewsFoundError } func ParseNotificationToPR(thread *models.NotificationThread) (org string, repo string, num int64, err error) { rx := regexp.MustCompile(`^https://src\.(?:open)?suse\.(?:org|de)/api/v\d+/repos/(?[-_a-zA-Z0-9]+)/(?[-_a-zA-Z0-9]+)/issues/(?[0-9]+)$`) notification := thread.Subject match := rx.FindStringSubmatch(notification.URL) if match == nil { err = fmt.Errorf("Unexpected notification format: %s", notification.URL) return } org = match[1] repo = match[2] num, err = strconv.ParseInt(match[3], 10, 64) return } func ProcessPullNotification(gitea common.Gitea, thread *models.NotificationThread) { defer func() { err := recover() if err != nil { common.LogError(err) common.LogError(string(debug.Stack())) } }() org, repo, num, err := ParseNotificationToPR(thread) if err != nil { common.LogError(err.Error()) return } common.LogInfo("processing PR:", org, "/", repo, "#", num) done, err := ProcessPullRequest(gitea, org, repo, num) if !IsDryRun && err == nil && done { gitea.SetNotificationRead(thread.ID) } else if err != nil { common.LogError(err) } } var CleanedUpIssues []int64 = []int64{} func CleanupPullNotification(gitea common.Gitea, thread *models.NotificationThread) (CleanupComplete bool) { defer func() { err := recover() if err != nil { common.LogError(err) common.LogError(string(debug.Stack())) } }() cleanUpIdx, cleanedUp := slices.BinarySearch(CleanedUpIssues, thread.ID) if cleanedUp { return true } common.LogDebug(" processing notification:", thread.Subject.HTMLURL) org, repo, num, err := ParseNotificationToPR(thread) if err != nil { common.LogError(err.Error()) return false } pr, err := gitea.GetPullRequest(org, repo, num) if err != nil { common.LogError("Cannot fetch PR ", org, "/", repo, "#", num, " error:", err) return false } if pr.State != "closed" { common.LogInfo(" ignoring pending PR", thread.Subject.HTMLURL, " state:", pr.State) return false } data, _, err := gitea.GetRepositoryFileContent(org, repo, pr.Head.Sha, common.StagingConfigFile) // TODO: remove this legacy PR cleanup stuff var config *common.StagingConfig = &common.StagingConfig{} if err != nil { if errors.Is(err, &repository.RepoGetContentsNotFound{}) { data, _, err := gitea.GetRepositoryFileContent(org, repo, pr.Head.Sha, "project.build") if err == nil { common.LogDebug(" --> Legacy PR cleanup") config.ObsProject = string(data) } else { common.LogDebug("assuming cleaned up already...", pr.URL) CleanedUpIssues = slices.Insert(CleanedUpIssues, cleanUpIdx, thread.ID) return true } } if config.ObsProject == "" { common.LogDebug("Cannot fetch config file in the PR. Skipping cleanup.") return false } } else { config, err = common.ParseStagingConfig(data) if err != nil { common.LogDebug("Failed to parse config ... so probably nothing to cleanup anyway. Marked as done") CleanedUpIssues = slices.Insert(CleanedUpIssues, cleanUpIdx, thread.ID) return true } } if !pr.HasMerged && time.Since(time.Time(pr.Closed)) < time.Duration(config.CleanupDelay)*time.Hour { common.LogInfo("Cooldown period for cleanup of", thread.Subject.HTMLURL) return false } stagingProject := GetObsProjectAssociatedWithPr(config, ObsClient.HomeProject, pr) if prj, err := ObsClient.GetProjectMeta(stagingProject); err != nil { common.LogError("Failed fetching meta for project:", stagingProject, ". Not cleaning up") return false } else if prj == nil && err == nil { // cleanup already done CleanedUpIssues = slices.Insert(CleanedUpIssues, cleanUpIdx, thread.ID) return true } common.LogDebug("Cleaning up", stagingProject) for _, qa := range config.QA { project := stagingProject + ":" + qa.Name common.LogDebug("Cleaning up QA staging", project) if !IsDryRun { if err := ObsClient.DeleteProject(project); err != nil { common.LogError("Failed to cleanup QA staging", project, err) } } } if !IsDryRun { if err := ObsClient.DeleteProject(stagingProject); err != nil { common.LogError("Failed to cleanup staging", stagingProject, err) } } CleanedUpIssues = slices.Insert(CleanedUpIssues, cleanUpIdx, thread.ID) if l := len(CleanedUpIssues); l > 100000 { common.LogInfo("** Problem? ** Cleaning up massive CleanedUpIssues cache") CleanedUpIssues = CleanedUpIssues[l-50000:] } return false // cleaned up now, but the cleanup was not aleady done } func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, error) { dir, err := os.MkdirTemp(os.TempDir(), BotName) common.PanicOnError(err) if IsDryRun { common.LogInfo("will keep temp directory:", dir) } else { defer os.RemoveAll(dir) } gh, err := common.AllocateGitWorkTree(dir, GitAuthor, "noaddress@suse.de") common.PanicOnError(err) git, err := gh.CreateGitHandler(org) common.PanicOnError(err) common.LogDebug("Fetching PR:", org, repo, id) pr, err := gitea.GetPullRequest(org, repo, id) if err != nil { common.LogError("No PR associated with review:", org, "/", repo, "#", id, "Error:", err) return true, err } common.LogDebug("PR state:", pr.State) if pr.State == "closed" { // dismiss the review common.LogInfo(" -- closed request, so nothing to review") return true, nil } if !IsReviewerRequested(pr) { common.LogError("Review not requested in notification. Setting to status 'read'") if !IsDryRun { return true, nil } else { common.LogDebug(" -- continueing dry mode") } } if review, err := FetchOurLatestActionableReview(gitea, org, repo, id); err == nil { common.LogInfo("processing review", review.HTMLURL, "state", review.State) err = FetchPrGit(git, pr) if err != nil { common.LogError("Cannot fetch PR git:", pr.URL) return false, err } // we want the possibly pending modification here, in case stagings are added, etc. // jobs of review team to deal with issues common.LogDebug("QA configuration fetching ...", common.StagingConfigFile) data, err := git.GitCatFile(pr.Head.Sha, pr.Head.Sha, common.StagingConfigFile) 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) } return true, err } stagingConfig, err := common.ParseStagingConfig(data) if err != nil { common.LogError("Error parsing config file", common.StagingConfigFile, err) } if stagingConfig.ObsProject == "" { common.LogError("Cannot find reference project for PR#", pr.Index) if !IsDryRun { _, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Cannot find reference project") return true, err } return true, nil } meta, err := ObsClient.GetProjectMeta(stagingConfig.ObsProject) if err != nil { common.LogError("Cannot find reference project meta:", stagingConfig.ObsProject, err) if !IsDryRun { _, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Cannot fetch reference project meta") return true, err } return true, nil } if metaUrl, err := url.Parse(meta.ScmSync); err != nil { return false, err } else if targetPRSyncUrl, err := url.Parse(pr.Base.Repo.CloneURL); err != nil { return false, err } else { metaUrl.RawQuery = "" metaUrl.Fragment = "" metaUrl.Path = strings.TrimSuffix(metaUrl.Path, ".git") targetPRSyncUrl.RawQuery = "" targetPRSyncUrl.Fragment = "" targetPRSyncUrl.Path = strings.TrimSuffix(targetPRSyncUrl.Path, ".git") if metaUrl.String() != targetPRSyncUrl.String() { common.LogError("SCMSYNC in meta", meta.ScmSync, "!= PR CloneURL from Gitea", pr.Base.Repo.CloneURL, ". Skipping staging") if !IsDryRun { _, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "SCMSYNC of target project is not equal to CloneURL of this PR. Can't review.") return true, err } return true, nil } } if stagingConfig.StagingProject != "" { // staging project must either be nothing or be *under* the target project. // other setups are currently not supported // NOTE: this is user input, so we need some limits here l := len(stagingConfig.ObsProject) if l >= len(stagingConfig.StagingProject) || stagingConfig.ObsProject != stagingConfig.StagingProject[0:l] { common.LogError("StagingProject (", stagingConfig.StagingProject, ") is not child of target project", stagingConfig.ObsProject) } } if meta.Name != stagingConfig.ObsProject { common.LogError("staging.config . ObsProject:", stagingConfig.ObsProject, " is not target project name", meta.Name) if !IsDryRun { _, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "OBS Meta and staging.config are inconsistent") return true, err } return true, nil } // find modified submodules and new submodules -- build them dir := pr.Head.Sha common.LogDebug("PR:", pr.MergeBase, "->", pr.Head.Sha) headSubmodules, err := git.GitSubmoduleList(dir, pr.Head.Sha) common.PanicOnError(err) baseSubmodules, err := git.GitSubmoduleList(dir, pr.MergeBase) common.PanicOnError(err) common.LogDebug(" # head submodules:", len(headSubmodules)) common.LogDebug(" # base submodules:", len(baseSubmodules)) modifiedOrNew := make([]string, 0, 16) if !stagingConfig.RebuildAll { for pkg, headOid := range headSubmodules { if baseOid, exists := baseSubmodules[pkg]; !exists || baseOid != headOid { modifiedOrNew = append(modifiedOrNew, pkg) common.LogDebug(pkg, ":", baseOid, "->", headOid) } } } if len(modifiedOrNew) == 0 { rebuild_all := false || stagingConfig.RebuildAll reviews, err := gitea.GetPullRequestReviews(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index) common.LogDebug("num reviews:", len(reviews)) if err == nil { rebuild_rx := regexp.MustCompile("^@autogits_obs_staging_bot\\s*:\\s*(re)?build\\s*all$") done: for _, r := range reviews { for _, l := range common.SplitLines(r.Body) { if rebuild_rx.MatchString(strings.ToLower(l)) { rebuild_all = true break done } } } comments, err := gitea.GetIssueComments(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index) common.LogDebug("num comments:", len(comments)) if err == nil { done2: for _, r := range comments { for _, l := range common.SplitLines(r.Body) { if rebuild_rx.MatchString(strings.ToLower(l)) { rebuild_all = true break done2 } } } } } else { common.LogError(err) } if !rebuild_all { common.LogInfo("No package changes detected. Ignoring") if !IsDryRun { _, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, "No package changes, not rebuilding project by default, accepting change") if err != nil { common.LogError(err) } else { return true, nil } } return true, err } } common.LogDebug("ObsProject:", stagingConfig.ObsProject) stagingProject := GetObsProjectAssociatedWithPr(stagingConfig, ObsClient.HomeProject, pr) change, err := StartOrUpdateBuild(stagingConfig, git, gitea, pr) status := &models.CommitStatus{ Context: BotName, Description: "OBS Staging build", Status: common.CommitStatus_Pending, TargetURL: ObsWebHost + "/project/show/" + stagingProject, } msg := "Changed source updated for build" if change == RequestModificationProjectCreated { msg = "Build is started in " + ObsWebHost + "/project/show/" + stagingProject + " .\n" if len(stagingConfig.QA) > 0 { msg = msg + "\nAdditional QA builds: \n" } gitea.SetCommitStatus(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Head.Sha, status) for _, setup := range stagingConfig.QA { CreateQASubProject(stagingConfig, git, gitea, pr, stagingProject, setup.Origin, setup.Name) msg = msg + ObsWebHost + "/project/show/" + stagingProject + ":" + setup.Name + "\n" } } if change != RequestModificationNoChange && !IsDryRun { gitea.AddComment(pr, msg) } baseResult, err := ObsClient.LastBuildResults(stagingConfig.ObsProject, modifiedOrNew...) if err != nil { common.LogError("failed fetching ref project status for", stagingConfig.ObsProject, ":", err) } stagingResult, err := ObsClient.BuildStatus(stagingProject) if err != nil { common.LogError("failed fetching ref project status for", stagingProject, ":", err) } buildStatus := ProcessBuildStatus(stagingResult, baseResult) switch buildStatus { case BuildStatusSummarySuccess: status.Status = common.CommitStatus_Success if !IsDryRun { _, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, "Build successful") if err != nil { common.LogError(err) } else { return true, nil } } case BuildStatusSummaryFailed: status.Status = common.CommitStatus_Fail if !IsDryRun { _, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Build failed") if err != nil { common.LogError(err) } else { return true, nil } } } common.LogInfo("Build status:", buildStatus) gitea.SetCommitStatus(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Head.Sha, status) // waiting for build results -- nothing to do } else if err == NonActionableReviewError || err == NoReviewsFoundError { return true, nil } return false, nil } func PollWorkNotifications(giteaUrl string) { gitea := common.AllocateGiteaTransport(giteaUrl) data, err := gitea.GetNotifications(common.GiteaNotificationType_Pull, nil) if err != nil { common.LogError(err) return } if data != nil { common.LogDebug("Processing", len(data), "notifications.") for _, notification := range data { common.LogInfo("notification", notification.ID, "--", notification.Subject.HTMLURL) if !ListPullNotificationsOnly { switch notification.Subject.Type { case "Pull": ProcessPullNotification(gitea, notification) default: if !IsDryRun { gitea.SetNotificationRead(notification.ID) } } } } } // cleanup old projects common.LogDebug("Cleaning up old pull requests") cleanupFinished := false for page := int64(1); !cleanupFinished; page++ { cleanupFinished = true if data, err := gitea.GetDoneNotifications(common.GiteaNotificationType_Pull, page); data != nil { for _, n := range data { if n.Unread { common.LogError("Done notification is unread or pinned?", *n.Subject) cleanupFinished = false continue } cleanupFinished = CleanupPullNotification(gitea, n) && cleanupFinished } } else if err != nil { common.LogError(err) } } } var ListPullNotificationsOnly bool var GiteaUrl string var GiteaUseSshClone bool var ObsWebHost string var IsDryRun bool var ProcessPROnly string var ObsClient *common.ObsClient func ObsWebHostFromApiHost(apihost string) string { u, err := url.Parse(apihost) if err != nil { common.LogError("Cannot parse OBS API URL") panic(err) } if len(u.Host) > 4 && u.Host[0:4] == "api." { u.Host = "build" + u.Host[3:] } return u.String() } func main() { flag.BoolVar(&ListPullNotificationsOnly, "list-notifications-only", false, "Only lists notifications without acting on them") ProcessPROnly := flag.String("pr", "", "Process only specific PR and ignore the rest. Use for debugging") buildRoot := flag.String("build-root", "", "Default build location for staging projects. Default is bot's home project") flag.StringVar(&GiteaUrl, "gitea-url", "https://src.opensuse.org", "Gitea instance") flag.BoolVar(&GiteaUseSshClone, "use-ssh-clone", false, "enforce cloning via ssh") obsApiHost := flag.String("obs", "https://api.opensuse.org", "API for OBS instance") flag.StringVar(&ObsWebHost, "obs-web", "", "Web OBS instance, if not derived from the obs config") flag.BoolVar(&IsDryRun, "dry", false, "Dry-run, don't actually create any build projects or review changes") debug := flag.Bool("debug", false, "Turns on debug logging") flag.Parse() if *debug { common.SetLoggingLevel(common.LogLevelDebug) } else { common.SetLoggingLevel(common.LogLevelInfo) } if len(ObsWebHost) == 0 { ObsWebHost = ObsWebHostFromApiHost(*obsApiHost) } common.LogDebug("OBS Web Host:", ObsWebHost) common.LogDebug("OBS API Host:", *obsApiHost) common.PanicOnErrorWithMsg(common.RequireGiteaSecretToken(), "Cannot find GITEA_TOKEN") common.PanicOnErrorWithMsg(common.RequireObsSecretToken(), "Cannot find OBS_USER and OBS_PASSWORD") var err error if ObsClient, err = common.NewObsClient(*obsApiHost); err != nil { log.Error(err) return } if len(*buildRoot) > 0 { ObsClient.HomeProject = *buildRoot } if len(*ProcessPROnly) > 0 { rx := regexp.MustCompile("^(\\w+)/(\\w+)#(\\d+)$") m := rx.FindStringSubmatch(*ProcessPROnly) if m == nil { common.LogError("Cannot find any PR matches in", *ProcessPROnly) return } gitea := common.AllocateGiteaTransport(GiteaUrl) id, _ := strconv.ParseInt(m[3], 10, 64) ProcessPullRequest(gitea, m[1], m[2], id) return } for { PollWorkNotifications(GiteaUrl) common.LogInfo("Poll cycle finished") time.Sleep(5 * time.Minute) } }