diff --git a/common/config.go b/common/config.go index d4a6331..ee13188 100644 --- a/common/config.go +++ b/common/config.go @@ -273,6 +273,15 @@ func (config *AutogitConfig) GetRemoteBranch() string { return "origin_" + config.Branch } +type RepositoryConfig struct { + Architectures []string +} + +type InstallcheckConfig struct { + Repos map[string][]string `json:"repos"` + NoCheckRepos map[string][]string `json:"nocheck_repos"` +} + type StagingConfig struct { ObsProject string RebuildAll bool @@ -281,6 +290,9 @@ type StagingConfig struct { // if set, then only use pull request numbers as unique identifiers StagingProject string QA []QAConfig + + Repositories map[string]RepositoryConfig `json:",omitempty"` + Installcheck InstallcheckConfig `json:",omitempty"` } func ParseStagingConfig(data []byte) (*StagingConfig, error) { diff --git a/common/cpio_utils.go b/common/cpio_utils.go new file mode 100644 index 0000000..4433bf5 --- /dev/null +++ b/common/cpio_utils.go @@ -0,0 +1,147 @@ +package common + +/* + * 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/binary" + "fmt" + "io" + "regexp" + "strconv" + "strings" +) + +// CpioHeader represents the CPIO "newc" format header. +// The fields are stored as 8-char hex strings. +type CpioHeader struct { + Magic [6]byte + Ino [8]byte + Mode [8]byte + UID [8]byte + GID [8]byte + NLink [8]byte + MTime [8]byte + FileSize [8]byte + DevMajor [8]byte + DevMinor [8]byte + RDevMajor [8]byte + RDevMinor [8]byte + NameSize [8]byte + Check [8]byte +} + +// cpioMagic is the magic number for the "newc" format. +var cpioMagic = []byte("070701") + +// cpioNameRe is a regex to parse filenames inside the CPIO archive. +var cpioNameRe = regexp.MustCompile(`^([^/]+)-([0-9a-f]{32})$`) + +// parseHex parses an 8-char hex string from a byte slice into an int64. +func parseHex(hexBytes []byte) (int64, error) { + return strconv.ParseInt(string(hexBytes), 16, 64) +} + +// ExtractCPIOStream reads a CPIO stream and extracts files to destDir. +func ExtractCPIOStream(stream io.Reader) ([]byte, error) { + for { + var fileContent []byte + var hdr CpioHeader + if err := binary.Read(stream, binary.LittleEndian, &hdr); err != nil { + if err == io.EOF { + return nil, fmt.Errorf("unexpected EOF while reading CPIO header") + } + return nil, fmt.Errorf("failed to read CPIO header: %w", err) + } + + if string(hdr.Magic[:]) != string(cpioMagic) { + return nil, fmt.Errorf("CPIO format magic %s not implemented", string(hdr.Magic[:])) + } + + nameSize, err := parseHex(hdr.NameSize[:]) + if err != nil { + return nil, fmt.Errorf("failed to parse name size: %w", err) + } + + fileSize, err := parseHex(hdr.FileSize[:]) + if err != nil { + return nil, fmt.Errorf("failed to parse file size: %w", err) + } + + // Read filename, which includes a null terminator. + nameBuf := make([]byte, nameSize) + if _, err := io.ReadFull(stream, nameBuf); err != nil { // The filename is null-terminated. + return nil, fmt.Errorf("failed to read filename: %w", err) + } + // The filename is null-terminated. + filename := strings.TrimRight(string(nameBuf), "\x00") + + // The new-ascii format has padding for 4-byte alignment after the header and filename. + // The total size of header + filename is len(hdr) + nameSize. + headerAndNameSize := binary.Size(hdr) + int(nameSize) + if padding := (4 - (headerAndNameSize % 4)) % 4; padding > 0 { + if _, err := io.CopyN(io.Discard, stream, int64(padding)); err != nil { // End of the CPIO archive. + return nil, fmt.Errorf("failed to read header padding: %w", err) + } + } + + if filename == "TRAILER!!!" { + // End of the CPIO archive. + break + } + + if filename == ".errors" { + content := make([]byte, fileSize) + if _, err := io.ReadFull(stream, content); err != nil { // Create a temporary file to download into. + return nil, fmt.Errorf("failed to read .errors content: %w", err) + } + return nil, fmt.Errorf("download has errors: %s", string(content)) + } + + if match := cpioNameRe.FindStringSubmatch(filename); match != nil { + fileContent = make([]byte, fileSize) + if _, err := io.ReadFull(stream, fileContent); err != nil { + return nil, fmt.Errorf("failed to read file content: %w", err) + } + + } else { + // For any other file, just discard its content. + LogInfo("Unhandled file %s in archive, discarding.", filename) + if _, err := io.CopyN(io.Discard, stream, fileSize); err != nil { + return nil, fmt.Errorf("failed to discard unhandled file content: %w", err) + } + } + + // The file data is also padded to a 4-byte boundary. + if padding := (4 - (fileSize % 4)) % 4; padding > 0 { + if _, err := io.CopyN(io.Discard, stream, int64(padding)); err != nil { + return nil, fmt.Errorf("failed to read file data padding: %w", err) + } + } + return fileContent, nil + } + + // Check for any trailing data. + if n, err := io.Copy(io.Discard, stream); err != nil && err != io.EOF { + return nil, fmt.Errorf("error reading trailing data: %w", err) + } else if n > 0 { + LogInfo("Warning: %d bytes of unexpected trailing data in CPIO stream", n) + } + + return nil, nil +} diff --git a/common/diff_parser.go b/common/diff_parser.go new file mode 100644 index 0000000..8795cc1 --- /dev/null +++ b/common/diff_parser.go @@ -0,0 +1,34 @@ +package common + +import ( + "path" + "regexp" + "strings" +) + +// ParseSubprojectChangesFromDiff parses a diff string and returns a slice of package names that have changed. +// It identifies subproject changes by looking for "Subproject commit" lines within diff chunks. +func ParseSubprojectChangesFromDiff(diff string) []string { + var changedPackages []string + + // This regex finds diff chunks for subprojects. + // It looks for a `diff --git` line, followed by lines indicating a subproject commit change. + re := regexp.MustCompile(`diff --git a\/(.+) b\/(.+)\n`) + + matches := re.FindAllStringSubmatch(diff, -1) + + for _, match := range matches { + if len(match) > 1 { + // The package path is in the first capturing group. + // We use path.Base to get just the package name from the path (e.g., "rpms/iptraf-ng" -> "iptraf-ng"). + pkgPath := strings.TrimSpace(match[1]) + basePath := path.Base(pkgPath) + // TODO: parse _manifest files to get real package names + if basePath != ".gitmodules" && basePath != "_config" && basePath != "" { + changedPackages = append(changedPackages, basePath) + } + } + } + + return changedPackages +} diff --git a/common/gitea_utils.go b/common/gitea_utils.go index c3358c9..c97fd2a 100644 --- a/common/gitea_utils.go +++ b/common/gitea_utils.go @@ -194,6 +194,7 @@ type Gitea interface { GetRecentPullRequests(org, repo, branch string) ([]*models.PullRequest, error) GetRecentCommits(org, repo, branch string, commitNo int64) ([]*models.Commit, error) GetPullRequests(org, project string) ([]*models.PullRequest, error) + GetPullRequestDiff(owner, repo string, index int64) (string, error) GetCurrentUser() (*models.User, error) } @@ -929,3 +930,19 @@ func (gitea *GiteaTransport) GetCurrentUser() (*models.User, error) { return user.GetPayload(), nil } + +func (gitea *GiteaTransport) GetPullRequestDiff(owner, repo string, index int64) (string, error) { + params := repository.NewRepoDownloadPullDiffOrPatchParams(). + WithDefaults(). + WithOwner(owner). + WithRepo(repo). + WithIndex(index). + WithDiffType("diff") + + result, err := gitea.client.Repository.RepoDownloadPullDiffOrPatch(params, gitea.transport.DefaultAuthentication) + if err != nil { + return "", err + } + + return result.Payload, nil +} diff --git a/common/obs_utils.go b/common/obs_utils.go index 817367e..3351ed4 100644 --- a/common/obs_utils.go +++ b/common/obs_utils.go @@ -630,6 +630,24 @@ type Binary struct { Mtime uint64 `xml:"mtime,attr"` } +type PackageBinariesList struct { + XMLName xml.Name `xml:"binarylist"` + Binary []Binary `xml:"binary"` +} + +// BinaryVersion represents a binary in a binaryversionlist response. +type BinaryVersion struct { + Name string `xml:"name,attr"` + SizeK string `xml:"sizek,attr"` + HdrMD5 string `xml:"hdrmd5,attr"` +} + +// BinaryVersionList represents a list of binaries from a binaryversionlist response. +type BinaryVersionList struct { + XMLName xml.Name `xml:"binaryversionlist"` + Binary []BinaryVersion `xml:"binary"` +} + type BinaryList struct { Package string `xml:"package,attr"` Binary []Binary `xml:"binary"` @@ -932,3 +950,142 @@ func (c *ObsClient) BuildStatusWithState(project string, opts *BuildResultOption } return ret, err } + +func parsePackageBinaries(data []byte) (*PackageBinariesList, error) { + result := PackageBinariesList{} + err := xml.Unmarshal(data, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func (c *ObsClient) GetBinariesFromPackage(project, repo, arch, pkg string) (*PackageBinariesList, error) { + req := []string{"build", project, repo, arch, pkg} + res, err := c.ObsRequest("GET", req, nil) + + if err != nil { + return nil, err + } + + switch res.StatusCode { + case 200: + break + case 404: + return nil, nil + default: + return nil, fmt.Errorf("Unexpected return code: %d %s %w", res.StatusCode, req, err) + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + defer res.Body.Close() + + result, err := parsePackageBinaries(data) + if err != nil { + return nil, err + } + + if result != nil { + result.Binary = slices.DeleteFunc(result.Binary, func(b Binary) bool { + return !strings.HasSuffix(b.Filename, ".rpm") + }) + } + return result, nil +} + +func (c *ObsClient) GetBinary(project, repo, arch, pkg, filename string) ([]byte, error) { + req := []string{"build", project, repo, arch, pkg, filename} + res, err := c.ObsRequest("GET", req, nil) + + if err != nil { + return nil, err + } + + switch res.StatusCode { + case 200: + break + case 404: + return nil, nil + default: + return nil, fmt.Errorf("Unexpected return code: %d %s %w", res.StatusCode, req, err) + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + defer res.Body.Close() + + return data, nil +} + +func parsePackageBinarieVersions(data []byte) (*BinaryVersionList, error) { + result := BinaryVersionList{} + err := xml.Unmarshal(data, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + +func (c *ObsClient) GetBinariesFromRepo(project, repo, arch string) (*BinaryVersionList, error) { + u := c.baseUrl.JoinPath("build", project, repo, arch, "_repository") + query := u.Query() + query.Add("view", "binaryversions") + query.Add("nometa", "1") + u.RawQuery = query.Encode() + res, err := c.ObsRequestRaw("GET", u.String(), nil) + if err != nil { + return nil, err + } + + switch res.StatusCode { + case 200: + break + case 404: + return nil, ObsProjectNotFound{project} + default: + return nil, fmt.Errorf("Unexpected return code: %d", res.StatusCode) + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + defer res.Body.Close() + + result, err := parsePackageBinarieVersions(data) + if err != nil { + return nil, err + } + + return result, nil +} + +func (c *ObsClient) GetBinaryHeader(project, repo, arch, filename string) ([]byte, error) { + u := c.baseUrl.JoinPath("build", project, repo, arch, "_repository") + query := u.Query() + query.Add("view", "cpioheaders") + query.Add("binary", strings.TrimSuffix(filename, ".rpm")) + u.RawQuery = query.Encode() + res, err := c.ObsRequestRaw("GET", u.String(), nil) + if err != nil { + return nil, err + } + + switch res.StatusCode { + case 200: + break + case 404: + return nil, ObsProjectNotFound{project} + default: + return nil, fmt.Errorf("Unexpected return code: %d", res.StatusCode) + } + + return ExtractCPIOStream(res.Body) +} diff --git a/obs-installcheck-bot/main.go b/obs-installcheck-bot/main.go new file mode 100644 index 0000000..909053b --- /dev/null +++ b/obs-installcheck-bot/main.go @@ -0,0 +1,441 @@ +package main + +import ( + "flag" + "fmt" + "os/exec" + "path/filepath" + "runtime/debug" + "strings" + "time" + + "os" + "regexp" + "strconv" + + "github.com/opentracing/opentracing-go/log" + "src.opensuse.org/autogits/common" + "src.opensuse.org/autogits/common/gitea-generated/models" +) + +var GiteaUrl string +var ObsClient *common.ObsClient +var ProcessPROnly string +var IsDryRun bool +var WaitForApprover string +var ListPullNotificationsOnly bool + +const SkipSrcRpm = true + +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 + } + + done, err := ProcessPullRequest(gitea, org, repo, num) + if !IsDryRun && done { + err = gitea.SetNotificationRead(thread.ID) + if err != nil { + common.LogError(err) + } else { + common.LogInfo("Notification marked as read") + } + } else if err != nil { + common.LogError(err) + } +} + +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) + } + } + } + } + } +} + +func addUniqueReviewComment(gitea common.Gitea, pr *models.PullRequest, state models.ReviewStateType, comment string) error { + reviews, err := gitea.GetPullRequestReviews(pr.Base.Repo.Owner.UserName, pr.Base.Repo.Name, pr.Index) + if err != nil { + return fmt.Errorf("failed to get pull request reviews: %w", err) + } + + botUser, err := gitea.GetCurrentUser() + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + + for _, review := range reviews { + if review.User != nil && review.User.UserName == botUser.UserName && review.Body == comment && review.State == common.ReviewStatePending { + common.LogInfo("Review comment already exists, skipping.") + return nil + } + } + + _, err = gitea.AddReviewComment(pr, state, comment) + return err +} + +func ProcessPullRequest(gitea common.Gitea, org, repo string, id int64) (bool, error) { + var done bool = false + + common.LogInfo("Processing PR", org+"/"+repo+"#"+strconv.FormatInt(id, 10)) + + pr, err := gitea.GetPullRequest(org, repo, id) + if err != nil { + common.LogError("No PR associated with review:", org, "/", repo, "#", id, "Error:", err) + done = false + return done, err + } + + if pr.State != "open" { + common.LogInfo("PR is not open, skipping.") + done = true + return done, nil + } + + if WaitForApprover != "" { + common.LogInfo("Waiting for approver:", WaitForApprover) + reviews, err := gitea.GetPullRequestReviews(org, repo, id) + if err != nil { + done = false + return done, fmt.Errorf("failed to get pull request reviews: %w", err) + } + + approved := false + for _, review := range reviews { + if review.User != nil && review.User.UserName == WaitForApprover && review.State == common.ReviewStateApproved { + approved = true + break + } + } + + if approved { + common.LogInfo("PR approved by", WaitForApprover) + } else { + common.LogInfo("PR not yet approved by", WaitForApprover+". Skipping for now.") + done = false + return done, nil // Not an error, just not ready to process. + } + } + + data, _, err := gitea.GetRepositoryFileContent(org, repo, pr.Head.Sha, common.StagingConfigFile) + if err != nil { + common.LogError("Cannot find 'staging.config' in PR#", pr.Index, ":", err) + err = addUniqueReviewComment(gitea, pr, common.ReviewStateRequestChanges, "Cannot find project config in PR: "+common.StagingConfigFile) + if err != nil { + common.LogError("Failed to add review comment:", err) + } + done = true + return done, err + } + + var config *common.StagingConfig + + config, err = common.ParseStagingConfig(data) + if err != nil { + common.LogDebug("Failed to parse staging config. Error:", err) + err = addUniqueReviewComment(gitea, pr, common.ReviewStateRequestChanges, "Failed to parse staging config.") + if err != nil { + common.LogError("Failed to add review comment:", err) + } + done = true + return done, fmt.Errorf("failed to parse staging config: %w", err) + } + + if config.StagingProject == "" || + config.Repositories == nil || len(config.Repositories) == 0 || + config.Installcheck.Repos == nil || len(config.Installcheck.Repos) == 0 { + common.LogError("Wrong staging config format in PR#", pr.Index) + err = addUniqueReviewComment(gitea, pr, common.ReviewStateRequestChanges, "Wrong staging config format.") + if err != nil { + common.LogError("Failed to add review comment:", err) + } + done = true + return done, fmt.Errorf("wrong staging config format in PR: %w", err) + } + + config.StagingProject = config.StagingProject + ":" + strconv.FormatInt(pr.Index, 10) + + common.LogInfo("Using staging project:", config.StagingProject) + for repoName, repoConfig := range config.Repositories { + common.LogInfo("Using OBS repository:", repoName, "architectures: ", repoConfig.Architectures) + } + for arch, repos := range config.Installcheck.Repos { + common.LogInfo("Using installcheck repos for architecture:", arch, ":", repos) + } + for arch, repos := range config.Installcheck.NoCheckRepos { + common.LogInfo("Using installcheck nocheck repos for architecture:", arch, ":", repos) + } + + dir, err := os.MkdirTemp(os.TempDir(), fmt.Sprintf("%s-%s-%d-", org, repo, id)) + common.PanicOnError(err) + if IsDryRun { + common.LogInfo("will keep temp directory:", dir) + } else { + defer os.RemoveAll(dir) + } + + diff, err := gitea.GetPullRequestDiff(org, repo, id) + common.LogDebug("PR Diff:\n", diff) + if err != nil { + common.LogError("Failed to get PR diff:", err) + done = false + return done, err + } + + changedPackages := common.ParseSubprojectChangesFromDiff(diff) + if len(changedPackages) > 0 { + common.LogInfo("Changed packages found in PR!") + + for repoName, repoConfig := range config.Repositories { + for _, arch := range repoConfig.Architectures { + archDir := filepath.Join(dir, repoName, arch) + err := os.MkdirAll(archDir, 0755) + common.PanicOnError(err) + common.LogInfo("Created temporary directory for", repo, "arch", arch, "at", archDir) + } + } + + var nameIgnoreRe = regexp.MustCompile(`-debug(info|source|info-32bit)\.rpm$`) + + for repoName, repoConfig := range config.Repositories { + for _, arch := range repoConfig.Architectures { + archDir := filepath.Join(dir, repoName, arch) + + var binaries *common.BinaryVersionList + binaries, err = ObsClient.GetBinariesFromRepo(config.StagingProject, repoName, arch) + if err != nil || binaries == nil { + common.LogError("Failed to get list of binaries:", err) + err = addUniqueReviewComment(gitea, pr, common.ReviewStateRequestChanges, "Failed to get list of binaries for repo '"+repoName+"' arch '"+arch+"': "+err.Error()) + if err != nil { + common.LogError("Failed to add review comment:", err) + } + done = true + return done, err + } + + var filtered_binaries []string + + if binaries != nil { + common.LogInfo("Binaries for repo ", repoName, " and arch", arch+":") + for _, bin := range binaries.Binary { + common.LogInfo(" -", bin.Name, "(md5:", bin.HdrMD5, "size:", bin.SizeK, ")") + if strings.HasSuffix(bin.Name, ".rpm") && !nameIgnoreRe.MatchString(bin.Name) { + filtered_binaries = append(filtered_binaries, bin.Name) + } + } + } + + if len(filtered_binaries) > 0 { + // Add check if all changed packages are present in the binaries + for _, changedPkg := range changedPackages { + found := false + for _, bin := range filtered_binaries { + if strings.HasPrefix(bin, changedPkg+"-") || bin == changedPkg+".rpm" { + common.LogDebug("Changed package", changedPkg, "found in binaries for repo", repoName, "arch", arch) + found = true + break + } + } + if !found { + common.LogError("Changed package", changedPkg, "NOT found in binaries for repo", repoName, "arch", arch) + // staging bot can approve the request even if some packages are missing + done = false + return done, nil + } + } + + // Download the filtered binaries + common.LogInfo("Downloading", len(filtered_binaries), "binaries for repo", repoName, "arch", arch) + for _, bin := range filtered_binaries { + common.LogInfo("Downloading binary header:", bin) + binaryData, err := ObsClient.GetBinaryHeader(config.StagingProject, repoName, arch, bin) + if err != nil { + common.LogError("Failed to download binary '"+bin+"':", err) + err = addUniqueReviewComment(gitea, pr, common.ReviewStateRequestChanges, "Failed to download binary '"+bin+"': "+err.Error()) + if err != nil { + common.LogError("Failed to add review comment:", err) + } + done = true + return done, err + } + if binaryData == nil { + common.LogInfo("Binary not found or is empty:", bin) + err = addUniqueReviewComment(gitea, pr, common.ReviewStateRequestChanges, "Binary not found or is empty: '"+bin+"'") + if err != nil { + common.LogError("Failed to add review comment:", err) + } + done = true + return done, err + } + filePath := filepath.Join(archDir, bin) + err = os.WriteFile(filePath, binaryData, 0644) + common.PanicOnError(err) + } + } + } + } + + var review_msg string = "" + var anyInstallcheckFailed bool = false + + for repoName, repoConfig := range config.Repositories { + for _, arch := range repoConfig.Architectures { + archDir := filepath.Join(dir, repoName, arch) + // Call createrepo_c for the architecture directory + common.LogInfo("Creating repository for architecture:", arch, "in", archDir) + cmd := exec.Command("createrepo_c", archDir) + output, err := cmd.CombinedOutput() + if err != nil { + common.LogError("Failed to create repository for '"+arch+"' in repo '"+repoName+"':", err, "Output:", string(output)) + } + + common.LogInfo("Running installcheck for architecture:", arch) + + var installcheckArgs []string + installcheckArgs = append(installcheckArgs, arch) + if reposToInclude, ok := config.Installcheck.Repos[arch]; ok { + installcheckArgs = append(installcheckArgs, reposToInclude...) + } + installcheckArgs = append(installcheckArgs, filepath.Join(archDir, "repodata", "*primary.xml.*")) + if reposToExclude, ok := config.Installcheck.NoCheckRepos[arch]; ok { + installcheckArgs = append(installcheckArgs, "--nocheck") + installcheckArgs = append(installcheckArgs, reposToExclude...) + } + installcheckCommand := "installcheck " + strings.Join(installcheckArgs, " ") + common.LogInfo("Running installcheck for repository "+repoName+" and architecture:", arch, "Command:", installcheckCommand) + installcheckCmd := exec.Command("bash", "-c", installcheckCommand) + installcheckOutput, installcheckErr := installcheckCmd.CombinedOutput() + if installcheckErr != nil { + common.LogError("Failed to run installcheck for repository '"+repoName+"' and architecture '"+arch+"':", installcheckErr, "Output:\n", string(installcheckOutput)) + anyInstallcheckFailed = true + review_msg += "Failed to run installcheck for repository '" + repoName + "' and architecture '" + arch + "'\n```" + string(installcheckOutput) + "```\n\n" + + } else { + common.LogInfo("Installcheck for repository '"+repoName+"' and architecture '"+arch+"' completed successfully. Output:", string(installcheckOutput)) + review_msg += "Installcheck for repository '" + repoName + "' and architecture '" + arch + "' completed successfully.\n\n" + } + } + } + + if !IsDryRun { + if anyInstallcheckFailed { + err = addUniqueReviewComment(gitea, pr, common.ReviewStateRequestChanges, review_msg) + } else { + err = addUniqueReviewComment(gitea, pr, common.ReviewStateApproved, review_msg) + } + if err != nil { + common.LogError("Failed to add review comment:", err) + } else { + common.LogInfo("Review comment added successfully") + } + done = true + } + + } else { + done = true + common.LogInfo("No subproject changes found in PR.") + + err = addUniqueReviewComment(gitea, pr, common.ReviewStateApproved, "No packages changed in the PR, nothing to check.") + if err != nil { + common.LogError("Failed to add review comment:", err) + } + } + + return done, nil +} + +func main() { + flag.BoolVar(&ListPullNotificationsOnly, "list-notifications-only", false, "Only lists notifications without acting on them") + flag.StringVar(&GiteaUrl, "gitea-url", "https://src.opensuse.org", "Gitea instance") + ProcessPROnly := flag.String("pr", "", "Process only specific PR and ignore the rest. Use for debugging") + obsApiHost := flag.String("obs", "https://api.opensuse.org", "API for OBS instance") // Keep this as it's for the OBS client itself + debug := flag.Bool("debug", false, "Turns on debug logging") // Keep this for logging control + flag.BoolVar(&IsDryRun, "dry", false, "Dry-run, don't actually post reviews") + flag.StringVar(&WaitForApprover, "wait-for-approver", "", "Username of the user whose approval is required before processing the PR") + flag.Parse() + + if *debug { + common.SetLoggingLevel(common.LogLevelDebug) + } else { + common.SetLoggingLevel(common.LogLevelInfo) + } + + common.LogDebug("OBS Gitea Host:", GiteaUrl) + 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(*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) + } +}