418 lines
11 KiB
Go
418 lines
11 KiB
Go
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 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/(?<org>[_a-zA-Z0-9-]+)/(?<project>[_a-zA-Z0-9-]+)/(?:issues|pulls)/(?<num>[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] <review-group-name>")
|
|
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)))
|
|
}
|
|
}
|