autogits/obs-staging-bot/main.go
2024-10-29 15:36:20 +01:00

572 lines
16 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 (
"bytes"
"errors"
"flag"
"fmt"
"log"
"net/url"
"os"
"path"
"regexp"
"runtime/debug"
"slices"
"strconv"
"strings"
"time"
"src.opensuse.org/autogits/common"
"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 failOnError(err error, msg string) {
if err != nil {
log.Panicf("%s: %s", err, msg)
}
}
func fetchPrGit(git *common.GitHandler, pr *models.PullRequest) error {
// clone PR head and base and return path
if _, err := os.Stat(path.Join(git.GitPath, pr.Head.Sha)); os.IsNotExist(err) {
git.GitExec("", "clone", "--depth", "1", pr.Head.Repo.CloneURL, pr.Head.Sha)
git.GitExec(pr.Head.Sha, "fetch", "--depth", "1", "origin", pr.Head.Sha, pr.Base.Sha)
} else if err != nil {
return err
}
return nil
}
func getObsProjectAssociatedWithPr(baseProject string, pr *models.PullRequest) string {
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,
)
}
func processBuildStatusUpdate() {
}
type BuildStatusSummary int
const (
BuildStatusSummarySuccess = 1
BuildStatusSummaryFailed = 2
BuildStatusSummaryBuilding = 3
BuildStatusSummaryUnknown = 4
)
func processBuildStatus(project, refProject *common.BuildResultList) BuildStatusSummary {
if _, finished := project.BuildResultSummary(); !finished {
return BuildStatusSummaryBuilding
}
if _, finished := refProject.BuildResultSummary(); !finished {
log.Println("refProject not finished building??")
return BuildStatusSummaryUnknown
}
// 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
SomeSuccess := false
for i := 0; i < len(project.Result); i++ {
repoRes := &project.Result[i]
repoResStatus, ok := common.ObsRepoStatusDetails[repoRes.Code]
if !ok {
log.Println("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 {
log.Println("Unknown package build status:", pkg.Code, "for", pkg.Package)
log.Println("Details:", pkg.Details)
}
if pkgStatus.Success {
SomeSuccess = true
}
}
}
if SomeSuccess {
return BuildStatusSummarySuccess
}
return BuildStatusSummaryFailed
}
slices.SortFunc(refProject.Result, BuildResultSorter)
log.Printf("comparing results %d vs. ref %d\n", len(project.Result), len(refProject.Result))
for i := 0; i < len(project.Result); i++ {
log.Println("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
}
log.Printf("found match for %s/%s @ %d\n", project.Result[i].Repository, project.Result[i].Arch, j)
res := processRepoBuildStatus(project.Result[i].Status, refProject.Result[j].Status)
switch res {
case BuildStatusSummarySuccess:
break found
default:
return res
}
}
log.Println(j)
if j >= len(refProject.Result) {
log.Printf("Cannot find results... %#v \n %#v\n", project.Result[i], refProject.Result)
return BuildStatusSummaryUnknown
}
}
return BuildStatusSummarySuccess
}
func processRepoBuildStatus(results, ref []common.PackageBuildStatus) BuildStatusSummary {
PackageBuildStatusSorter := func(a, b common.PackageBuildStatus) int {
return strings.Compare(a.Package, b.Package)
}
// compare build result
slices.SortFunc(results, PackageBuildStatusSorter)
slices.SortFunc(ref, PackageBuildStatusSorter)
j := 0
for i := 0; i < len(results); i++ {
res, ok := common.ObsBuildStatusDetails[results[i].Code]
if !ok {
log.Printf("unknown package result code: %s for package %s\n", results[i].Code, results[i].Package)
return BuildStatusSummaryUnknown
}
if !res.Finished {
return BuildStatusSummaryBuilding
}
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 {
log.Printf("unknown package result code: %s for package %s\n", ref[j].Code, ref[j].Package)
return BuildStatusSummaryUnknown
}
if !refRes.Finished {
log.Println("not finished building in reference project?!")
}
if refRes.Success {
return BuildStatusSummaryFailed
}
}
}
}
return BuildStatusSummarySuccess
}
func generateObsPrjMeta(git *common.GitHandler, gitea *common.GiteaTransport, pr *models.PullRequest, obsClient *common.ObsClient) (*common.ProjectMeta, error) {
log.Println("repo content fetching ...")
err := fetchPrGit(git, pr)
if err != nil {
log.Println("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.Base.Sha)
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)
}
}
prjBuild, err := git.GitCatFile(dir, pr.Head.Sha, "project.build")
if err != nil {
return nil, err
}
buildPrj := string(bytes.TrimSpace(prjBuild))
if len(buildPrj) < 1 {
_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Cannot find reference project")
if err != nil {
log.Println(err)
return nil, err
}
return nil, fmt.Errorf("Cannot find reference project for %s PR#%d", pr.Base.Name, pr.Index)
}
meta, err := obsClient.GetProjectMeta(buildPrj)
if err != nil {
log.Println("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 = getObsProjectAssociatedWithPr(obsClient.HomeProject, pr)
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(
"https://src.opensuse.org/%s/%s/pulls/%d",
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)
meta.PublicFlags = common.Flags{Contents: "<disable/>"}
// set paths to parent project
for idx, r := range meta.Repositories {
meta.Repositories[idx].Paths = []common.RepositoryPathMeta{{
Project: buildPrj,
Repository: r.Name,
}}
}
log.Println(meta)
return meta, nil
}
func startOrUpdateBuild(git *common.GitHandler, gitea *common.GiteaTransport, pr *models.PullRequest, obsClient *common.ObsClient) error {
log.Println("fetching OBS project Meta")
obsPrProject := getObsProjectAssociatedWithPr(obsClient.HomeProject, pr)
meta, err := obsClient.GetProjectMeta(obsPrProject)
if err != nil {
log.Println("error fetching project meta for", obsPrProject, ":", err)
return err
}
if meta != nil {
path, err := url.Parse(meta.ScmSync)
if err != nil {
log.Println("Cannot parse SCMSYNC url: '", meta.ScmSync, "' .. regenerating build")
meta = nil
} else {
if path.Fragment == pr.Head.Sha {
// build in progress
return nil
}
// build needs update
log.Println("Detected Head update... regenerating build...")
meta = nil
}
}
if meta == nil {
// new build
meta, err = generateObsPrjMeta(git, gitea, pr, obsClient)
if err != nil {
return err
}
}
err = obsClient.SetProjectMeta(meta)
if err != nil {
log.Println("cannot create meta project:", err)
return err
}
return nil
}
func processPullNotification(gitea *common.GiteaTransport, thread *models.NotificationThread) {
defer func() {
err := recover()
if err != nil {
log.Println(err)
log.Println(string(debug.Stack()))
}
}()
git, err := common.CreateGitHandler(GitAuthor, "noaddress@suse.de", BotName)
if err != nil {
log.Panicln(err)
}
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 {
log.Panicf("Unexpected format of notification: %s", notification.URL)
}
log.Println("processing")
log.Println("project:", match[2])
log.Println("org: ", match[1])
log.Println("number: ", match[3])
org := match[1]
repo := match[2]
id, _ := strconv.ParseInt(match[3], 10, 64)
pr, reviews, err := gitea.GetPullRequestAndReviews(org, repo, id)
if err != nil {
log.Println("No PR associated with review:", notification.URL, "Error:", err)
return
}
obsClient, err := common.NewObsClient("api.opensuse.org")
if err != nil {
log.Println(err)
return
}
reviewRequested := false
for _, reviewer := range pr.RequestedReviewers {
if reviewer.UserName == Username {
reviewRequested = true
break
}
}
if !reviewRequested {
log.Println("Review not requested in notification. Setting to status 'read'")
gitea.SetNotificationRead(thread.ID)
return
}
newReviews := make([]*models.PullReview, 0, len(reviews))
for _, review := range reviews {
if review.User.UserName == Username {
newReviews = append(newReviews, review)
}
}
reviews = newReviews
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]
log.Printf("state: %s, body: %s, id:%d\n", string(review.State), review.Body, review.ID)
if review.User.UserName != Username {
continue
}
log.Println("processing state...")
switch review.State {
// create build project, if doesn't exist, and add it to pending requests
case common.ReviewStateUnknown, common.ReviewStateRequestReview:
if err := startOrUpdateBuild(git, gitea, pr, obsClient); err != nil {
return
}
msg := "Build is started in https://build.opensuse.org/project/show/" +
getObsProjectAssociatedWithPr(obsClient.HomeProject, pr)
gitea.AddReviewComment(pr, common.ReviewStatePending, msg)
case common.ReviewStatePending:
err := fetchPrGit(git, pr)
if err != nil {
log.Println("Cannot fetch PR git:", pr.URL)
return
}
// find modified submodules and new submodules -- build them
dir := pr.Head.Sha
headSubmodules, err := git.GitSubmoduleList(dir, pr.Head.Sha)
if err != nil {
log.Panicln(err)
}
baseSubmodules, err := git.GitSubmoduleList(dir, pr.Base.Sha)
if err != nil {
log.Panicln(err)
}
modifiedOrNew := make([]string, 0, 16)
for pkg, headOid := range headSubmodules {
if baseOid, exists := baseSubmodules[pkg]; !exists || baseOid != headOid {
modifiedOrNew = append(modifiedOrNew, pkg)
}
}
log.Println("repo content fetching ...")
refPrjData, err := git.GitCatFile(dir, pr.Head.Sha, "project.build")
if err != nil {
log.Panicln(err)
}
refPrj := string(bytes.TrimSpace(refPrjData))
if len(refPrj) < 1 {
_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Cannot find reference project")
if err != nil {
log.Println(err)
return
}
log.Printf("Cannot find reference project for %s PR#%d\n", pr.Base.Name, pr.Index)
return
}
obsProject := getObsProjectAssociatedWithPr(obsClient.HomeProject, pr)
prjResult, err := obsClient.BuildStatus(obsProject)
if err != nil {
if errors.Is(err, common.ObsProjectNotFound{Project: obsProject}) {
// recreate missing project
log.Printf("missing OBS project ... recreating '%s': %v\n", obsProject, err)
startOrUpdateBuild(git, gitea, pr, obsClient)
return
}
log.Printf("failed fetching build status for '%s': %v\n", obsProject, err)
return
}
refProjectResult, err := obsClient.BuildStatus(refPrj, prjResult.GetPackageList()...)
if err != nil {
log.Printf("failed fetching ref project status for '%s': %v\n", refPrj, err)
}
buildStatus := processBuildStatus(prjResult, refProjectResult)
switch buildStatus {
case BuildStatusSummarySuccess:
_, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, "Build successful")
if err != nil {
log.Println(err)
}
case BuildStatusSummaryFailed:
_, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Build failed")
if err != nil {
log.Println(err)
}
}
log.Println("Build status waiting:", buildStatus)
// waiting for build results -- nothing to do
case common.ReviewStateApproved:
// done, mark notification as read
log.Println("processing request for success build ...")
gitea.SetNotificationRead(thread.ID)
case common.ReviewStateRequestChanges:
// build failures, nothing to do here, mark notification as read
log.Println("processing request for failed request changes...")
gitea.SetNotificationRead(thread.ID)
}
break
}
}
func pollWorkNotifications(giteaHost string) {
gitea := common.AllocateGiteaTransport(giteaHost)
data, err := gitea.GetPullNotifications(nil)
if err != nil {
log.Println(err)
return
}
if data != nil {
for _, notification := range data {
switch notification.Subject.Type {
case "Pull":
processPullNotification(gitea, notification)
default:
gitea.SetNotificationRead(notification.ID)
}
}
}
}
func main() {
failOnError(common.RequireGiteaSecretToken(), "Cannot find GITEA_TOKEN")
failOnError(common.RequireObsSecretToken(), "Cannot find OBS_USER and OBS_PASSWORD")
giteaHost := flag.String("giteaHost", "src.opensuse.org", "Gitea hostname")
// go ProcessingObsMessages("rabbit.opensuse.org", "opensuse", "opensuse", "")
for {
pollWorkNotifications(*giteaHost)
time.Sleep(10 * time.Minute)
}
}