Files
tiny-forge/internal/workload/plugin/trigger/git/git.go
T
alexei.dolgolyov 410a131cec feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
  WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
  ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
  + {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
  /apps/[id] edit form onto the same components (removes the duplication). Add
  vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
  environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
  state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
  conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
  label hints; dashboard + /apps "Total workloads" count only source_kind workloads
  (drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
  empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
2026-05-29 02:09:54 +03:00

167 lines
4.8 KiB
Go

// 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
}