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 }