Files
autogits/obs-staging-bot/main.go
Adrian Schröter a03491f75c Keep maintainers from staging template project
They need to keep access as they might need to be able to modify the
stage project. They could grant access anyway, by adding themselfs
as they own the upper project. No reason to force them the
extra trip or to hide build results first to them
2025-09-24 10:39:07 +02:00

1084 lines
33 KiB
Go

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 <https://www.gnu.org/licenses/>.
*/
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 runId uint
func FetchPrGit(git common.Git, pr *models.PullRequest) error {
// clone PR head via base (target) repo
cloneURL := pr.Base.Repo.CloneURL
// pass our token as user always
user, err := url.Parse(cloneURL)
common.PanicOnError(err)
user.User = url.User(common.GetGiteaToken())
cloneURL = user.String()
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)
}
}
// find modified directories and assume they are packages
// TODO: use _manifest for this here
headDirectories, err := git.GitDirectoryList(dir, pr.Head.Sha)
if err != nil {
return nil, err
}
baseDirectories, err := git.GitDirectoryList(dir, pr.MergeBase)
if err != nil {
return nil, err
}
for pkg, headOid := range headDirectories {
if baseOid, exists := baseDirectories[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
if len(meta.ScmSync) >= 65535 {
return nil, errors.New("Reached max amount of package changes per request")
}
meta.Title = fmt.Sprintf("PR#%d to %s", pr.Index, pr.Base.Name)
// Untouched content are flags and involved users. These can be configured
// via the staging project.
// 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/(?<org>[-_a-zA-Z0-9]+)/(?<project>[-_a-zA-Z0-9]+)/issues/(?<num>[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 SetStatus(gitea common.Gitea, org, repo, hash string, status *models.CommitStatus) error {
_, err := gitea.SetCommitStatus(org, repo, hash, status)
if err != nil {
common.LogError(err)
}
return err
}
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)
return true, 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 || meta == 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))
modifiedPackages := make([]string, 0, 16)
newPackages := make([]string, 0, 16)
if !stagingConfig.RebuildAll {
for pkg, headOid := range headSubmodules {
if baseOid, exists := baseSubmodules[pkg]; !exists || baseOid != headOid {
if exists {
modifiedPackages = append(modifiedPackages, pkg)
} else {
newPackages = append(newPackages, pkg)
}
common.LogDebug(pkg, ":", baseOid, "->", headOid)
}
}
}
if len(modifiedPackages) == 0 && len(newPackages) == 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,
}
if err != nil {
msg := "Unable to setup stage project " + stagingConfig.ObsProject
status.Status = common.CommitStatus_Fail
common.LogError(msg)
if !IsDryRun {
SetStatus(gitea, org, repo, pr.Head.Sha, status)
_, err = gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, msg)
if err != nil {
common.LogError(err)
} else {
return true, nil
}
}
return false, nil
}
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"
}
SetStatus(gitea, org, repo, 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, modifiedPackages...)
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 stage project status for", stagingProject, ":", err)
}
buildStatus := ProcessBuildStatus(stagingResult, baseResult)
done := false
switch buildStatus {
case BuildStatusSummarySuccess:
status.Status = common.CommitStatus_Success
done = true
if !IsDryRun {
_, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, "Build successful")
if err != nil {
common.LogError(err)
}
}
case BuildStatusSummaryFailed:
status.Status = common.CommitStatus_Fail
done = true
if !IsDryRun {
_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Build failed")
if err != nil {
common.LogError(err)
}
}
}
common.LogInfo("Build status:", buildStatus)
if !IsDryRun {
if err = SetStatus(gitea, org, repo, pr.Head.Sha, status); err != nil {
return false, err
}
}
return done, nil
} 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 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")
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("^([^/#]+)/([^/#]+)#([0-9]+)$")
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)
}
}