Files
kandev/apps/backend/internal/automation/evaluator.go
T

233 lines
6.0 KiB
Go

package automation
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/github"
)
const defaultGitHubPollInterval = 60 * time.Second
// GitHubEvaluator polls GitHub for events matching automation triggers.
// It handles github_pr, github_push, and github_ci trigger types.
type GitHubEvaluator struct {
svc *Service
githubService *github.Service
logger *logger.Logger
cancel context.CancelFunc
wg sync.WaitGroup
started bool
}
// NewGitHubEvaluator creates a new GitHub trigger evaluator.
func NewGitHubEvaluator(svc *Service, githubService *github.Service, log *logger.Logger) *GitHubEvaluator {
return &GitHubEvaluator{
svc: svc,
githubService: githubService,
logger: log,
}
}
// Start begins the polling loop.
func (e *GitHubEvaluator) Start(ctx context.Context) {
if e.started || e.githubService == nil {
return
}
e.started = true
ctx, e.cancel = context.WithCancel(ctx)
e.wg.Add(1)
go e.loop(ctx)
e.logger.Info("automation GitHub evaluator started")
}
// Stop cancels the polling loop and waits for it to finish.
func (e *GitHubEvaluator) Stop() {
if !e.started {
return
}
if e.cancel != nil {
e.cancel()
}
e.wg.Wait()
e.started = false
e.logger.Info("automation GitHub evaluator stopped")
}
func (e *GitHubEvaluator) loop(ctx context.Context) {
defer e.wg.Done()
e.evaluate(ctx)
ticker := time.NewTicker(defaultGitHubPollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
e.evaluate(ctx)
}
}
}
func (e *GitHubEvaluator) evaluate(ctx context.Context) {
e.evaluatePRTriggers(ctx)
}
func (e *GitHubEvaluator) evaluatePRTriggers(ctx context.Context) {
triggers, err := e.svc.Store().ListEnabledTriggersByType(ctx, TriggerTypeGitHubPR)
if err != nil {
e.logger.Error("failed to list github_pr triggers", zap.Error(err))
return
}
for i := range triggers {
t := &triggers[i]
e.checkPRTrigger(ctx, t)
}
}
func (e *GitHubEvaluator) checkPRTrigger(ctx context.Context, t *AutomationTrigger) {
var cfg GitHubPRTriggerConfig
if err := json.Unmarshal(t.Config, &cfg); err != nil {
e.logger.Debug("invalid github_pr trigger config",
zap.String("trigger_id", t.ID), zap.Error(err))
return
}
repos := cfg.Repos
if len(repos) == 0 {
return // Cannot poll without specific repos
}
automation, err := e.svc.GetAutomation(ctx, t.AutomationID)
if err != nil || automation == nil || automation.WorkspaceID == "" {
e.logger.Debug("automation trigger is missing workspace ownership",
zap.String("trigger_id", t.ID), zap.Error(err))
return
}
for _, repo := range repos {
// Use ListReviewRequestedPRs with a custom query scoped to this repo.
query := fmt.Sprintf("is:pr is:open repo:%s/%s", repo.Owner, repo.Name)
prs, err := e.githubService.ListAutomationPRs(
ctx, automation.WorkspaceID, repo.Owner, repo.Name, query,
)
if err != nil {
e.logger.Debug("failed to list PRs for automation trigger",
zap.String(automationRepoKey, repo.Owner+"/"+repo.Name),
zap.Error(err))
continue
}
for _, pr := range prs {
if pr == nil {
continue
}
if cfg.ExcludeDraft && pr.Draft {
continue
}
if !matchesBranches(pr.BaseBranch, cfg.Branches) {
continue
}
if !matchesAuthors(pr.AuthorLogin, cfg.Authors) {
continue
}
dedupKey := fmt.Sprintf("pr:%s/%s#%d", repo.Owner, repo.Name, pr.Number)
e.firePRTrigger(ctx, t, pr, dedupKey)
}
}
}
func (e *GitHubEvaluator) firePRTrigger(ctx context.Context, t *AutomationTrigger, pr *github.PR, dedupKey string) {
data, _ := json.Marshal(map[string]interface{}{
"number": pr.Number,
"title": pr.Title,
automationHTMLURLKey: pr.HTMLURL,
automationAuthorLoginKey: pr.AuthorLogin,
automationRepoKey: fmt.Sprintf("%s/%s", pr.RepoOwner, pr.RepoName),
automationHeadBranchKey: pr.HeadBranch,
automationBaseBranchKey: pr.BaseBranch,
automationBodyKey: pr.Body,
"draft": pr.Draft,
"state": pr.State,
})
if _, err := e.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeGitHubPR, data, dedupKey); err != nil {
e.logger.Error("failed to fire PR trigger",
zap.String("trigger_id", t.ID), zap.Error(err))
}
}
// matchesBranches checks if a branch matches any of the filter patterns.
// Empty filter means match all.
func matchesBranches(branch string, filters []string) bool {
if len(filters) == 0 {
return true
}
for _, f := range filters {
if matchGlob(f, branch) {
return true
}
}
return false
}
// matchesRepo checks if owner/name matches any entry in the repo filter list.
// An empty list never matches — webhook-driven push/CI triggers cannot act
// without knowing which repo(s) to scope to.
func matchesRepo(owner, name string, repos []github.RepoFilter) bool {
for _, r := range repos {
if r.Owner == owner && r.Name == name {
return true
}
}
return false
}
// matchesAuthors checks if an author matches the filter list.
// Empty filter means match all.
func matchesAuthors(author string, filters []string) bool {
return matchesFilterValue(author, filters)
}
// matchesFilterValue reports whether value equals one of the filters, treating
// an empty filter list as "match all". Used for exact-string filters such as
// PR authors, CI conclusions, and CI check names.
func matchesFilterValue(value string, filters []string) bool {
if len(filters) == 0 {
return true
}
for _, f := range filters {
if f == value {
return true
}
}
return false
}
// matchGlob performs simple glob matching (supports * wildcard).
func matchGlob(pattern, s string) bool {
if pattern == "*" {
return true
}
if pattern == s {
return true
}
// Handle trailing wildcard: "release/*" matches "release/v1"
if len(pattern) > 1 && pattern[len(pattern)-1] == '*' {
prefix := pattern[:len(pattern)-1]
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
return false
}