289 lines
8.3 KiB
Go
289 lines
8.3 KiB
Go
package automation
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/github"
|
|
)
|
|
|
|
// GitHubPRMergedSubscriber subscribes to github.task_pr.updated events and
|
|
// fires github_pr_merged automation triggers. The subscriber enforces all
|
|
// per-event gates (payload validity, merged state, repo identity, task
|
|
// lookup) and carries a per-event fired-key set to guarantee exactly one
|
|
// run per automation per event when multiple triggers match.
|
|
type GitHubPRMergedSubscriber struct {
|
|
svc *Service
|
|
eventBus bus.EventBus
|
|
logger *logger.Logger
|
|
|
|
sub bus.Subscription
|
|
started bool
|
|
}
|
|
|
|
// NewGitHubPRMergedSubscriber creates a new subscriber.
|
|
func NewGitHubPRMergedSubscriber(svc *Service, eventBus bus.EventBus, log *logger.Logger) *GitHubPRMergedSubscriber {
|
|
return &GitHubPRMergedSubscriber{svc: svc, eventBus: eventBus, logger: log}
|
|
}
|
|
|
|
// Start subscribes to the PR-updated bus event. Idempotent; a second Start
|
|
// on an already-started subscriber is a no-op and emits neither log line.
|
|
func (s *GitHubPRMergedSubscriber) Start(_ context.Context) {
|
|
if s.started {
|
|
return
|
|
}
|
|
|
|
if s.eventBus == nil {
|
|
s.started = true
|
|
return
|
|
}
|
|
|
|
sub, err := s.eventBus.Subscribe(events.GitHubTaskPRUpdated, s.handlePRUpdated)
|
|
if err != nil {
|
|
s.logger.Error("failed to subscribe to github.task_pr.updated; github_pr_merged triggers will not fire",
|
|
zap.Error(err))
|
|
return
|
|
}
|
|
s.sub = sub
|
|
s.started = true
|
|
if s.svc.TaskOriginLookup() == nil {
|
|
s.logger.Warn("github_pr_merged trigger will not fire until a task-origin lookup is wired",
|
|
zap.String("trigger_type", string(TriggerTypeGitHubPRMerged)))
|
|
} else {
|
|
s.logger.Info("github_pr_merged subscription active")
|
|
}
|
|
}
|
|
|
|
// Stop unsubscribes from the bus. Idempotent.
|
|
func (s *GitHubPRMergedSubscriber) Stop() {
|
|
if !s.started {
|
|
return
|
|
}
|
|
if s.sub != nil {
|
|
_ = s.sub.Unsubscribe()
|
|
s.sub = nil
|
|
}
|
|
s.started = false
|
|
s.logger.Info("github_pr_merged subscriber stopped")
|
|
}
|
|
|
|
// firedKey is the per-event in-memory set element that prevents the same
|
|
// automation from firing more than once per event when it has multiple
|
|
// matching triggers.
|
|
type firedKey struct {
|
|
automationID string
|
|
dedupKey string
|
|
}
|
|
|
|
func (s *GitHubPRMergedSubscriber) handlePRUpdated(ctx context.Context, event *bus.Event) error {
|
|
// Gate 1: payload must be a typed TaskPR or its JSON-decoded bus form.
|
|
pr, ok := normalizeTaskPR(event.Data)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
// Gate 2: task id must be non-empty.
|
|
if pr.TaskID == "" {
|
|
return nil
|
|
}
|
|
|
|
// Gate 3: state must equal "merged" (case-insensitive, trimmed).
|
|
if !strings.EqualFold(strings.TrimSpace(pr.State), "merged") {
|
|
return nil
|
|
}
|
|
|
|
// Gate 4: owner and repo must both be non-empty.
|
|
if pr.Owner == "" || pr.Repo == "" {
|
|
return nil
|
|
}
|
|
|
|
// Gate 5: PR number must be positive.
|
|
if pr.PRNumber <= 0 {
|
|
return nil
|
|
}
|
|
|
|
// Gate 6: resolve the task — once per event, reused for every trigger.
|
|
lookup := s.svc.TaskOriginLookup()
|
|
if lookup == nil {
|
|
return nil
|
|
}
|
|
workspaceID, isAutomationRun, resolved := lookup.TaskWorkspaceAndAutomationOrigin(ctx, pr.TaskID)
|
|
if !resolved {
|
|
return nil
|
|
}
|
|
|
|
// Gate 7: the task must not be an automation run (loop guard).
|
|
if isAutomationRun {
|
|
return nil
|
|
}
|
|
|
|
// List all enabled github_pr_merged triggers.
|
|
triggers, err := s.svc.Store().ListEnabledTriggersByType(ctx, TriggerTypeGitHubPRMerged)
|
|
if err != nil {
|
|
s.logger.Error("failed to list github_pr_merged triggers", zap.Error(err))
|
|
return nil
|
|
}
|
|
|
|
// Per-event fired-key set: (automation_id, dedup_key) pairs already
|
|
// handed to FireTrigger during this event. Prevents a second trigger on
|
|
// the same automation from duplicating the run.
|
|
fired := make(map[firedKey]struct{})
|
|
|
|
for i := range triggers {
|
|
s.checkMergedTrigger(ctx, &triggers[i], pr, workspaceID, fired)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// normalizeTaskPR accepts both event representations used by the configured
|
|
// event buses. The in-memory bus preserves the typed pointer, while NATS
|
|
// decodes JSON data into an untyped map before invoking subscribers.
|
|
func normalizeTaskPR(data interface{}) (*github.TaskPR, bool) {
|
|
switch pr := data.(type) {
|
|
case *github.TaskPR:
|
|
return pr, pr != nil
|
|
case github.TaskPR:
|
|
return &pr, true
|
|
default:
|
|
encoded, err := json.Marshal(data)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
var decoded github.TaskPR
|
|
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
|
return nil, false
|
|
}
|
|
return &decoded, true
|
|
}
|
|
}
|
|
|
|
func (s *GitHubPRMergedSubscriber) checkMergedTrigger(
|
|
ctx context.Context,
|
|
t *AutomationTrigger,
|
|
pr *github.TaskPR,
|
|
resolvedWorkspaceID string,
|
|
fired map[firedKey]struct{},
|
|
) {
|
|
// Parse trigger config; skip on error.
|
|
var cfg GitHubPRMergedTriggerConfig
|
|
if err := json.Unmarshal(t.Config, &cfg); err != nil {
|
|
s.logger.Debug("invalid github_pr_merged trigger config",
|
|
zap.String("trigger_id", t.ID), zap.Error(err))
|
|
return
|
|
}
|
|
|
|
// Gate 8: workspace must match (lookup's workspace is authoritative).
|
|
a, err := s.svc.GetAutomation(ctx, t.AutomationID)
|
|
if err != nil || a == nil || a.WorkspaceID == "" {
|
|
s.logger.Debug("could not load automation for github_pr_merged trigger",
|
|
zap.String("trigger_id", t.ID), zap.Error(err))
|
|
return
|
|
}
|
|
if a.WorkspaceID != resolvedWorkspaceID {
|
|
return
|
|
}
|
|
|
|
// Gate 9: repository filter.
|
|
if !matchesMergedRepo(pr.Owner, pr.Repo, cfg) {
|
|
return
|
|
}
|
|
|
|
// Gate 10: base-branch filter (empty list = match all; trim and drop empties at match time).
|
|
if !matchesMergedBranches(pr.BaseBranch, cfg.BaseBranches) {
|
|
return
|
|
}
|
|
|
|
// Build the dedup key.
|
|
dedupKey := fmt.Sprintf("pr_merged:%s:%s/%s#%d",
|
|
pr.TaskID,
|
|
strings.ToLower(pr.Owner),
|
|
strings.ToLower(pr.Repo),
|
|
pr.PRNumber,
|
|
)
|
|
|
|
// Gate 11: per-event fired-key set.
|
|
fk := firedKey{automationID: t.AutomationID, dedupKey: dedupKey}
|
|
if _, alreadyFired := fired[fk]; alreadyFired {
|
|
return
|
|
}
|
|
fired[fk] = struct{}{}
|
|
|
|
// Build the trigger data map (narrow, no free-form prose).
|
|
var mergedAt string
|
|
if pr.MergedAt != nil {
|
|
mergedAt = pr.MergedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
data, _ := json.Marshal(map[string]interface{}{
|
|
"task_id": pr.TaskID,
|
|
automationRepoKey: fmt.Sprintf("%s/%s", pr.Owner, pr.Repo),
|
|
"pr_number": pr.PRNumber,
|
|
"pr_url": pr.PRURL,
|
|
automationBaseBranchKey: pr.BaseBranch,
|
|
"merged_at": mergedAt,
|
|
})
|
|
|
|
// Pass the dedup key so FireTrigger's store-level check prevents duplicate
|
|
// runs for the same PR event. If the concurrent-run cap is reached inside
|
|
// FireTrigger, the skipped-run record is written with an empty key so the
|
|
// dedup slot is not consumed (see maybeSkipForConcurrencyCap).
|
|
if _, err := s.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeGitHubPRMerged, data, dedupKey); err != nil {
|
|
s.logger.Error("failed to fire github_pr_merged trigger",
|
|
zap.String("trigger_id", t.ID), zap.Error(err))
|
|
}
|
|
}
|
|
|
|
// matchesMergedRepo implements the repository-matching semantics for
|
|
// github_pr_merged, which differ from the shared matchesRepo helper:
|
|
// - all_repos=true: every repository matches.
|
|
// - all_repos=false + non-empty list: match by owner (case-insensitive) and
|
|
// name (case-insensitive, empty name = org wildcard).
|
|
// - all_repos=false + empty list: never matches.
|
|
func matchesMergedRepo(owner, name string, cfg GitHubPRMergedTriggerConfig) bool {
|
|
if cfg.AllRepos {
|
|
return true
|
|
}
|
|
for _, r := range cfg.Repos {
|
|
if r.Owner == "" {
|
|
// No owner → matches nothing.
|
|
continue
|
|
}
|
|
if !strings.EqualFold(r.Owner, owner) {
|
|
continue
|
|
}
|
|
// Empty name = organization wildcard: any name under this owner.
|
|
if r.Name == "" || strings.EqualFold(r.Name, name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchesMergedBranches checks whether baseBranch matches the filter list,
|
|
// trimming entries and dropping empties before matching. An empty list after
|
|
// that normalization (originally empty or all-empty entries) matches everything.
|
|
func matchesMergedBranches(baseBranch string, filters []string) bool {
|
|
var effective []string
|
|
for _, f := range filters {
|
|
if t := strings.TrimSpace(f); t != "" {
|
|
effective = append(effective, t)
|
|
}
|
|
}
|
|
if len(effective) == 0 {
|
|
return true
|
|
}
|
|
for _, f := range effective {
|
|
if matchGlob(f, baseBranch) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|