572 lines
16 KiB
Go
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.Gitea, 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.Gitea, 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.Gitea, 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)
|
|
}
|
|
}
|
|
|