reviews: use timeline and ignore reviews prior to last push

This commit is contained in:
2025-05-12 19:44:10 +02:00
parent 1498438fee
commit 24fe165c46
5 changed files with 289 additions and 43 deletions

View File

@@ -67,6 +67,10 @@ const (
ReviewStateUnknown models.ReviewStateType = "" ReviewStateUnknown models.ReviewStateType = ""
) )
type GiteaTimelineFetcher interface {
GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error)
}
type GiteaComment interface { type GiteaComment interface {
AddComment(pr *models.PullRequest, comment string) error AddComment(pr *models.PullRequest, comment string) error
} }
@@ -81,6 +85,10 @@ type GiteaPRFetcher interface {
GetAssociatedPrjGitPR(prjGitOrg, prjGitRepo, refOrg, refRepo string, Index int64) (*models.PullRequest, error) GetAssociatedPrjGitPR(prjGitOrg, prjGitRepo, refOrg, refRepo string, Index int64) (*models.PullRequest, error)
} }
type GiteaCommitFetcher interface {
GetCommit(org, repo, sha string) (*models.Commit, error)
}
type GiteaReviewFetcher interface { type GiteaReviewFetcher interface {
GetPullRequestReviews(org, project string, PRnum int64) ([]*models.PullReview, error) GetPullRequestReviews(org, project string, PRnum int64) ([]*models.PullReview, error)
} }
@@ -138,8 +146,10 @@ type Gitea interface {
GiteaReviewRequester GiteaReviewRequester
GiteaReviewer GiteaReviewer
GiteaPRFetcher GiteaPRFetcher
GiteaCommitFetcher
GiteaReviewFetcher GiteaReviewFetcher
GiteaCommentFetcher GiteaCommentFetcher
GiteaTimelineFetcher
GiteaMaintainershipReader GiteaMaintainershipReader
GiteaFileContentReader GiteaFileContentReader
GiteaCommitStatusGetter GiteaCommitStatusGetter
@@ -290,6 +300,25 @@ func (gitea *GiteaTransport) GetPullRequestReviews(org, project string, PRnum in
return allReviews, nil return allReviews, nil
} }
func (gitea *GiteaTransport) GetCommit(org, repo, sha string) (*models.Commit, error) {
f := false
r, err := gitea.client.Repository.RepoGetSingleCommit(
repository.NewRepoGetSingleCommitParams().
WithOwner(org).
WithRepo(repo).
WithSha(sha).
WithStat(&f).
WithFiles(&f).
WithVerification(&f),
gitea.transport.DefaultAuthentication)
if err != nil {
return nil, err
}
return r.Payload, nil
}
func (gitea *GiteaTransport) GetIssueComments(org, project string, issueNo int64) ([]*models.Comment, error) { func (gitea *GiteaTransport) GetIssueComments(org, project string, issueNo int64) ([]*models.Comment, error) {
// limit := int64(20) // limit := int64(20)
// var page int64 // var page int64
@@ -625,6 +654,41 @@ func (gitea *GiteaTransport) AddComment(pr *models.PullRequest, comment string)
return nil return nil
} }
func (gitea *GiteaTransport) GetTimeline(org, repo string, idx int64) ([]*models.TimelineComment, error) {
limit := int64(20)
page := int64(1)
resCount := limit
retData := []*models.TimelineComment{}
for resCount == limit {
res, err := gitea.client.Issue.IssueGetCommentsAndTimeline(
issue.NewIssueGetCommentsAndTimelineParams().
WithOwner(org).
WithRepo(repo).
WithIndex(idx).
WithPage(&page).
WithLimit(&limit),
gitea.transport.DefaultAuthentication,
)
if err != nil {
return nil, err
}
resCount = int64(len(res.Payload))
page++
retData = append(retData, res.Payload...)
}
slices.SortFunc(retData, func(a, b *models.TimelineComment) int {
return time.Time(a.Created).Compare(time.Time(b.Created))
})
return retData, nil
}
func (gitea *GiteaTransport) GetRepositoryFileContent(org, repo, hash, path string) ([]byte, string, error) { func (gitea *GiteaTransport) GetRepositoryFileContent(org, repo, hash, path string) ([]byte, string, error) {
params := repository.NewRepoGetContentsParams().WithOwner(org).WithRepo(repo).WithFilepath(path) params := repository.NewRepoGetContentsParams().WithOwner(org).WithRepo(repo).WithFilepath(path)
if len(hash) > 0 { if len(hash) > 0 {

View File

@@ -149,7 +149,7 @@ func (l *ListenDefinitions) processRabbitMQ(msgCh chan<- RabbitMessage) error {
} }
// log.Printf("queue: %s:%d", q.Name, q.Consumers) // log.Printf("queue: %s:%d", q.Name, q.Consumers)
LogInfo(" -- listening to topics:") LogDebug(" -- listening to topics:")
l.topicSubChanges = make(chan string) l.topicSubChanges = make(chan string)
defer close(l.topicSubChanges) defer close(l.topicSubChanges)
go l.processTopicChanges(ch, q.Name) go l.processTopicChanges(ch, q.Name)

View File

@@ -52,18 +52,38 @@ type LogLevel int
const ( const (
LogLevelNone = 0 LogLevelNone = 0
LogLevelError = 2
LogLevelInfo = 5 LogLevelInfo = 5
LogLevelDebug = 10 LogLevelDebug = 10
) )
var logLevel LogLevel var logLevel LogLevel = LogLevelInfo
func SetLoggingLevel(ll LogLevel) { func SetLoggingLevel(ll LogLevel) {
logLevel = ll logLevel = ll
} }
func SetLoggingLevelFromString(ll string) error {
switch ll {
case "info":
SetLoggingLevel(LogLevelInfo)
case "debug":
SetLoggingLevel(LogLevelDebug)
case "error":
SetLoggingLevel(LogLevelError)
case "none":
SetLoggingLevel(LogLevelNone)
default:
return fmt.Errorf("Unknown logging level: %s", ll)
}
return nil
}
func LogError(params ...any) { func LogError(params ...any) {
if logLevel >= LogLevelError {
log.Println(append([]any{"[E]"}, params...)...) log.Println(append([]any{"[E]"}, params...)...)
}
} }
func LogDebug(params ...any) { func LogDebug(params ...any) {

View File

@@ -1,10 +1,13 @@
package main package main
import ( import (
"encoding/json"
"flag" "flag"
"fmt"
"log" "log"
"net/url" "net/url"
"regexp" "regexp"
"runtime/debug"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -57,89 +60,191 @@ func ReviewRejected(reviewText string) bool {
func ProcessNotifications(notification *models.NotificationThread, gitea common.Gitea) { func ProcessNotifications(notification *models.NotificationThread, gitea common.Gitea) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
log.Println("--- resovered") 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/(?<num>[0-9]+)$`) 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 subject := notification.Subject
u, err := url.Parse(notification.Subject.URL) u, err := url.Parse(notification.Subject.URL)
if err != nil { if err != nil {
log.Panicln("Invalid format of notification: %s", subject.URL) common.LogError("Invalid format of notification:", subject.URL, err)
return
} }
match := rx.FindStringSubmatch(u.Path) match := rx.FindStringSubmatch(u.Path)
if match == nil { if match == nil {
log.Panicf("** Unexpected format of notification: %s", subject.URL) common.LogError("** Unexpected format of notification:", subject.URL)
return
} }
org := match[1] org := match[1]
repo := match[2] repo := match[2]
id, _ := strconv.ParseInt(match[3], 10, 64) id, _ := strconv.ParseInt(match[3], 10, 64)
log.Printf("processing: %s/%s#%d\n", org, repo, id) common.LogInfo("processing:", fmt.Sprintf("%s/%s#%d", org, repo, id))
pr, err := gitea.GetPullRequest(org, repo, id) pr, err := gitea.GetPullRequest(org, repo, id)
if err != nil { if err != nil {
log.Println(" ** Cannot fetch PR associated with review:", subject.URL, "Error:", err) common.LogError(" ** Cannot fetch PR associated with review:", subject.URL, "Error:", err)
return
}
commit, err := gitea.GetCommit(org, repo, pr.Head.Sha)
if err != nil {
common.LogError(" ** Cannot fetch head commit:", pr.Head.Sha, err)
return return
} }
config := configs.GetPrjGitConfig(org, repo, pr.Base.Name) config := configs.GetPrjGitConfig(org, repo, pr.Base.Name)
if pr.State == "closed" { if pr.State == "closed" {
// dismiss the review // dismiss the review
log.Println(" -- closed request, so nothing to review") common.LogInfo(" -- closed request, so nothing to review")
if !common.IsDryRun {
gitea.SetNotificationRead(notification.ID) gitea.SetNotificationRead(notification.ID)
}
return return
} }
reviews, err := gitea.GetPullRequestReviews(org, repo, id) reviews, err := gitea.GetPullRequestReviews(org, repo, id)
if err != nil { if err != nil {
log.Println(" ** No reviews associated with request:", subject.URL, "Error:", err) common.LogInfo(" ** No reviews associated with request:", subject.URL, "Error:", err)
return return
} }
timeline, err := gitea.GetTimeline(org, repo, id)
if err != nil {
common.LogError(err)
return
}
idx := len(timeline) - 1
//{"is_force_push":true,"commit_ids":["36e43509be1b13a1a8fc63a4361405de04cc621ab16935f88968c46193221bb6","732246a48fbc6bac9df16c0b0ca23ce0f6fbabd9990795863b6d1f0ef3f242c8"]}
type PullPushData struct {
IsForcePush bool `json:"is_force_push"`
CommitIds []string `json:"commit_ids"`
}
for ; idx > 0; idx-- {
e := timeline[idx]
if e.Type == "pull_push" {
var push PullPushData
if err := json.Unmarshal([]byte(e.Body), &push); err != nil {
common.LogError(err)
}
if slices.Contains(push.CommitIds, pr.Head.Sha) {
break
}
}
}
timeline = timeline[idx:]
for _, t := range timeline {
common.LogDebug(t.Created, "->", t.Updated, ":", t.Body)
common.LogDebug(" Type:", t.Type)
}
requestReviewers, err := config.GetReviewGroupMembers(groupName) requestReviewers, err := config.GetReviewGroupMembers(groupName)
if err != nil { if err != nil {
log.Println(err) common.LogError(err)
return return
} }
valid_review_ids := make([]int64, 0, len(timeline))
for _, t := range timeline {
if t.Type == "review" {
valid_review_ids = append(valid_review_ids, t.ReviewID)
}
}
// submitter cannot be reviewer // submitter cannot be reviewer
requestReviewers = slices.DeleteFunc(requestReviewers, func(u string) bool { return u == pr.User.UserName }) requestReviewers = slices.DeleteFunc(requestReviewers, func(u string) bool { return u == pr.User.UserName })
// pr.Head.Sha
common.LogDebug(" PR update time:", pr.Updated)
for _, review := range reviews { for _, review := range reviews {
user := review.User.UserName user := review.User.UserName
if !slices.Contains(valid_review_ids, review.ID) {
continue
}
submit_time := time.Time(review.Submitted)
/* pr_update := time.Time(pr.Updated)
*/
if !review.Stale && !review.Dismissed && if !review.Stale && !review.Dismissed &&
slices.Contains(requestReviewers, user) { slices.Contains(requestReviewers, user) {
/*
if review.CommitID != pr.Head.Sha {
common.LogDebug("review commitid", review.CommitID, "is not up-to-date with HEAD", pr.Head.Sha)
common.LogDebug("ignoring review by", review.User.UserName, "at", review.Submitted)
continue
}
*/
common.LogDebug("Created: ", commit.Created)
common.LogDebug("Submit: ", submit_time)
if submit_time.Compare(time.Time(commit.Created)) < 0 {
common.LogDebug(" ignoring. Review predates commit time. Review Submit:", submit_time, "created:", commit.Created)
continue
}
if review.State == common.ReviewStateApproved && ReviewAccepted(review.Body) { if review.State == common.ReviewStateApproved && ReviewAccepted(review.Body) {
if !common.IsDryRun {
gitea.AddReviewComment(pr, common.ReviewStateApproved, "Signed off by: "+user) gitea.AddReviewComment(pr, common.ReviewStateApproved, "Signed off by: "+user)
if !common.IsDryRun {
if err := gitea.SetNotificationRead(notification.ID); err != nil { if err := gitea.SetNotificationRead(notification.ID); err != nil {
log.Println(" Cannot set notification as read", err) common.LogDebug(" Cannot set notification as read", err)
} }
log.Println(" -> approved by", user) }
}
common.LogInfo(" -> approved by", user)
common.LogInfo(" review at", submit_time)
return return
} else if review.State == common.ReviewStateRequestChanges && ReviewRejected(review.Body) { } else if review.State == common.ReviewStateRequestChanges && ReviewRejected(review.Body) {
if !common.IsDryRun {
gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Request changes. See review by: "+user) gitea.AddReviewComment(pr, common.ReviewStateRequestChanges, "Request changes. See review by: "+user)
if err := gitea.SetNotificationRead(notification.ID); err != nil { if err := gitea.SetNotificationRead(notification.ID); err != nil {
log.Println(" Cannot set notification as read", err) common.LogDebug(" Cannot set notification as read", err)
} }
log.Println(" -> declined by", user) }
common.LogInfo(" -> declined by", user)
return return
} }
} }
} }
// request group member reviews, if missing // request group member reviews, if missing
log.Println(" Requesting reviews for:", requestReviewers) common.LogDebug(" Review incomplete...")
requestReviewers = slices.DeleteFunc(requestReviewers, func(user string) bool {
for _, r := range pr.RequestedReviewers {
if r.UserName == user {
return true
}
}
return false
})
if len(requestReviewers) > 0 {
common.LogDebug(" Requesting reviews for:", requestReviewers)
if !common.IsDryRun {
if _, err := gitea.RequestReviews(pr, requestReviewers...); err != nil { if _, err := gitea.RequestReviews(pr, requestReviewers...); err != nil {
log.Println(" -> err:", err) common.LogDebug(" -> err:", err)
} else {
common.LogDebug(" ^^^ not done")
}
}
} else {
common.LogDebug(" Not requesting additional reviewers:", requestReviewers)
} }
} }
func PeriodReviewCheck(gitea common.Gitea) { func PeriodReviewCheck(gitea common.Gitea) {
notifications, err := gitea.GetPullNotifications(nil) notifications, err := gitea.GetPullNotifications(nil)
if err != nil { if err != nil {
log.Println(" EEE Error fetching unread notifications: %w", err) common.LogError(" Error fetching unread notifications: %w", err)
return return
} }
@@ -154,6 +259,8 @@ func main() {
rabbitMqHost := flag.String("rabbit-host", "rabbit.opensuse.org", "RabbitMQ instance where Gitea webhook notifications are sent") rabbitMqHost := flag.String("rabbit-host", "rabbit.opensuse.org", "RabbitMQ instance where Gitea webhook notifications are sent")
interval := flag.Int64("interval", 5, "Notification polling interval in minutes (min 1 min)") interval := flag.Int64("interval", 5, "Notification polling interval in minutes (min 1 min)")
configFile := flag.String("config", "", "PrjGit listing config file") 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() flag.Parse()
args := flag.Args() args := flag.Args()
@@ -166,29 +273,43 @@ func main() {
} }
groupName = args[0] groupName = args[0]
if *configFile == "" {
common.LogError("Missing config file")
return
}
configData, err := common.ReadConfigFile(*configFile) configData, err := common.ReadConfigFile(*configFile)
if err != nil { if err != nil {
log.Println("Failed to read config file", err) common.LogError("Failed to read config file", err)
return return
} }
if err := common.RequireGiteaSecretToken(); err != nil { if err := common.RequireGiteaSecretToken(); err != nil {
log.Panicln(err) common.LogError(err)
return
} }
if err := common.RequireRabbitSecrets(); err != nil { if err := common.RequireRabbitSecrets(); err != nil {
log.Panicln(err) common.LogError(err)
return
} }
gitea := common.AllocateGiteaTransport(*giteaUrl) gitea := common.AllocateGiteaTransport(*giteaUrl)
configs, err = common.ResolveWorkflowConfigs(gitea, configData) configs, err = common.ResolveWorkflowConfigs(gitea, configData)
if err != nil { if err != nil {
log.Panicln(err) common.LogError("Cannot parse workflow configs:", err)
return
} }
reviewer, err := gitea.GetCurrentUser() reviewer, err := gitea.GetCurrentUser()
if err != nil { if err != nil {
log.Panicln("Cannot fetch review user: %w", err) common.LogError("Cannot fetch review user:", err)
return
}
if err := common.SetLoggingLevelFromString(*logging); err != nil {
common.LogError(err.Error())
return
} }
if *interval < 1 { if *interval < 1 {
@@ -197,31 +318,66 @@ func main() {
InitRegex(groupName) InitRegex(groupName)
log.Println(" ** processing group reviews for group:", groupName) common.LogInfo(" ** processing group reviews for group:", groupName)
log.Println(" ** username in Gitea:", reviewer.UserName) common.LogInfo(" ** username in Gitea:", reviewer.UserName)
log.Println(" ** polling internval:", *interval, "min") common.LogInfo(" ** polling interval:", *interval, "min")
log.Println(" ** connecting to RabbitMQ:", *rabbitMqHost) common.LogInfo(" ** connecting to RabbitMQ:", *rabbitMqHost)
if groupName != reviewer.UserName { if groupName != reviewer.UserName {
log.Println(" ***** Reviewer does not match group name. Aborting. *****") common.LogError(" ***** Reviewer does not match group name. Aborting. *****")
return return
} }
u, err := url.Parse("amqps://" + *rabbitMqHost) u, err := url.Parse("amqps://" + *rabbitMqHost)
if err != nil { if err != nil {
log.Panicln("Cannot parse RabbitMQ host:", err) common.LogError("Cannot parse RabbitMQ host:", err)
return
} }
configUpdates := &common.ListenDefinitions { config_update := ConfigUpdatePush{
config_modified: make(chan *common.AutogitConfig),
}
configUpdates := &common.ListenDefinitions{
RabbitURL: u, RabbitURL: u,
Orgs: []string{}, Orgs: []string{},
Handlers: map[string]common.RequestProcessor { Handlers: map[string]common.RequestProcessor{
common.RequestType_Push: &ConfigUpdatePush{}, 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() go configUpdates.ProcessRabbitMQEvents()
for { 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) PeriodReviewCheck(gitea)
time.Sleep(time.Duration(*interval * int64(time.Minute))) time.Sleep(time.Duration(*interval * int64(time.Minute)))
} }

View File

@@ -7,8 +7,8 @@ import (
"src.opensuse.org/autogits/common" "src.opensuse.org/autogits/common"
) )
type ConfigUpdatePush struct{ type ConfigUpdatePush struct {
config_modified chan *common.AutogitConfig
} }
func (s *ConfigUpdatePush) ProcessFunc(req *common.Request) error { func (s *ConfigUpdatePush) ProcessFunc(req *common.Request) error {
@@ -44,7 +44,13 @@ func (s *ConfigUpdatePush) ProcessFunc(req *common.Request) error {
slices.Contains(commit.Removed, common.ProjectConfigFile) slices.Contains(commit.Removed, common.ProjectConfigFile)
} }
s.config_modified <- if modified_config {
for _, config := range configs {
if o, r, _ := config.GetPrjGit(); o == org && r == repo {
s.config_modified <- config
}
}
}
return nil return nil
} }