package main import ( "flag" "fmt" "log" "net/url" "regexp" "runtime/debug" "slices" "strconv" "strings" "time" "src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common/gitea-generated/models" ) var configs common.AutogitConfigs var acceptRx *regexp.Regexp var rejectRx *regexp.Regexp var groupName string func InitRegex(groupName string) { acceptRx = regexp.MustCompile("\\s*:\\s*LGTM") rejectRx = regexp.MustCompile("\\s*:\\s*") } func ParseReviewLine(reviewText string) (bool, string) { line := strings.TrimSpace(reviewText) groupTextName := "@" + groupName glen := len(groupTextName) if len(line) < glen || line[0:glen] != groupTextName { return false, line } return true, line[glen:] } func ReviewAccepted(reviewText string) bool { for _, line := range common.SplitStringNoEmpty(reviewText, "\n") { if matched, reviewLine := ParseReviewLine(line); matched { return acceptRx.MatchString(reviewLine) } } return false } func ReviewRejected(reviewText string) bool { for _, line := range common.SplitStringNoEmpty(reviewText, "\n") { if matched, reviewLine := ParseReviewLine(line); matched { if rejectRx.MatchString(reviewLine) { return !acceptRx.MatchString(reviewLine) } } } return false } /* comment types - from gitea models/issues/comment.go var commentStrings = []string{ "comment", "reopen", "close", "issue_ref", "commit_ref", "comment_ref", "pull_ref", "label", "milestone", "assignees", "change_title", "delete_branch", "start_tracking", "stop_tracking", "add_time_manual", "cancel_tracking", "added_deadline", "modified_deadline", "removed_deadline", "add_dependency", "remove_dependency", "code", "review", "lock", "unlock", "change_target_branch", "delete_time_manual", "review_request", "merge_pull", "pull_push", "project", "project_board", // FIXME: the name should be project_column "dismiss_review", "change_issue_ref", "pull_scheduled_merge", "pull_cancel_scheduled_merge", "pin", "unpin", "change_time_estimate", }*/ func FindAcceptableReviewInTimeline(user string, timeline []*models.TimelineComment, reviews []*models.PullReview) *models.TimelineComment { for _, t := range timeline { if t.Type == common.TimelineCommentType_Comment && t.User.UserName == user && t.Created == t.Updated { if ReviewAccepted(t.Body) || ReviewRejected(t.Body) { return t } } } return nil } func UnrequestReviews(gitea common.Gitea, org, repo string, id int64, users []string) { if err := gitea.UnrequestReview(org, repo, id, users...); err != nil { common.LogError("Can't remove reviewrs after a review:", err) } } func ProcessNotifications(notification *models.NotificationThread, gitea common.Gitea) { defer func() { if r := recover(); r != nil { common.LogInfo("panic cought --- recovered") common.LogError(string(debug.Stack())) } }() rx := regexp.MustCompile(`^/?api/v\d+/repos/(?[_a-zA-Z0-9-]+)/(?[_a-zA-Z0-9-]+)/(?:issues|pulls)/(?[0-9]+)$`) subject := notification.Subject u, err := url.Parse(notification.Subject.URL) if err != nil { common.LogError("Invalid format of notification:", subject.URL, err) return } match := rx.FindStringSubmatch(u.Path) if match == nil { common.LogError("** Unexpected format of notification:", subject.URL) return } org := match[1] repo := match[2] id, _ := strconv.ParseInt(match[3], 10, 64) common.LogInfo("processing:", fmt.Sprintf("%s/%s#%d", org, repo, id)) pr, err := gitea.GetPullRequest(org, repo, id) if err != nil { common.LogError(" ** Cannot fetch PR associated with review:", subject.URL, "Error:", err) return } found := false for _, reviewer := range pr.RequestedReviewers { if reviewer != nil && reviewer.UserName == groupName { found = true break } } if !found { common.LogInfo(" review is not requested for", groupName) if !common.IsDryRun { gitea.SetNotificationRead(notification.ID) } return } config := configs.GetPrjGitConfig(org, repo, pr.Base.Name) if config == nil { common.LogError("Cannot find config for:", fmt.Sprintf("%s/%s#%s", org, repo, pr.Base.Name)) return } if pr.State == "closed" { // dismiss the review common.LogInfo(" -- closed request, so nothing to review") if !common.IsDryRun { gitea.SetNotificationRead(notification.ID) } return } reviews, err := gitea.GetPullRequestReviews(org, repo, id) if err != nil { common.LogInfo(" ** No reviews associated with request:", subject.URL, "Error:", err) return } timeline, err := common.FetchTimelineSinceReviewRequestOrPush(gitea, groupName, pr.Head.Sha, org, repo, id) if err != nil { common.LogError(err) return } requestReviewers, err := config.GetReviewGroupMembers(groupName) if err != nil { common.LogError(err) return } // submitter cannot be reviewer requestReviewers = slices.DeleteFunc(requestReviewers, func(u string) bool { return u == pr.User.UserName }) // pr.Head.Sha for _, reviewer := range requestReviewers { if review := FindAcceptableReviewInTimeline(reviewer, timeline, reviews); review != nil { if ReviewAccepted(review.Body) { if !common.IsDryRun { gitea.AddReviewComment(pr, common.ReviewStateApproved, "Signed off by: "+reviewer) UnrequestReviews(gitea, org, repo, id, requestReviewers) if !common.IsDryRun { if err := gitea.SetNotificationRead(notification.ID); err != nil { common.LogDebug(" Cannot set notification as read", err) } } } common.LogInfo(" -> approved by", reviewer) common.LogInfo(" review at", review.Created) return } else if ReviewRejected(review.Body) { if !common.IsDryRun { gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Changes requested. See review by: "+reviewer) UnrequestReviews(gitea, org, repo, id, requestReviewers) if err := gitea.SetNotificationRead(notification.ID); err != nil { common.LogDebug(" Cannot set notification as read", err) } } common.LogInfo(" -> declined by", reviewer) return } } } // request group member reviews, if missing common.LogDebug(" Review incomplete...") if len(requestReviewers) > 0 { common.LogDebug(" Requesting reviews for:", requestReviewers) if !common.IsDryRun { if _, err := gitea.RequestReviews(pr, requestReviewers...); err != nil { common.LogDebug(" -> err:", err) } } else { common.LogDebug(" ^^^ not done") } } else { common.LogDebug(" Not requesting additional reviewers") } // add a helpful comment, if not yet added found_help_comment := false for _, t := range timeline { if t.Type == common.TimelineCommentType_Comment && t.User != nil && t.User.UserName == groupName { found_help_comment = true break } } if !found_help_comment && !common.IsDryRun { helpComment := fmt.Sprintln("Review by", groupName, "represents a group of reviewers:", strings.Join(requestReviewers, ", "), ". To review as part of this group, create a comment with contents @"+groupName+": LGTM on a separate line to accept a review. To request changes, write @"+groupName+": followed by reason for rejection. Do not use reviews to review as a group. Editing a comment invalidates that comment.") gitea.AddComment(pr, helpComment) } } func PeriodReviewCheck(gitea common.Gitea) { notifications, err := gitea.GetNotifications(common.GiteaNotificationType_Pull, nil) if err != nil { common.LogError(" Error fetching unread notifications: %w", err) return } for _, notification := range notifications { ProcessNotifications(notification, gitea) } } func main() { giteaUrl := flag.String("gitea-url", "https://src.opensuse.org", "Gitea instance used for reviews") rabbitMqHost := flag.String("rabbit-url", "amqps://rabbit.opensuse.org", "RabbitMQ instance where Gitea webhook notifications are sent") interval := flag.Int64("interval", 5, "Notification polling interval in minutes (min 1 min)") configFile := flag.String("config", "", "PrjGit listing config file") logging := flag.String("logging", "info", "Logging level: [none, error, info, debug]") flag.BoolVar(&common.IsDryRun, "dry", false, "Dry run, no effect. For debugging") flag.Parse() args := flag.Args() if len(args) != 1 { log.Println(" syntax:") log.Println(" group-review [OPTIONS] ") log.Println() flag.Usage() return } groupName = args[0] if *configFile == "" { common.LogError("Missing config file") return } configData, err := common.ReadConfigFile(*configFile) if err != nil { common.LogError("Failed to read config file", err) return } if err := common.RequireGiteaSecretToken(); err != nil { common.LogError(err) return } if err := common.RequireRabbitSecrets(); err != nil { common.LogError(err) return } gitea := common.AllocateGiteaTransport(*giteaUrl) configs, err = common.ResolveWorkflowConfigs(gitea, configData) if err != nil { common.LogError("Cannot parse workflow configs:", err) return } reviewer, err := gitea.GetCurrentUser() if err != nil { common.LogError("Cannot fetch review user:", err) return } if err := common.SetLoggingLevelFromString(*logging); err != nil { common.LogError(err.Error()) return } if *interval < 1 { *interval = 1 } InitRegex(groupName) common.LogInfo(" ** processing group reviews for group:", groupName) common.LogInfo(" ** username in Gitea:", reviewer.UserName) common.LogInfo(" ** polling interval:", *interval, "min") common.LogInfo(" ** connecting to RabbitMQ:", *rabbitMqHost) if groupName != reviewer.UserName { common.LogError(" ***** Reviewer does not match group name. Aborting. *****") return } u, err := url.Parse(*rabbitMqHost) if err != nil { common.LogError("Cannot parse RabbitMQ host:", err) return } config_update := ConfigUpdatePush{ config_modified: make(chan *common.AutogitConfig), } configUpdates := &common.ListenDefinitions{ RabbitURL: u, Orgs: []string{}, Handlers: map[string]common.RequestProcessor{ common.RequestType_Push: &config_update, }, } for _, c := range configs { if org, _, _ := c.GetPrjGit(); !slices.Contains(configUpdates.Orgs, org) { configUpdates.Orgs = append(configUpdates.Orgs, org) } } go configUpdates.ProcessRabbitMQEvents() for { config_update_loop: for { select { case configTouched, ok := <-config_update.config_modified: if ok { for idx, c := range configs { if c == configTouched { org, repo, branch := c.GetPrjGit() prj := fmt.Sprintf("%s/%s#%s", org, repo, branch) common.LogInfo("Detected config update for", prj) new_config, err := common.ReadWorkflowConfig(gitea, prj) if err != nil { common.LogError("Failed parsing Project config for", prj, err) } else { configs[idx] = new_config } } } } default: break config_update_loop } } PeriodReviewCheck(gitea) time.Sleep(time.Duration(*interval * int64(time.Minute))) } }