package main import ( "flag" "fmt" "log" "net/url" "regexp" "runtime/debug" "slices" "strconv" "strings" "time" "unicode" "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(newGroupName string) { groupName = newGroupName acceptRx = regexp.MustCompile("^:\\s*(LGTM|approved?)") rejectRx = regexp.MustCompile("^:\\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 } l := line[glen:] for idx, r := range l { if unicode.IsSpace(r) { continue } else if r == ':' { return true, l[idx:] } else { return false, line } } return false, line } 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 FindOurLastReviewInTimeline(timeline []*models.TimelineComment) *models.TimelineComment { for _, t := range timeline { if t.Type == common.TimelineCommentType_Review && t.User.UserName == groupName && t.Created == t.Updated { 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 } if err := ProcessPR(pr); err == nil && !common.IsDryRun { if err := gitea.SetNotificationRead(notification.ID); err != nil { common.LogDebug(" Cannot set notification as read", err) } } else if err != nil && err != ReviewNotFinished { common.LogError(err) } } var ReviewNotFinished = fmt.Errorf("Review is not finished") func ProcessPR(pr *models.PullRequest) error { org := pr.Base.Repo.Owner.UserName repo := pr.Base.Repo.Name id := pr.Index 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) return nil } config := configs.GetPrjGitConfig(org, repo, pr.Base.Name) if config == nil { return fmt.Errorf("Cannot find config for: %s", pr.URL) } if pr.State == "closed" { // dismiss the review common.LogInfo(" -- closed request, so nothing to review") return nil } reviews, err := gitea.GetPullRequestReviews(org, repo, id) if err != nil { return fmt.Errorf("Failed to fetch reviews for: %v: %w", pr.URL, err) } timeline, err := common.FetchTimelineSinceReviewRequestOrPush(gitea, groupName, pr.Head.Sha, org, repo, id) if err != nil { return fmt.Errorf("Failed to fetch timeline to review. %w", err) } groupConfig, err := config.GetReviewGroup(groupName) if err != nil { return fmt.Errorf("Failed to fetch review group. %w", err) } // submitter cannot be reviewer requestReviewers := groupConfig.Reviewers 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 { text := reviewer + " approved a review on behalf of " + groupName if review := FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text { _, err := gitea.AddReviewComment(pr, common.ReviewStateApproved, text) if err != nil { common.LogError(" -> failed to write approval comment", err) } UnrequestReviews(gitea, org, repo, id, requestReviewers) } } common.LogInfo(" -> approved by", reviewer) common.LogInfo(" review at", review.Created) return nil } else if ReviewRejected(review.Body) { if !common.IsDryRun { text := reviewer + " requested changes on behalf of " + groupName + ". See " + review.HTMLURL if review := FindOurLastReviewInTimeline(timeline); review == nil || review.Body != text { _, err := gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Changes requested. See review by: "+reviewer) if err != nil { common.LogError(" -> failed to write rejecting comment", err) } UnrequestReviews(gitea, org, repo, id, requestReviewers) } } common.LogInfo(" -> declined by", reviewer) return nil } } } // request group member reviews, if missing common.LogDebug(" Review incomplete...") if !groupConfig.Silent && 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, ", "), ".\n\n"+ "Do **not** use standard review interface to review on behalf of the group.\n"+ "To accept the review on behalf of the group, create the following comment: `@"+groupName+": approve`.\n"+ "To request changes on behalf of the group, create the following comment: `@"+groupName+": decline` followed with lines justifying the decision.\n"+ "Future edits of the comments are ignored, a new comment is required to change the review state.") if slices.Contains(groupConfig.Reviewers, pr.User.UserName) { helpComment = helpComment + "\n\n" + "Submitter is member of this review group, hence they are excluded from being one of the reviewers here" } gitea.AddComment(pr, helpComment) } return ReviewNotFinished } func PeriodReviewCheck() { 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) } } var gitea common.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", 10, "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), } process_issue_pr := IssueCommentProcessor{} configUpdates := &common.RabbitMQGiteaEventsProcessor{ Orgs: []string{}, Handlers: map[string]common.RequestProcessor{ common.RequestType_Push: &config_update, common.RequestType_IssueComment: &process_issue_pr, }, } configUpdates.Connection().RabbitURL = u for _, c := range configs { if org, _, _ := c.GetPrjGit(); !slices.Contains(configUpdates.Orgs, org) { configUpdates.Orgs = append(configUpdates.Orgs, org) } } go common.ProcessRabbitMQEvents(configUpdates) 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() time.Sleep(time.Duration(*interval * int64(time.Minute))) } }