// Package git implements the "git" trigger: matches inbound git push or // tag-create events from Gitea, GitHub, or GitLab against a repo + ref // filter. package git import ( "context" "encoding/json" "fmt" "path" "strings" "time" "github.com/alexei/tinyforge/internal/workload/plugin" ) // Config is the per-workload trigger config. Repo is "owner/name" (must // match the event repo). Mode controls whether branch pushes or tag // pushes fire the deploy. Branch is exact-matched when Mode=="push"; // TagPattern is glob-matched when Mode=="tag". // // BranchPattern is the preview-deploy escape hatch: when non-empty in // "push" mode it overrides Branch and matches the event branch as a glob // (`feat/*`, `release-*`, `*` for "any branch"). The trigger returns an // intent whose Metadata["preview_branch"] holds the matched branch — the // dispatcher uses that signal to materialize an ephemeral per-branch // child workload rather than redeploying the parent. type Config struct { Repo string `json:"repo"` Mode string `json:"mode"` // "push" | "tag" Branch string `json:"branch"` BranchPattern string `json:"branch_pattern"` TagPattern string `json:"tag_pattern"` } type trigger struct{} func init() { plugin.RegisterTrigger(&trigger{}) } func (*trigger) Kind() string { return "git" } func (*trigger) SchemaSample() any { return Config{ Repo: "owner/repo", Mode: "push", Branch: "main", } } func (*trigger) Validate(cfg json.RawMessage) error { var c Config if len(cfg) == 0 { return fmt.Errorf("git trigger: config is required") } if err := json.Unmarshal(cfg, &c); err != nil { return fmt.Errorf("git trigger: invalid json: %w", err) } switch c.Mode { case "push": // Branch is optional ("" means any branch). BranchPattern is // validated as a path.Match glob if present; misconfigured // patterns are rejected at the boundary rather than letting them // fail silently inside Match. if c.BranchPattern != "" { if _, err := path.Match(c.BranchPattern, "probe"); err != nil { return fmt.Errorf("git trigger: invalid branch_pattern %q: %w", c.BranchPattern, err) } } case "tag": pattern := c.TagPattern if pattern == "" { pattern = "*" } if _, err := path.Match(pattern, "probe"); err != nil { return fmt.Errorf("git trigger: invalid tag_pattern %q: %w", pattern, err) } default: return fmt.Errorf("git trigger: mode must be \"push\" or \"tag\"") } return nil } func (*trigger) Match(ctx context.Context, deps plugin.Deps, w plugin.Workload, evt plugin.InboundEvent) (*plugin.DeploymentIntent, error) { if evt.Git == nil { return nil, nil } cfg, err := plugin.TriggerConfigOf[Config](w) if err != nil { return nil, fmt.Errorf("git trigger: decode config: %w", err) } if cfg.Repo != "" && !strings.EqualFold(cfg.Repo, evt.Git.Repo) { return nil, nil } if !refMatches(cfg, evt.Git.Ref) { return nil, nil } meta := map[string]string{ "repo": evt.Git.Repo, "vendor": evt.Git.Vendor, "ref": evt.Git.Ref, "pusher": evt.Git.Pusher, } if evt.Git.Branch != "" { meta["branch"] = evt.Git.Branch } if evt.Git.Tag != "" { meta["tag"] = evt.Git.Tag } // Preview-deploy signal: when BranchPattern is set AND the matched // branch is NOT the configured baseline Branch, flag this dispatch // for materialization as a per-branch child workload. The dispatcher // reads preview_branch and decides whether to spawn a preview row; // a baseline-branch push falls through to a normal redeploy of the // template itself. if cfg.Mode == "push" && cfg.BranchPattern != "" && evt.Git.Branch != "" && evt.Git.Branch != cfg.Branch { meta["preview_branch"] = evt.Git.Branch if evt.Git.Deleted { meta["preview_deleted"] = "1" } } reason := "git-push" if meta["preview_deleted"] == "1" { reason = "git-branch-deleted" } return &plugin.DeploymentIntent{ Reason: reason, Reference: evt.Git.CommitSHA, Metadata: meta, TriggeredAt: time.Now().UTC(), TriggeredBy: "git-webhook", }, nil } func refMatches(cfg Config, ref string) bool { switch cfg.Mode { case "push": branch, ok := strings.CutPrefix(ref, "refs/heads/") if !ok { return false } // Pattern-mode preview filter: any branch whose name matches the // glob is in scope. The baseline `cfg.Branch` is also allowed so // pushes to the template's primary branch keep redeploying the // template itself. if cfg.BranchPattern != "" { if cfg.Branch != "" && cfg.Branch == branch { return true } matched, err := path.Match(cfg.BranchPattern, branch) return err == nil && matched } return cfg.Branch == "" || cfg.Branch == branch case "tag": tag, ok := strings.CutPrefix(ref, "refs/tags/") if !ok { return false } pattern := cfg.TagPattern if pattern == "" { pattern = "*" } matched, err := path.Match(pattern, tag) return err == nil && matched } return false }