From 363c19f39c7c544abb3acb077e98f418da514e68 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 20:44:47 -0400 Subject: [PATCH 01/29] feat(secret): add git credential type Adds the 'git' credential type with token, ssh_key, and username fields, plus a RequireAtLeastOne validator ensuring token or ssh_key is always present. Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/secret/types.go | 28 ++++++++++- packages/engine/internal/secret/types_test.go | 47 ++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/packages/engine/internal/secret/types.go b/packages/engine/internal/secret/types.go index e954ddd..b259a88 100644 --- a/packages/engine/internal/secret/types.go +++ b/packages/engine/internal/secret/types.go @@ -14,8 +14,9 @@ type FieldDef struct { // CredentialType defines the schema for a credential type. type CredentialType struct { - Name string - Fields []FieldDef + Name string + Fields []FieldDef + RequireAtLeastOne []string // at least one of these field names must be non-empty } // Validate checks that all required fields are present in the data map. @@ -28,6 +29,19 @@ func (ct *CredentialType) Validate(data map[string]string) error { } } } + if len(ct.RequireAtLeastOne) > 0 { + anySet := false + for _, name := range ct.RequireAtLeastOne { + if v, ok := data[name]; ok && v != "" { + anySet = true + break + } + } + if !anySet { + return fmt.Errorf("credential type %q requires at least one of: %s", + ct.Name, strings.Join(ct.RequireAtLeastOne, ", ")) + } + } return nil } @@ -86,6 +100,16 @@ var builtinTypes = map[string]*CredentialType{ {Name: "client_key", Required: false}, }, }, + "git": { + Name: "git", + Fields: []FieldDef{ + {Name: "token", Required: false}, + {Name: "ssh_key", Required: false}, + {Name: "username", Required: false}, + }, + // At-least-one validator below guarantees we have auth material. + RequireAtLeastOne: []string{"token", "ssh_key"}, + }, } // GetType returns the credential type definition, or an error if unknown. diff --git a/packages/engine/internal/secret/types_test.go b/packages/engine/internal/secret/types_test.go index 923c5ff..409e9d4 100644 --- a/packages/engine/internal/secret/types_test.go +++ b/packages/engine/internal/secret/types_test.go @@ -41,7 +41,7 @@ func TestGetType_Unknown(t *testing.T) { } func TestGetType_AllBuiltins(t *testing.T) { - for _, name := range []string{"generic", "bearer", "openai", "basic"} { + for _, name := range []string{"generic", "bearer", "openai", "basic", "git"} { ct, err := GetType(name) if err != nil { t.Errorf("GetType(%q) error: %v", name, err) @@ -76,3 +76,48 @@ func TestCredentialType_Docker(t *testing.T) { t.Errorf("full TLS data should be valid: %v", err) } } + +func TestGitCredentialType_Fields(t *testing.T) { + ct, err := GetType("git") + if err != nil { + t.Fatalf("GetType(\"git\") error: %v", err) + } + want := map[string]bool{ + "token": false, + "ssh_key": false, + "username": false, + } + got := map[string]bool{} + for _, f := range ct.Fields { + got[f.Name] = f.Required + } + if len(got) != len(want) { + t.Fatalf("git type fields: got %v, want %v", got, want) + } + for name, required := range want { + if gotReq, ok := got[name]; !ok { + t.Errorf("git type missing field %q", name) + } else if gotReq != required { + t.Errorf("field %q required: got %v, want %v", name, gotReq, required) + } + } +} + +func TestGitCredentialType_ValidateRequiresTokenOrSSHKey(t *testing.T) { + ct, err := GetType("git") + if err != nil { + t.Fatalf("GetType(\"git\") error: %v", err) + } + // Neither token nor ssh_key — should fail. + if err := ct.Validate(map[string]string{"username": "git"}); err == nil { + t.Error("expected error when both token and ssh_key are empty") + } + // token-only — should succeed. + if err := ct.Validate(map[string]string{"token": "ghp_xxx"}); err != nil { + t.Errorf("token-only: unexpected error %v", err) + } + // ssh_key-only — should succeed. + if err := ct.Validate(map[string]string{"ssh_key": "----BEGIN KEY----"}); err != nil { + t.Errorf("ssh_key-only: unexpected error %v", err) + } +} From 49d47e4bd5b9fbbd7e128189de40e74653e220a6 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 20:49:19 -0400 Subject: [PATCH 02/29] feat(audit): add repo.{added,updated,removed} actions --- packages/engine/internal/audit/audit.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/engine/internal/audit/audit.go b/packages/engine/internal/audit/audit.go index 3f547cf..44ec091 100644 --- a/packages/engine/internal/audit/audit.go +++ b/packages/engine/internal/audit/audit.go @@ -61,6 +61,11 @@ const ( ActionEnvironmentUpdated Action = "environment.updated" ActionEnvironmentDeleted Action = "environment.deleted" ActionEnvironmentRevealed Action = "environment.revealed" + + // Git repo operations. + ActionRepoAdded Action = "repo.added" + ActionRepoUpdated Action = "repo.updated" + ActionRepoRemoved Action = "repo.removed" ) // Resource identifies the target of an audit event. From 0966f808cc7d3243e5c641955ac050292d1af91c Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 20:52:37 -0400 Subject: [PATCH 03/29] feat(db): add 019_git_repos migration --- .../internal/db/migrations/019_git_repos.sql | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/engine/internal/db/migrations/019_git_repos.sql diff --git a/packages/engine/internal/db/migrations/019_git_repos.sql b/packages/engine/internal/db/migrations/019_git_repos.sql new file mode 100644 index 0000000..3a05d64 --- /dev/null +++ b/packages/engine/internal/db/migrations/019_git_repos.sql @@ -0,0 +1,36 @@ +-- +goose Up +-- git_repos stores configuration for GitOps workflow sources (issue #16). +-- Each row represents a remote git repository that Mantle will pull from +-- (via a k8s git-sync sidecar) and whose .yaml/.yml files will be applied +-- as workflow definitions. The raw auth material lives in credentials — +-- this row only references it by name. +-- +-- The `name` column is a human-readable identifier for CLI ergonomics +-- (`mantle repos status `), unique per team. It does not derive +-- from the repo URL because multiple teams may share the same upstream. +CREATE TABLE git_repos ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + team_id UUID NOT NULL REFERENCES teams(id) DEFAULT '00000000-0000-0000-0000-000000000001', + name TEXT NOT NULL, + url TEXT NOT NULL, + branch TEXT NOT NULL DEFAULT 'main', + path TEXT NOT NULL DEFAULT '/', + poll_interval TEXT NOT NULL DEFAULT '60s', + credential TEXT NOT NULL, + auto_apply BOOLEAN NOT NULL DEFAULT TRUE, + prune BOOLEAN NOT NULL DEFAULT TRUE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_sync_sha TEXT, + last_sync_at TIMESTAMPTZ, + last_sync_error TEXT, + webhook_secret TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT git_repos_team_name_key UNIQUE(team_id, name) +); + +CREATE INDEX idx_git_repos_team ON git_repos(team_id); +CREATE INDEX idx_git_repos_enabled ON git_repos(enabled) WHERE enabled = TRUE; + +-- +goose Down +DROP TABLE IF EXISTS git_repos; From 0578835b7b850df3443ee9d959db57855eec71d9 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 20:53:50 -0400 Subject: [PATCH 04/29] feat(repo): add Repo struct and validators Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/repo/types.go | 74 ++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 packages/engine/internal/repo/types.go diff --git a/packages/engine/internal/repo/types.go b/packages/engine/internal/repo/types.go new file mode 100644 index 0000000..d69ec35 --- /dev/null +++ b/packages/engine/internal/repo/types.go @@ -0,0 +1,74 @@ +// Package repo manages registered GitOps source repositories (issue #16). +// Each Repo references encrypted auth material in the credentials table +// by name and stores last-sync state for observability. The sync engine +// itself (file discovery, validate/plan/apply pipeline) lives in a +// separate package and is out of scope for this package. +package repo + +import ( + "fmt" + "regexp" + "time" +) + +// maxRepoNameLength caps names at the DNS label limit (RFC 1035) — same +// rationale as environments: names embed into log lines, metric labels, +// and URL path segments without escaping. +const maxRepoNameLength = 63 + +// validRepoNamePattern enforces DNS-label-like names: lowercase +// alphanumerics, underscores, and hyphens, starting with an alphanumeric. +var validRepoNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + +// Repo represents a registered git repository that Mantle pulls workflow +// definitions from. +type Repo struct { + ID string + Name string + URL string + Branch string + Path string + PollInterval string // Go duration literal, e.g., "60s" + Credential string // name of the git-type credential row + AutoApply bool + Prune bool + Enabled bool + LastSyncSHA string + LastSyncAt *time.Time + LastSyncError string + WebhookSecret string + CreatedAt time.Time + UpdatedAt time.Time +} + +// ValidateName returns an error when name violates the allowed pattern +// or length. Exported because the CLI validates the flag before calling +// into the store for faster feedback on typos. +func ValidateName(name string) error { + if name == "" { + return fmt.Errorf("repo name is required") + } + if len(name) > maxRepoNameLength { + return fmt.Errorf("invalid repo name %q: length %d exceeds %d-char cap", + name, len(name), maxRepoNameLength) + } + if !validRepoNamePattern.MatchString(name) { + return fmt.Errorf("invalid repo name %q: must match %s", + name, validRepoNamePattern.String()) + } + return nil +} + +// ValidatePollInterval returns an error when interval cannot be parsed +// as a Go duration or is below the 10-second minimum. The floor exists +// to prevent operators from hammering their git provider's rate limits. +func ValidatePollInterval(interval string) error { + d, err := time.ParseDuration(interval) + if err != nil { + return fmt.Errorf("invalid poll_interval %q: %w", interval, err) + } + if d < 10*time.Second { + return fmt.Errorf("poll_interval %q below 10s minimum", interval) + } + return nil +} From a468afb669ae5a919b9ee4dae9a1c8d48cca6613 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 20:57:45 -0400 Subject: [PATCH 05/29] feat(repo): add Store.Create with audit emission Implements the first CRUD method on the repo.Store type. Create validates name and poll_interval before touching the database, inserts the row, emits a repo.added audit event inside the same transaction, and returns the persisted Repo struct. Four integration tests cover the happy path, duplicate name rejection, and both validation guards. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/repo/store.go | 101 ++++++++++++++ packages/engine/internal/repo/store_test.go | 147 ++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 packages/engine/internal/repo/store.go create mode 100644 packages/engine/internal/repo/store_test.go diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go new file mode 100644 index 0000000..3e34926 --- /dev/null +++ b/packages/engine/internal/repo/store.go @@ -0,0 +1,101 @@ +package repo + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/dvflw/mantle/internal/audit" + "github.com/dvflw/mantle/internal/auth" +) + +// Store handles CRUD operations for registered git repos. Every +// state-changing method emits an audit event in the same transaction +// as the write so audit log and state never drift. +type Store struct { + DB *sql.DB + Actor string // defaults to "cli" when empty +} + +func (s *Store) actor() string { + if s.Actor == "" { + return "cli" + } + return s.Actor +} + +// ErrNotFound is returned when a lookup by name does not match a row in +// the current team scope. +var ErrNotFound = errors.New("repo not found") + +// CreateParams captures the fields required to register a new repo. +// Fields with empty defaults (Branch, Path, PollInterval) are filled +// in by the caller using the same defaults the `git_repos` table uses. +type CreateParams struct { + Name string + URL string + Branch string + Path string + PollInterval string + Credential string + AutoApply bool + Prune bool +} + +// Create inserts a new repo row and emits a repo.added audit event. +func (s *Store) Create(ctx context.Context, p CreateParams) (*Repo, error) { + if err := ValidateName(p.Name); err != nil { + return nil, err + } + if err := ValidatePollInterval(p.PollInterval); err != nil { + return nil, err + } + if p.URL == "" { + return nil, fmt.Errorf("repo url is required") + } + if p.Credential == "" { + return nil, fmt.Errorf("credential is required") + } + + teamID := auth.TeamIDFromContext(ctx) + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("starting transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + var r Repo + err = tx.QueryRowContext(ctx, + `INSERT INTO git_repos + (team_id, name, url, branch, path, poll_interval, credential, auto_apply, prune) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, name, url, branch, path, poll_interval, credential, + auto_apply, prune, enabled, created_at, updated_at`, + teamID, p.Name, p.URL, p.Branch, p.Path, p.PollInterval, p.Credential, + p.AutoApply, p.Prune, + ).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval, + &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, + &r.CreatedAt, &r.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("creating repo %q: %w", p.Name, err) + } + + if err := audit.EmitTx(ctx, tx, audit.Event{ + Timestamp: time.Now(), + Actor: s.actor(), + Action: audit.ActionRepoAdded, + Resource: audit.Resource{Type: "git_repo", ID: r.ID}, + TeamID: teamID, + Metadata: map[string]string{"name": p.Name, "url": p.URL}, + }); err != nil { + return nil, fmt.Errorf("emitting audit event: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("committing repo create: %w", err) + } + return &r, nil +} diff --git a/packages/engine/internal/repo/store_test.go b/packages/engine/internal/repo/store_test.go new file mode 100644 index 0000000..632ae65 --- /dev/null +++ b/packages/engine/internal/repo/store_test.go @@ -0,0 +1,147 @@ +package repo + +import ( + "context" + "database/sql" + "os" + "testing" + "time" + + "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/dbdefaults" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +// setupTestDB spins up a Postgres container, runs migrations, and returns +// the connection. Copied from internal/environment/store_test.go to keep +// both packages decoupled from a shared test helper. +func setupTestDB(t *testing.T) *sql.DB { + t.Helper() + ctx := context.Background() + pgContainer, err := postgres.Run(ctx, + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(30*time.Second), + ), + ) + if err != nil { + if os.Getenv("CI") != "" { + t.Fatalf("Could not start Postgres container (CI): %v", err) + } + t.Skipf("Could not start Postgres container: %v", err) + } + t.Cleanup(func() { _ = pgContainer.Terminate(ctx) }) + connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("ConnectionString: %v", err) + } + database, err := db.Open(config.DatabaseConfig{URL: connStr}) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { database.Close() }) + if err := db.Migrate(ctx, database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + return database +} + +func newTestStore(t *testing.T) *Store { + t.Helper() + return &Store{DB: setupTestDB(t), Actor: "test"} +} + +// defaultCtx returns the default single-tenant test context. TeamIDFromContext +// returns auth.DefaultTeamID when no authenticated user is present, which +// matches the FK default on git_repos. +func defaultCtx() context.Context { + return context.Background() +} + +func TestStore_Create_PersistsRow(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + + r, err := store.Create(ctx, CreateParams{ + Name: "acme", + URL: "https://github.com/acme/workflows.git", + Branch: "main", + Path: "/", + PollInterval: "60s", + Credential: "github-pat", + AutoApply: true, + Prune: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + if r.ID == "" { + t.Error("expected generated ID") + } + if r.Name != "acme" { + t.Errorf("Name: got %q, want %q", r.Name, "acme") + } + if !r.Enabled { + t.Error("Enabled should default to true") + } +} + +func TestStore_Create_RejectsDuplicateName(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + base := CreateParams{ + Name: "dup", + URL: "https://example.com/a.git", + Branch: "main", + Path: "/", + PollInterval: "60s", + Credential: "pat", + } + if _, err := store.Create(ctx, base); err != nil { + t.Fatalf("first Create: %v", err) + } + if _, err := store.Create(ctx, base); err == nil { + t.Error("expected duplicate-name error") + } +} + +func TestStore_Create_ValidatesName(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + _, err := store.Create(ctx, CreateParams{ + Name: "Bad Name!", + URL: "https://example.com/a.git", + Branch: "main", + Path: "/", + PollInterval: "60s", + Credential: "pat", + }) + if err == nil { + t.Error("expected name validation error") + } +} + +func TestStore_Create_ValidatesPollInterval(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + _, err := store.Create(ctx, CreateParams{ + Name: "slow", + URL: "https://example.com/a.git", + Branch: "main", + Path: "/", + PollInterval: "5s", + Credential: "pat", + }) + if err == nil { + t.Error("expected poll_interval floor error") + } +} + From 31141abe455bf208ed810f4601f52834ee7a2075 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:00:26 -0400 Subject: [PATCH 06/29] feat(repo): add Store.Get with nullable last-sync scan Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/repo/store.go | 40 +++++++++++++++++++++ packages/engine/internal/repo/store_test.go | 32 +++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go index 3e34926..7211de8 100644 --- a/packages/engine/internal/repo/store.go +++ b/packages/engine/internal/repo/store.go @@ -99,3 +99,43 @@ func (s *Store) Create(ctx context.Context, p CreateParams) (*Repo, error) { } return &r, nil } + +// Get retrieves a repo by name within the current team scope. Returns +// ErrNotFound when no row matches. +func (s *Store) Get(ctx context.Context, name string) (*Repo, error) { + teamID := auth.TeamIDFromContext(ctx) + + var r Repo + var lastSyncAt sql.NullTime + var lastSyncSHA, lastSyncError, webhookSecret sql.NullString + err := s.DB.QueryRowContext(ctx, + `SELECT id, name, url, branch, path, poll_interval, credential, + auto_apply, prune, enabled, last_sync_sha, last_sync_at, + last_sync_error, webhook_secret, created_at, updated_at + FROM git_repos WHERE name = $1 AND team_id = $2`, + name, teamID, + ).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval, + &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, + &lastSyncSHA, &lastSyncAt, &lastSyncError, &webhookSecret, + &r.CreatedAt, &r.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %q", ErrNotFound, name) + } + if err != nil { + return nil, fmt.Errorf("querying repo: %w", err) + } + if lastSyncSHA.Valid { + r.LastSyncSHA = lastSyncSHA.String + } + if lastSyncAt.Valid { + t := lastSyncAt.Time + r.LastSyncAt = &t + } + if lastSyncError.Valid { + r.LastSyncError = lastSyncError.String + } + if webhookSecret.Valid { + r.WebhookSecret = webhookSecret.String + } + return &r, nil +} diff --git a/packages/engine/internal/repo/store_test.go b/packages/engine/internal/repo/store_test.go index 632ae65..eacde74 100644 --- a/packages/engine/internal/repo/store_test.go +++ b/packages/engine/internal/repo/store_test.go @@ -3,6 +3,7 @@ package repo import ( "context" "database/sql" + "errors" "os" "testing" "time" @@ -145,3 +146,34 @@ func TestStore_Create_ValidatesPollInterval(t *testing.T) { } } +func TestStore_Get_ReturnsRow(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + created, err := store.Create(ctx, CreateParams{ + Name: "acme", + URL: "https://github.com/acme/workflows.git", + Branch: "main", + Path: "/", + PollInterval: "60s", + Credential: "pat", + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + got, err := store.Get(ctx, "acme") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.ID != created.ID { + t.Errorf("ID: got %q, want %q", got.ID, created.ID) + } +} + +func TestStore_Get_NotFound(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + _, err := store.Get(ctx, "nope") + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} From cc033f737a6c071de757cd8216eaedd88c1c16de Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:02:12 -0400 Subject: [PATCH 07/29] feat(repo): add Store.List Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/repo/store.go | 44 +++++++++++++++++++++ packages/engine/internal/repo/store_test.go | 31 +++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go index 7211de8..f099b48 100644 --- a/packages/engine/internal/repo/store.go +++ b/packages/engine/internal/repo/store.go @@ -100,6 +100,50 @@ func (s *Store) Create(ctx context.Context, p CreateParams) (*Repo, error) { return &r, nil } +// List returns all repos in the current team scope, ordered by name. +// Last-sync fields are populated when non-null. The credential name is +// returned but raw secret material is never loaded here. +func (s *Store) List(ctx context.Context) ([]Repo, error) { + teamID := auth.TeamIDFromContext(ctx) + + rows, err := s.DB.QueryContext(ctx, + `SELECT id, name, url, branch, path, poll_interval, credential, + auto_apply, prune, enabled, last_sync_sha, last_sync_at, + last_sync_error, created_at, updated_at + FROM git_repos WHERE team_id = $1 ORDER BY name`, + teamID, + ) + if err != nil { + return nil, fmt.Errorf("listing repos: %w", err) + } + defer rows.Close() + + var repos []Repo + for rows.Next() { + var r Repo + var lastSyncAt sql.NullTime + var lastSyncSHA, lastSyncError sql.NullString + if err := rows.Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, + &r.PollInterval, &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, + &lastSyncSHA, &lastSyncAt, &lastSyncError, + &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, fmt.Errorf("scanning repo: %w", err) + } + if lastSyncSHA.Valid { + r.LastSyncSHA = lastSyncSHA.String + } + if lastSyncAt.Valid { + t := lastSyncAt.Time + r.LastSyncAt = &t + } + if lastSyncError.Valid { + r.LastSyncError = lastSyncError.String + } + repos = append(repos, r) + } + return repos, rows.Err() +} + // Get retrieves a repo by name within the current team scope. Returns // ErrNotFound when no row matches. func (s *Store) Get(ctx context.Context, name string) (*Repo, error) { diff --git a/packages/engine/internal/repo/store_test.go b/packages/engine/internal/repo/store_test.go index eacde74..cfa24a0 100644 --- a/packages/engine/internal/repo/store_test.go +++ b/packages/engine/internal/repo/store_test.go @@ -177,3 +177,34 @@ func TestStore_Get_NotFound(t *testing.T) { t.Errorf("expected ErrNotFound, got %v", err) } } + +func TestStore_List_ReturnsAllReposForTeam(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + for _, name := range []string{"zeta", "alpha", "mike"} { + if _, err := store.Create(ctx, CreateParams{ + Name: name, + URL: "https://example.com/" + name + ".git", + Branch: "main", + Path: "/", + PollInterval: "60s", + Credential: "pat", + }); err != nil { + t.Fatalf("Create %s: %v", name, err) + } + } + repos, err := store.List(ctx) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(repos) != 3 { + t.Fatalf("len: got %d, want 3", len(repos)) + } + // ORDER BY name — alpha, mike, zeta. + want := []string{"alpha", "mike", "zeta"} + for i, r := range repos { + if r.Name != want[i] { + t.Errorf("index %d: got %q, want %q", i, r.Name, want[i]) + } + } +} From 2d917be79af6f7b331fe72c94c45d9a9ccf4aa4d Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:04:27 -0400 Subject: [PATCH 08/29] feat(repo): add Store.Update for mutable fields Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/repo/store.go | 67 +++++++++++++++++++++ packages/engine/internal/repo/store_test.go | 34 +++++++++++ 2 files changed, 101 insertions(+) diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go index f099b48..cd133af 100644 --- a/packages/engine/internal/repo/store.go +++ b/packages/engine/internal/repo/store.go @@ -144,6 +144,73 @@ func (s *Store) List(ctx context.Context) ([]Repo, error) { return repos, rows.Err() } +// UpdateParams captures the mutable fields of a repo. Name and URL are +// intentionally omitted — changing either requires delete + recreate so +// that audit history clearly reflects the identity change. +type UpdateParams struct { + Branch string + Path string + PollInterval string + Credential string + AutoApply bool + Prune bool + Enabled bool +} + +// Update replaces the mutable fields of a repo by name and emits a +// repo.updated audit event in the same transaction. +func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo, error) { + if err := ValidatePollInterval(p.PollInterval); err != nil { + return nil, err + } + if p.Credential == "" { + return nil, fmt.Errorf("credential is required") + } + + teamID := auth.TeamIDFromContext(ctx) + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("starting transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + var r Repo + err = tx.QueryRowContext(ctx, + `UPDATE git_repos + SET branch = $3, path = $4, poll_interval = $5, credential = $6, + auto_apply = $7, prune = $8, enabled = $9, updated_at = NOW() + WHERE name = $1 AND team_id = $2 + RETURNING id, name, url, branch, path, poll_interval, credential, + auto_apply, prune, enabled, created_at, updated_at`, + name, teamID, p.Branch, p.Path, p.PollInterval, p.Credential, + p.AutoApply, p.Prune, p.Enabled, + ).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval, + &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, + &r.CreatedAt, &r.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: %q", ErrNotFound, name) + } + if err != nil { + return nil, fmt.Errorf("updating repo %q: %w", name, err) + } + + if err := audit.EmitTx(ctx, tx, audit.Event{ + Timestamp: time.Now(), + Actor: s.actor(), + Action: audit.ActionRepoUpdated, + Resource: audit.Resource{Type: "git_repo", ID: r.ID}, + TeamID: teamID, + Metadata: map[string]string{"name": name}, + }); err != nil { + return nil, fmt.Errorf("emitting audit event: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("committing repo update: %w", err) + } + return &r, nil +} + // Get retrieves a repo by name within the current team scope. Returns // ErrNotFound when no row matches. func (s *Store) Get(ctx context.Context, name string) (*Repo, error) { diff --git a/packages/engine/internal/repo/store_test.go b/packages/engine/internal/repo/store_test.go index cfa24a0..8547b67 100644 --- a/packages/engine/internal/repo/store_test.go +++ b/packages/engine/internal/repo/store_test.go @@ -208,3 +208,37 @@ func TestStore_List_ReturnsAllReposForTeam(t *testing.T) { } } } + +func TestStore_Update_ReplacesMutableFields(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + if _, err := store.Create(ctx, CreateParams{ + Name: "acme", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", AutoApply: true, Prune: true, + }); err != nil { + t.Fatalf("Create: %v", err) + } + updated, err := store.Update(ctx, "acme", UpdateParams{ + Branch: "release", Path: "/workflows", PollInterval: "120s", + Credential: "pat-v2", AutoApply: false, Prune: false, Enabled: true, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Branch != "release" || updated.Path != "/workflows" || + updated.PollInterval != "120s" || updated.Credential != "pat-v2" || + updated.AutoApply || updated.Prune { + t.Errorf("Update did not persist fields: %+v", updated) + } +} + +func TestStore_Update_NotFound(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + _, err := store.Update(ctx, "nope", UpdateParams{ + Branch: "main", Path: "/", PollInterval: "60s", Credential: "pat", + }) + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} From 78f8006e5d019b74b6b13fda1765f01b1fd723d1 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:06:22 -0400 Subject: [PATCH 09/29] feat(repo): add Store.Delete with audit emission Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/repo/store.go | 39 +++++++++++++++++++++ packages/engine/internal/repo/store_test.go | 25 +++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go index cd133af..c5b08cf 100644 --- a/packages/engine/internal/repo/store.go +++ b/packages/engine/internal/repo/store.go @@ -211,6 +211,45 @@ func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo, return &r, nil } +// Delete removes a repo by name and emits a repo.removed audit event in +// the same transaction. Returns ErrNotFound when no row matches. +func (s *Store) Delete(ctx context.Context, name string) error { + teamID := auth.TeamIDFromContext(ctx) + + tx, err := s.DB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("starting transaction: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + var deletedID string + err = tx.QueryRowContext(ctx, + `DELETE FROM git_repos WHERE name = $1 AND team_id = $2 RETURNING id`, + name, teamID, + ).Scan(&deletedID) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: %q", ErrNotFound, name) + } + if err != nil { + return fmt.Errorf("deleting repo %q: %w", name, err) + } + + if err := audit.EmitTx(ctx, tx, audit.Event{ + Timestamp: time.Now(), + Actor: s.actor(), + Action: audit.ActionRepoRemoved, + Resource: audit.Resource{Type: "git_repo", ID: deletedID}, + TeamID: teamID, + Metadata: map[string]string{"name": name}, + }); err != nil { + return fmt.Errorf("emitting audit event: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("committing repo delete: %w", err) + } + return nil +} + // Get retrieves a repo by name within the current team scope. Returns // ErrNotFound when no row matches. func (s *Store) Get(ctx context.Context, name string) (*Repo, error) { diff --git a/packages/engine/internal/repo/store_test.go b/packages/engine/internal/repo/store_test.go index 8547b67..3675447 100644 --- a/packages/engine/internal/repo/store_test.go +++ b/packages/engine/internal/repo/store_test.go @@ -242,3 +242,28 @@ func TestStore_Update_NotFound(t *testing.T) { t.Errorf("expected ErrNotFound, got %v", err) } } + +func TestStore_Delete_RemovesRow(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + if _, err := store.Create(ctx, CreateParams{ + Name: "acme", URL: "https://example.com/a.git", Branch: "main", + Path: "/", PollInterval: "60s", Credential: "pat", + }); err != nil { + t.Fatalf("Create: %v", err) + } + if err := store.Delete(ctx, "acme"); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := store.Get(ctx, "acme"); !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestStore_Delete_NotFound(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + if err := store.Delete(ctx, "nope"); !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} From 7097c7b893cf2c49974cab60d1a295db96f307e7 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:08:14 -0400 Subject: [PATCH 10/29] feat(config): add git_sync.repos block Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/config/config.go | 22 ++++++++++++ .../engine/internal/config/config_test.go | 36 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/engine/internal/config/config.go b/packages/engine/internal/config/config.go index f566176..f933d32 100644 --- a/packages/engine/internal/config/config.go +++ b/packages/engine/internal/config/config.go @@ -33,6 +33,7 @@ type Config struct { GCP GCPConfig `mapstructure:"gcp"` Azure AzureConfig `mapstructure:"azure"` Storage StorageConfig `mapstructure:"storage"` + GitSync GitSyncConfig `mapstructure:"git_sync"` Env map[string]string `mapstructure:"env"` } @@ -67,6 +68,27 @@ type StorageConfig struct { Retention string `mapstructure:"retention"` // Duration string, e.g. "24h". Empty = no auto-cleanup. } +// GitSyncConfig configures GitOps-style workflow sync (issue #16). Each +// entry under Repos is materialized into a git_repos row at startup so +// the registry stays consistent with mantle.yaml. +type GitSyncConfig struct { + Repos []GitSyncRepo `mapstructure:"repos"` +} + +// GitSyncRepo mirrors the relevant fields of the git_repos table for +// config-driven registration. Fields default to the same values as the +// DB when omitted. +type GitSyncRepo struct { + Name string `mapstructure:"name"` + URL string `mapstructure:"url"` + Branch string `mapstructure:"branch"` + Path string `mapstructure:"path"` + PollInterval string `mapstructure:"poll_interval"` + Credential string `mapstructure:"credential"` + AutoApply bool `mapstructure:"auto_apply"` + Prune bool `mapstructure:"prune"` +} + // AuthConfig holds authentication configuration. type AuthConfig struct { OIDC OIDCConfig `mapstructure:"oidc"` diff --git a/packages/engine/internal/config/config_test.go b/packages/engine/internal/config/config_test.go index a780344..146f212 100644 --- a/packages/engine/internal/config/config_test.go +++ b/packages/engine/internal/config/config_test.go @@ -467,3 +467,39 @@ log: assert.Empty(t, cfg.Env) } + +func TestLoadConfig_GitSyncRepos(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mantle.yaml") + yaml := `version: 1 +database: + url: postgres://localhost/mantle +git_sync: + repos: + - name: acme + url: https://github.com/acme/workflows.git + branch: main + path: / + poll_interval: 60s + credential: github-pat + auto_apply: true + prune: true +` + if err := os.WriteFile(path, []byte(yaml), 0600); err != nil { + t.Fatalf("writing config: %v", err) + } + cmd := newTestCommand() + _ = cmd.Flags().Set("config", path) + cfg, err := Load(cmd) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.GitSync.Repos) != 1 { + t.Fatalf("got %d repos, want 1", len(cfg.GitSync.Repos)) + } + r := cfg.GitSync.Repos[0] + if r.Name != "acme" || r.URL != "https://github.com/acme/workflows.git" || + r.Branch != "main" || r.Credential != "github-pat" || !r.AutoApply { + t.Errorf("parsed repo: %+v", r) + } +} From a4503d3a02d4543710571710711b38ea65f5609f Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:09:43 -0400 Subject: [PATCH 11/29] feat(cli): add mantle repos root command Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/cli/repos.go | 41 +++++++++++++++++++++++++++ packages/engine/internal/cli/root.go | 1 + 2 files changed, 42 insertions(+) create mode 100644 packages/engine/internal/cli/repos.go diff --git a/packages/engine/internal/cli/repos.go b/packages/engine/internal/cli/repos.go new file mode 100644 index 0000000..7b9150b --- /dev/null +++ b/packages/engine/internal/cli/repos.go @@ -0,0 +1,41 @@ +package cli + +import ( + "fmt" + + "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/repo" + "github.com/spf13/cobra" +) + +// newReposCommand returns the "repos" subcommand for managing registered +// GitOps source repositories (issue #16). Subcommands handle registration, +// listing, detailed status, and removal. Sync behavior lives in Plan B. +func newReposCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "repos", + Short: "Manage GitOps workflow source repositories", + Long: `Registered repos are periodically pulled by the git-sync sidecar and +their .yaml workflow definitions are applied to this Mantle instance. + +Auth material is stored in a "git" credential type (` + "`mantle secrets create --type git`" + `) +and referenced here by name.`, + } + return cmd +} + +// newRepoStore builds a repo.Store from the current command context. +func newRepoStore(cmd *cobra.Command) (*repo.Store, func(), error) { + cfg := config.FromContext(cmd.Context()) + if cfg == nil { + return nil, nil, fmt.Errorf("config not loaded") + } + database, err := db.Open(cfg.Database) + if err != nil { + return nil, nil, fmt.Errorf("failed to connect to database: %w", err) + } + store := &repo.Store{DB: database, Actor: "cli"} + cleanup := func() { database.Close() } + return store, cleanup, nil +} diff --git a/packages/engine/internal/cli/root.go b/packages/engine/internal/cli/root.go index 918ad78..cfb25b7 100644 --- a/packages/engine/internal/cli/root.go +++ b/packages/engine/internal/cli/root.go @@ -56,6 +56,7 @@ Full documentation: https://mantle.dvflw.co/docs`, newLogsCommand(), newStatusCommand(), newEnvCommand(), + newReposCommand(), ) // Server & triggers. From 11340d01b91e797a01ac63feaf2aa61fffc90070 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:12:47 -0400 Subject: [PATCH 12/29] feat(cli): add mantle repos add Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/cli/repos.go | 51 ++++++++++++++ packages/engine/internal/cli/repos_test.go | 77 ++++++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 packages/engine/internal/cli/repos_test.go diff --git a/packages/engine/internal/cli/repos.go b/packages/engine/internal/cli/repos.go index 7b9150b..cbc92ad 100644 --- a/packages/engine/internal/cli/repos.go +++ b/packages/engine/internal/cli/repos.go @@ -22,6 +22,7 @@ their .yaml workflow definitions are applied to this Mantle instance. Auth material is stored in a "git" credential type (` + "`mantle secrets create --type git`" + `) and referenced here by name.`, } + cmd.AddCommand(newReposAddCommand()) return cmd } @@ -39,3 +40,53 @@ func newRepoStore(cmd *cobra.Command) (*repo.Store, func(), error) { cleanup := func() { database.Close() } return store, cleanup, nil } + +func newReposAddCommand() *cobra.Command { + var url, branch, path, pollInterval, credential string + var autoApply, prune bool + + cmd := &cobra.Command{ + Use: "add ", + Short: "Register a new GitOps source repo", + Long: `Registers a new repository to sync workflow definitions from. The named +credential must already exist and be of type "git".`, + Example: ` mantle repos add acme --url https://github.com/acme/workflows.git --credential github-pat + mantle repos add staging --url git@github.com:acme/wf.git --credential github-ssh --branch release`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + store, cleanup, err := newRepoStore(cmd) + if err != nil { + return err + } + defer cleanup() + + r, err := store.Create(cmd.Context(), repo.CreateParams{ + Name: args[0], + URL: url, + Branch: branch, + Path: path, + PollInterval: pollInterval, + Credential: credential, + AutoApply: autoApply, + Prune: prune, + }) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Added repo %q (%s)\n", r.Name, r.ID) + return nil + }, + } + + cmd.Flags().StringVar(&url, "url", "", "Git repository URL (required)") + cmd.Flags().StringVar(&branch, "branch", "main", "Branch to sync") + cmd.Flags().StringVar(&path, "path", "/", "Subdirectory inside the repo to scan") + cmd.Flags().StringVar(&pollInterval, "poll-interval", "60s", "Interval between syncs (Go duration, min 10s)") + cmd.Flags().StringVar(&credential, "credential", "", "Git credential name (required)") + cmd.Flags().BoolVar(&autoApply, "auto-apply", true, "Automatically apply changes (false = plan-only)") + cmd.Flags().BoolVar(&prune, "prune", true, "Disable workflows removed from the repo") + _ = cmd.MarkFlagRequired("url") + _ = cmd.MarkFlagRequired("credential") + + return cmd +} diff --git a/packages/engine/internal/cli/repos_test.go b/packages/engine/internal/cli/repos_test.go new file mode 100644 index 0000000..1996320 --- /dev/null +++ b/packages/engine/internal/cli/repos_test.go @@ -0,0 +1,77 @@ +package cli + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + "time" + + "github.com/dvflw/mantle/internal/config" + "github.com/dvflw/mantle/internal/db" + "github.com/dvflw/mantle/internal/dbdefaults" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +// reposCtx spins up a Postgres container, runs migrations, and returns a +// context with the DatabaseConfig attached so `newRepoStore` can load it. +func reposCtx(t *testing.T) (context.Context, *config.Config) { + t.Helper() + bg := context.Background() + pgContainer, err := postgres.Run(bg, + dbdefaults.PostgresImage, + postgres.WithDatabase(dbdefaults.TestDatabase), + postgres.WithUsername(dbdefaults.User), + postgres.WithPassword(dbdefaults.Password), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(30*time.Second), + ), + ) + if err != nil { + if os.Getenv("CI") != "" { + t.Fatalf("Postgres (CI): %v", err) + } + t.Skipf("Postgres: %v", err) + } + t.Cleanup(func() { _ = pgContainer.Terminate(bg) }) + connStr, err := pgContainer.ConnectionString(bg, "sslmode=disable") + if err != nil { + t.Fatalf("ConnectionString: %v", err) + } + dbCfg := config.DatabaseConfig{URL: connStr} + conn, err := db.Open(dbCfg) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + t.Cleanup(func() { conn.Close() }) + if err := db.Migrate(bg, conn); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + cfg := &config.Config{Database: dbCfg} + ctx := config.WithContext(bg, cfg) + return ctx, cfg +} + +func TestReposAdd_PersistsRepo(t *testing.T) { + _, cfg := reposCtx(t) + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "add", "acme", + "--url", "https://github.com/acme/workflows.git", + "--credential", "github-pat", + "--database-url", cfg.Database.URL, + }) + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v\nstderr: %s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "Added repo \"acme\"") { + t.Errorf("unexpected stdout: %q", stdout.String()) + } +} From ac043669944d256fb89aaaa84549398517719cfa Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:15:00 -0400 Subject: [PATCH 13/29] feat(cli): add mantle repos list Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/cli/repos.go | 37 ++++++++++++++++++ packages/engine/internal/cli/repos_test.go | 45 ++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/engine/internal/cli/repos.go b/packages/engine/internal/cli/repos.go index cbc92ad..8d0d514 100644 --- a/packages/engine/internal/cli/repos.go +++ b/packages/engine/internal/cli/repos.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "text/tabwriter" "github.com/dvflw/mantle/internal/config" "github.com/dvflw/mantle/internal/db" @@ -23,6 +24,7 @@ Auth material is stored in a "git" credential type (` + "`mantle secrets create and referenced here by name.`, } cmd.AddCommand(newReposAddCommand()) + cmd.AddCommand(newReposListCommand()) return cmd } @@ -90,3 +92,38 @@ credential must already exist and be of type "git".`, return cmd } + +func newReposListCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all registered GitOps repos", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + store, cleanup, err := newRepoStore(cmd) + if err != nil { + return err + } + defer cleanup() + + repos, err := store.List(cmd.Context()) + if err != nil { + return err + } + if len(repos) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "(no repos)") + return nil + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tURL\tBRANCH\tAUTO-APPLY\tENABLED\tLAST SYNC") + for _, r := range repos { + last := "(never)" + if r.LastSyncAt != nil { + last = r.LastSyncAt.UTC().Format("2006-01-02 15:04:05 UTC") + } + fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%t\t%s\n", + r.Name, r.URL, r.Branch, r.AutoApply, r.Enabled, last) + } + return w.Flush() + }, + } +} diff --git a/packages/engine/internal/cli/repos_test.go b/packages/engine/internal/cli/repos_test.go index 1996320..aae4b10 100644 --- a/packages/engine/internal/cli/repos_test.go +++ b/packages/engine/internal/cli/repos_test.go @@ -75,3 +75,48 @@ func TestReposAdd_PersistsRepo(t *testing.T) { t.Errorf("unexpected stdout: %q", stdout.String()) } } + +func TestReposList_ShowsRegisteredRepos(t *testing.T) { + _, cfg := reposCtx(t) + // Seed one row by calling the add command. + { + root := NewRootCommand() + var seedStderr bytes.Buffer + root.SetErr(&seedStderr) + root.SetArgs([]string{"repos", "add", "acme", + "--url", "https://example.com/a.git", + "--credential", "pat", + "--database-url", cfg.Database.URL, + }) + if err := root.Execute(); err != nil { + t.Fatalf("seed add: %v\nstderr: %s", err, seedStderr.String()) + } + } + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "list", "--database-url", cfg.Database.URL}) + if err := root.Execute(); err != nil { + t.Fatalf("list: %v\nstderr: %s", err, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, "acme") || !strings.Contains(out, "NAME") { + t.Errorf("list output missing expected fields: %q", out) + } +} + +func TestReposList_EmptyState(t *testing.T) { + _, cfg := reposCtx(t) + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "list", "--database-url", cfg.Database.URL}) + if err := root.Execute(); err != nil { + t.Fatalf("list: %v\nstderr: %s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "(no repos)") { + t.Errorf("empty list output: %q", stdout.String()) + } +} From 495dcc790c6bb2eefa7cdef48e80ee65d4b038de Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:16:45 -0400 Subject: [PATCH 14/29] feat(cli): add mantle repos status Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/cli/repos.go | 42 ++++++++++++++++++++++ packages/engine/internal/cli/repos_test.go | 31 ++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/packages/engine/internal/cli/repos.go b/packages/engine/internal/cli/repos.go index 8d0d514..106e533 100644 --- a/packages/engine/internal/cli/repos.go +++ b/packages/engine/internal/cli/repos.go @@ -25,6 +25,7 @@ and referenced here by name.`, } cmd.AddCommand(newReposAddCommand()) cmd.AddCommand(newReposListCommand()) + cmd.AddCommand(newReposStatusCommand()) return cmd } @@ -93,6 +94,47 @@ credential must already exist and be of type "git".`, return cmd } +func newReposStatusCommand() *cobra.Command { + return &cobra.Command{ + Use: "status ", + Short: "Show detailed status for a registered repo", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + store, cleanup, err := newRepoStore(cmd) + if err != nil { + return err + } + defer cleanup() + + r, err := store.Get(cmd.Context(), args[0]) + if err != nil { + return err + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "Name: %s\n", r.Name) + fmt.Fprintf(out, "ID: %s\n", r.ID) + fmt.Fprintf(out, "URL: %s\n", r.URL) + fmt.Fprintf(out, "Branch: %s\n", r.Branch) + fmt.Fprintf(out, "Path: %s\n", r.Path) + fmt.Fprintf(out, "Poll: %s\n", r.PollInterval) + fmt.Fprintf(out, "Credential: %s\n", r.Credential) + fmt.Fprintf(out, "Auto-Apply: %t\n", r.AutoApply) + fmt.Fprintf(out, "Prune: %t\n", r.Prune) + fmt.Fprintf(out, "Enabled: %t\n", r.Enabled) + if r.LastSyncAt != nil { + fmt.Fprintf(out, "Last Sync: %s (SHA %s)\n", + r.LastSyncAt.UTC().Format("2006-01-02 15:04:05 UTC"), r.LastSyncSHA) + } else { + fmt.Fprintln(out, "Last Sync: (never)") + } + if r.LastSyncError != "" { + fmt.Fprintf(out, "Last Error: %s\n", r.LastSyncError) + } + return nil + }, + } +} + func newReposListCommand() *cobra.Command { return &cobra.Command{ Use: "list", diff --git a/packages/engine/internal/cli/repos_test.go b/packages/engine/internal/cli/repos_test.go index aae4b10..7f1f4e2 100644 --- a/packages/engine/internal/cli/repos_test.go +++ b/packages/engine/internal/cli/repos_test.go @@ -120,3 +120,34 @@ func TestReposList_EmptyState(t *testing.T) { t.Errorf("empty list output: %q", stdout.String()) } } + +func TestReposStatus_ShowsDetails(t *testing.T) { + _, cfg := reposCtx(t) + { + root := NewRootCommand() + var seedStderr bytes.Buffer + root.SetErr(&seedStderr) + root.SetArgs([]string{"repos", "add", "acme", + "--url", "https://example.com/a.git", + "--credential", "pat", + "--database-url", cfg.Database.URL, + }) + if err := root.Execute(); err != nil { + t.Fatalf("seed add: %v\nstderr: %s", err, seedStderr.String()) + } + } + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "status", "acme", "--database-url", cfg.Database.URL}) + if err := root.Execute(); err != nil { + t.Fatalf("status: %v\nstderr: %s", err, stderr.String()) + } + out := stdout.String() + for _, want := range []string{"Name:", "URL:", "Branch:", "Credential:", "Auto-Apply:"} { + if !strings.Contains(out, want) { + t.Errorf("status output missing %q: %s", want, out) + } + } +} From afa1d46cf9bcd1d961e89be9c8ea90d39308bd16 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:18:52 -0400 Subject: [PATCH 15/29] feat(cli): add mantle repos remove Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/cli/repos.go | 30 ++++++++++++ packages/engine/internal/cli/repos_test.go | 53 ++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/packages/engine/internal/cli/repos.go b/packages/engine/internal/cli/repos.go index 106e533..ca1eced 100644 --- a/packages/engine/internal/cli/repos.go +++ b/packages/engine/internal/cli/repos.go @@ -26,6 +26,7 @@ and referenced here by name.`, cmd.AddCommand(newReposAddCommand()) cmd.AddCommand(newReposListCommand()) cmd.AddCommand(newReposStatusCommand()) + cmd.AddCommand(newReposRemoveCommand()) return cmd } @@ -135,6 +136,35 @@ func newReposStatusCommand() *cobra.Command { } } +func newReposRemoveCommand() *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "remove ", + Short: "Unregister a GitOps source repo", + Long: `Unregisters a repo. Any previously applied workflows remain in place — this +command only stops future syncs. Requires --yes to confirm.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !yes { + return fmt.Errorf("refusing to remove %q without --yes", args[0]) + } + store, cleanup, err := newRepoStore(cmd) + if err != nil { + return err + } + defer cleanup() + + if err := store.Delete(cmd.Context(), args[0]); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Removed repo %q\n", args[0]) + return nil + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Confirm deletion (required)") + return cmd +} + func newReposListCommand() *cobra.Command { return &cobra.Command{ Use: "list", diff --git a/packages/engine/internal/cli/repos_test.go b/packages/engine/internal/cli/repos_test.go index 7f1f4e2..bdf5d70 100644 --- a/packages/engine/internal/cli/repos_test.go +++ b/packages/engine/internal/cli/repos_test.go @@ -151,3 +151,56 @@ func TestReposStatus_ShowsDetails(t *testing.T) { } } } + +func TestReposRemove_RequiresYesFlag(t *testing.T) { + _, cfg := reposCtx(t) + { + root := NewRootCommand() + var seedStderr bytes.Buffer + root.SetErr(&seedStderr) + root.SetArgs([]string{"repos", "add", "acme", + "--url", "https://example.com/a.git", + "--credential", "pat", + "--database-url", cfg.Database.URL, + }) + if err := root.Execute(); err != nil { + t.Fatalf("seed add: %v\nstderr: %s", err, seedStderr.String()) + } + } + root := NewRootCommand() + var stderr bytes.Buffer + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "remove", "acme", "--database-url", cfg.Database.URL}) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "--yes") { + t.Errorf("expected --yes error, got %v", err) + } +} + +func TestReposRemove_DeletesRow(t *testing.T) { + _, cfg := reposCtx(t) + { + root := NewRootCommand() + var seedStderr bytes.Buffer + root.SetErr(&seedStderr) + root.SetArgs([]string{"repos", "add", "acme", + "--url", "https://example.com/a.git", + "--credential", "pat", + "--database-url", cfg.Database.URL, + }) + if err := root.Execute(); err != nil { + t.Fatalf("seed add: %v\nstderr: %s", err, seedStderr.String()) + } + } + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs([]string{"repos", "remove", "acme", "--yes", "--database-url", cfg.Database.URL}) + if err := root.Execute(); err != nil { + t.Fatalf("remove: %v\nstderr: %s", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "Removed repo \"acme\"") { + t.Errorf("unexpected stdout: %q", stdout.String()) + } +} From ab59e26ab37c4b7e77b1db1cbcb372119d7add23 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:20:44 -0400 Subject: [PATCH 16/29] docs: document mantle repos CLI and git_sync config --- CLAUDE.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ca7b990..7eb500c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,10 @@ mantle env update # Replace inputs/env on an existing environment mantle env list # List named environments mantle env get # Show environment details (env values redacted; --reveal to unredact, audited) mantle env delete -y # Delete a named environment (requires --yes) +mantle repos add --url --credential # Register a GitOps source repo +mantle repos list # List registered repos with last-sync status +mantle repos status # Show detailed repo status +mantle repos remove -y # Unregister a repo (requires --yes) mantle run --values f.yaml # Run with a values file (inputs + env overrides) mantle run --env # Run against a stored named environment mantle plan --env # Plan; appends resolved inputs/env with source @@ -91,6 +95,25 @@ mantle serve # Start persistent server - **Workflow inputs** (consumed by `inputs.` in CEL): `--input` flags > `--values` file `inputs:` > `--env` named-environment `inputs` > workflow definition `default` - **Env vars** (consumed by `env.` in CEL): `MANTLE_ENV_*` OS vars > `--values` file `env:` > `--env` named-environment `env` > config `env:` section in `mantle.yaml` +## GitOps Config + +Register repos in `mantle.yaml` under `git_sync.repos`: + +```yaml +git_sync: + repos: + - name: acme + url: https://github.com/acme/workflows.git + branch: main + path: / + poll_interval: 60s + credential: github-pat # must reference a secret of type: git + auto_apply: true + prune: true +``` + +Credentials of type `git` accept `token` (for HTTPS), `ssh_key` (for SSH), and optional `username`. At least one of `token` or `ssh_key` is required. + ## License BSL/SSPL-style — source available, no commercial resale of forks. From 431f822466c0ef331460112ee46f60bce220d485 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Fri, 17 Apr 2026 21:27:48 -0400 Subject: [PATCH 17/29] fix: address pre-push reviewer feedback for git sync foundation - Clarify that Plan A ships registry + CLI only (sync engine lands later) - Rewrite --prune flag help to state the positive behavior - Mark webhook_secret as sensitive in Repo struct - Reject repo URLs that embed credentials inline Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 2 ++ packages/engine/internal/cli/repos.go | 12 +++++------ packages/engine/internal/config/config.go | 8 ++++--- packages/engine/internal/repo/store.go | 21 ++++++++++++++++++ packages/engine/internal/repo/store_test.go | 24 +++++++++++++++++++++ packages/engine/internal/repo/types.go | 4 ++++ 6 files changed, 62 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7eb500c..4e3b5f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,6 +114,8 @@ git_sync: Credentials of type `git` accept `token` (for HTTPS), `ssh_key` (for SSH), and optional `username`. At least one of `token` or `ssh_key` is required. +> Plan A ships the repo registry and CLI only. The sync engine that consumes this block — pulling from the sidecar, validating, and applying workflow YAML — ships in the next milestone. Registering a repo today has no runtime effect yet beyond persistence. + ## License BSL/SSPL-style — source available, no commercial resale of forks. diff --git a/packages/engine/internal/cli/repos.go b/packages/engine/internal/cli/repos.go index ca1eced..cec3ba9 100644 --- a/packages/engine/internal/cli/repos.go +++ b/packages/engine/internal/cli/repos.go @@ -17,11 +17,11 @@ func newReposCommand() *cobra.Command { cmd := &cobra.Command{ Use: "repos", Short: "Manage GitOps workflow source repositories", - Long: `Registered repos are periodically pulled by the git-sync sidecar and -their .yaml workflow definitions are applied to this Mantle instance. - -Auth material is stored in a "git" credential type (` + "`mantle secrets create --type git`" + `) -and referenced here by name.`, + Long: `Registers GitOps source repositories whose workflow YAML definitions will be +synced into this Mantle instance. This command manages the registry; the sync +engine itself (sidecar, file discovery, validate/plan/apply) ships in a later +milestone. Auth material is stored in a "git" credential type +(` + "`mantle secrets create --type git`" + `) and referenced here by name.`, } cmd.AddCommand(newReposAddCommand()) cmd.AddCommand(newReposListCommand()) @@ -88,7 +88,7 @@ credential must already exist and be of type "git".`, cmd.Flags().StringVar(&pollInterval, "poll-interval", "60s", "Interval between syncs (Go duration, min 10s)") cmd.Flags().StringVar(&credential, "credential", "", "Git credential name (required)") cmd.Flags().BoolVar(&autoApply, "auto-apply", true, "Automatically apply changes (false = plan-only)") - cmd.Flags().BoolVar(&prune, "prune", true, "Disable workflows removed from the repo") + cmd.Flags().BoolVar(&prune, "prune", true, "When true, workflows deleted from the repo are disabled in Mantle") _ = cmd.MarkFlagRequired("url") _ = cmd.MarkFlagRequired("credential") diff --git a/packages/engine/internal/config/config.go b/packages/engine/internal/config/config.go index f933d32..fba505a 100644 --- a/packages/engine/internal/config/config.go +++ b/packages/engine/internal/config/config.go @@ -68,9 +68,11 @@ type StorageConfig struct { Retention string `mapstructure:"retention"` // Duration string, e.g. "24h". Empty = no auto-cleanup. } -// GitSyncConfig configures GitOps-style workflow sync (issue #16). Each -// entry under Repos is materialized into a git_repos row at startup so -// the registry stays consistent with mantle.yaml. +// GitSyncConfig configures GitOps-style workflow sync (issue #16). Plan A +// parses and exposes this block so operators can version their repo +// registry in mantle.yaml; the startup reconciliation that materializes +// entries into git_repos rows ships with the sync engine in a later +// milestone. type GitSyncConfig struct { Repos []GitSyncRepo `mapstructure:"repos"` } diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go index c5b08cf..a8840fd 100644 --- a/packages/engine/internal/repo/store.go +++ b/packages/engine/internal/repo/store.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "net/url" "time" "github.com/dvflw/mantle/internal/audit" @@ -58,6 +59,9 @@ func (s *Store) Create(ctx context.Context, p CreateParams) (*Repo, error) { if p.Credential == "" { return nil, fmt.Errorf("credential is required") } + if err := validateURL(p.URL); err != nil { + return nil, err + } teamID := auth.TeamIDFromContext(ctx) @@ -250,6 +254,23 @@ func (s *Store) Delete(ctx context.Context, name string) error { return nil } +// validateURL rejects URLs that embed credentials inline (the `user:pass@host` +// form). Operators must put credential material in a "git" secret and +// reference it via the Credential field, never inline in the URL, so we +// don't risk persisting or displaying tokens that live in git_repos.url. +func validateURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid url %q: %w", raw, err) + } + if u.User != nil { + if _, hasPassword := u.User.Password(); hasPassword { + return fmt.Errorf("repo url must not embed credentials — use the --credential flag instead") + } + } + return nil +} + // Get retrieves a repo by name within the current team scope. Returns // ErrNotFound when no row matches. func (s *Store) Get(ctx context.Context, name string) (*Repo, error) { diff --git a/packages/engine/internal/repo/store_test.go b/packages/engine/internal/repo/store_test.go index 3675447..982d4e9 100644 --- a/packages/engine/internal/repo/store_test.go +++ b/packages/engine/internal/repo/store_test.go @@ -267,3 +267,27 @@ func TestStore_Delete_NotFound(t *testing.T) { t.Errorf("expected ErrNotFound, got %v", err) } } + +func TestStore_Create_RejectsURLWithPassword(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + _, err := store.Create(ctx, CreateParams{ + Name: "leaky", URL: "https://user:secret@github.com/acme/wf.git", + Branch: "main", Path: "/", PollInterval: "60s", Credential: "pat", + }) + if err == nil { + t.Error("expected url-credential rejection") + } +} + +func TestStore_Create_AllowsURLWithUsernameOnly(t *testing.T) { + store := newTestStore(t) + ctx := defaultCtx() + // GitHub HTTPS URLs often contain a username hint but no password — fine. + if _, err := store.Create(ctx, CreateParams{ + Name: "ok", URL: "https://acme@github.com/acme/wf.git", + Branch: "main", Path: "/", PollInterval: "60s", Credential: "pat", + }); err != nil { + t.Errorf("username-only URL should be allowed: %v", err) + } +} diff --git a/packages/engine/internal/repo/types.go b/packages/engine/internal/repo/types.go index d69ec35..9bfd928 100644 --- a/packages/engine/internal/repo/types.go +++ b/packages/engine/internal/repo/types.go @@ -36,6 +36,10 @@ type Repo struct { LastSyncSHA string LastSyncAt *time.Time LastSyncError string + // WebhookSecret is the HMAC shared secret used to verify inbound push + // webhooks from the git provider. SECURITY: this value is sensitive and + // MUST NOT be printed to terminal or logs. The CLI intentionally never + // renders it; any new caller must follow the same discipline. WebhookSecret string CreatedAt time.Time UpdatedAt time.Time From dc42f41fd2d3b33cf6b8874c67c3de2a4c31a910 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 18 Apr 2026 23:51:06 -0400 Subject: [PATCH 18/29] fix(repo): reject any userinfo in URL, not just passwords GitHub PATs are valid as a username in HTTPS URLs (user@host), so the previous check (hasPassword only) left a bypass. Now any u.User != nil is rejected. Renamed TestStore_Create_AllowsURLWithUsernameOnly to _RejectsURLWithUsernameOnly and flipped the assertion. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/repo/store.go | 14 +++++++------- packages/engine/internal/repo/store_test.go | 14 ++++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go index a8840fd..f9c9072 100644 --- a/packages/engine/internal/repo/store.go +++ b/packages/engine/internal/repo/store.go @@ -254,19 +254,19 @@ func (s *Store) Delete(ctx context.Context, name string) error { return nil } -// validateURL rejects URLs that embed credentials inline (the `user:pass@host` -// form). Operators must put credential material in a "git" secret and -// reference it via the Credential field, never inline in the URL, so we -// don't risk persisting or displaying tokens that live in git_repos.url. +// validateURL rejects URLs that embed any userinfo (user or user:pass). +// GitHub PATs are valid as a username in HTTPS URLs, so even a bare +// "user@host" form must be rejected. Operators must store credential +// material in a "git" secret and reference it via the Credential field, +// never inline in the URL, so we don't risk persisting or displaying +// tokens that live in git_repos.url. func validateURL(raw string) error { u, err := url.Parse(raw) if err != nil { return fmt.Errorf("invalid url %q: %w", raw, err) } if u.User != nil { - if _, hasPassword := u.User.Password(); hasPassword { - return fmt.Errorf("repo url must not embed credentials — use the --credential flag instead") - } + return fmt.Errorf("repo url must not embed credentials — use the --credential flag instead") } return nil } diff --git a/packages/engine/internal/repo/store_test.go b/packages/engine/internal/repo/store_test.go index 982d4e9..2ca3fbb 100644 --- a/packages/engine/internal/repo/store_test.go +++ b/packages/engine/internal/repo/store_test.go @@ -280,14 +280,16 @@ func TestStore_Create_RejectsURLWithPassword(t *testing.T) { } } -func TestStore_Create_AllowsURLWithUsernameOnly(t *testing.T) { +func TestStore_Create_RejectsURLWithUsernameOnly(t *testing.T) { store := newTestStore(t) ctx := defaultCtx() - // GitHub HTTPS URLs often contain a username hint but no password — fine. - if _, err := store.Create(ctx, CreateParams{ - Name: "ok", URL: "https://acme@github.com/acme/wf.git", + // GitHub PATs are valid in the username position for HTTPS — we still reject + // them so no credential material ever lives in git_repos.url. + _, err := store.Create(ctx, CreateParams{ + Name: "leaky2", URL: "https://acme@github.com/acme/wf.git", Branch: "main", Path: "/", PollInterval: "60s", Credential: "pat", - }); err != nil { - t.Errorf("username-only URL should be allowed: %v", err) + }) + if err == nil { + t.Error("expected url-credential rejection for username-only URL") } } From f64e1d697649700fa4de55a600fbdfab3eb1cf8a Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 18 Apr 2026 23:51:36 -0400 Subject: [PATCH 19/29] fix(repo): include last_sync_* and webhook_secret in Update RETURNING clause The Update method was returning a partial Repo that silently dropped last_sync_sha/at/error and webhook_secret. The RETURNING clause now mirrors Get's SELECT list, and the subsequent scan populates the nullable fields using sql.NullString/NullTime with the same pattern as Get. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/repo/store.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go index f9c9072..feede98 100644 --- a/packages/engine/internal/repo/store.go +++ b/packages/engine/internal/repo/store.go @@ -180,17 +180,21 @@ func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo, defer tx.Rollback() //nolint:errcheck var r Repo + var lastSyncAt sql.NullTime + var lastSyncSHA, lastSyncError, webhookSecret sql.NullString err = tx.QueryRowContext(ctx, `UPDATE git_repos SET branch = $3, path = $4, poll_interval = $5, credential = $6, auto_apply = $7, prune = $8, enabled = $9, updated_at = NOW() WHERE name = $1 AND team_id = $2 RETURNING id, name, url, branch, path, poll_interval, credential, - auto_apply, prune, enabled, created_at, updated_at`, + auto_apply, prune, enabled, last_sync_sha, last_sync_at, + last_sync_error, webhook_secret, created_at, updated_at`, name, teamID, p.Branch, p.Path, p.PollInterval, p.Credential, p.AutoApply, p.Prune, p.Enabled, ).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval, &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, + &lastSyncSHA, &lastSyncAt, &lastSyncError, &webhookSecret, &r.CreatedAt, &r.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("%w: %q", ErrNotFound, name) @@ -198,6 +202,19 @@ func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo, if err != nil { return nil, fmt.Errorf("updating repo %q: %w", name, err) } + if lastSyncSHA.Valid { + r.LastSyncSHA = lastSyncSHA.String + } + if lastSyncAt.Valid { + t := lastSyncAt.Time + r.LastSyncAt = &t + } + if lastSyncError.Valid { + r.LastSyncError = lastSyncError.String + } + if webhookSecret.Valid { + r.WebhookSecret = webhookSecret.String + } if err := audit.EmitTx(ctx, tx, audit.Event{ Timestamp: time.Now(), From ec6f0ccfbd3520ca9b286442b2c8a009841b3f30 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 18 Apr 2026 23:52:04 -0400 Subject: [PATCH 20/29] fix(repo): preserve existing Branch/Path in Update when caller passes empty Empty Branch/Path in UpdateParams previously overwrote the stored value with an empty string. Now a CASE expression in the SQL keeps the stored value when the caller passes ''. This is equivalent to Option B (preserve-on-empty) without a second round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/repo/store.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go index feede98..8df4f26 100644 --- a/packages/engine/internal/repo/store.go +++ b/packages/engine/internal/repo/store.go @@ -162,7 +162,9 @@ type UpdateParams struct { } // Update replaces the mutable fields of a repo by name and emits a -// repo.updated audit event in the same transaction. +// repo.updated audit event in the same transaction. Empty Branch/Path +// are treated as "keep existing" — the SQL preserves the stored value +// rather than overwriting it with an empty string. func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo, error) { if err := ValidatePollInterval(p.PollInterval); err != nil { return nil, err @@ -184,7 +186,9 @@ func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo, var lastSyncSHA, lastSyncError, webhookSecret sql.NullString err = tx.QueryRowContext(ctx, `UPDATE git_repos - SET branch = $3, path = $4, poll_interval = $5, credential = $6, + SET branch = CASE WHEN $3 = '' THEN branch ELSE $3 END, + path = CASE WHEN $4 = '' THEN path ELSE $4 END, + poll_interval = $5, credential = $6, auto_apply = $7, prune = $8, enabled = $9, updated_at = NOW() WHERE name = $1 AND team_id = $2 RETURNING id, name, url, branch, path, poll_interval, credential, From 1d123a3ac0d3f62d18ea08acc4684a0b1d2bb836 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 18 Apr 2026 23:52:12 -0400 Subject: [PATCH 21/29] fix(migration): add ON DELETE RESTRICT to git_repos.team_id FK Without an explicit action the FK defaulted to NO ACTION (functionally RESTRICT but implicit). Adding RESTRICT makes the intent unambiguous: deleting a team with registered repos is a hard error, not silent cascade. Operators must remove repos before removing the team. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/db/migrations/019_git_repos.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine/internal/db/migrations/019_git_repos.sql b/packages/engine/internal/db/migrations/019_git_repos.sql index 3a05d64..1717542 100644 --- a/packages/engine/internal/db/migrations/019_git_repos.sql +++ b/packages/engine/internal/db/migrations/019_git_repos.sql @@ -10,7 +10,7 @@ -- from the repo URL because multiple teams may share the same upstream. CREATE TABLE git_repos ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - team_id UUID NOT NULL REFERENCES teams(id) DEFAULT '00000000-0000-0000-0000-000000000001', + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE RESTRICT DEFAULT '00000000-0000-0000-0000-000000000001', name TEXT NOT NULL, url TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main', From fcad25d6779c5235b32ae9ebe471784706951032 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 18 Apr 2026 23:53:52 -0400 Subject: [PATCH 22/29] fix(config): validate git_sync.repos entries in Load Load now iterates cfg.GitSync.Repos and validates each entry: - repo.ValidateName for the name field - url is required and must not embed userinfo (same rule as store.Create) - repo.ValidatePollInterval when poll_interval is non-empty No import cycle: internal/repo production code does not import internal/config (only repo's _test.go does, which is excluded from the dependency graph). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/config/config.go | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/engine/internal/config/config.go b/packages/engine/internal/config/config.go index fba505a..4067221 100644 --- a/packages/engine/internal/config/config.go +++ b/packages/engine/internal/config/config.go @@ -15,6 +15,7 @@ import ( "github.com/dvflw/mantle/internal/budget" "github.com/dvflw/mantle/internal/dbdefaults" "github.com/dvflw/mantle/internal/netutil" + "github.com/dvflw/mantle/internal/repo" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -353,6 +354,30 @@ func Load(cmd *cobra.Command) (*Config, error) { cfg.Env = normalized } + // Validate git_sync.repos entries. Each repo must have a valid name, + // a valid poll_interval, and a URL free of embedded credentials. + // The URL check is intentionally a subset of the store's validateURL: + // it only needs to catch the "user@host" form that people may put in a + // config file. Full store-level validation runs again at registration time. + for i, r := range cfg.GitSync.Repos { + if err := repo.ValidateName(r.Name); err != nil { + return nil, fmt.Errorf("git_sync.repos[%d]: %w", i, err) + } + if r.URL == "" { + return nil, fmt.Errorf("git_sync.repos[%d] (%q): url is required", i, r.Name) + } + if parsed, err := url.Parse(r.URL); err != nil { + return nil, fmt.Errorf("git_sync.repos[%d] (%q): invalid url: %w", i, r.Name, err) + } else if parsed.User != nil { + return nil, fmt.Errorf("git_sync.repos[%d] (%q): url must not embed credentials", i, r.Name) + } + if r.PollInterval != "" { + if err := repo.ValidatePollInterval(r.PollInterval); err != nil { + return nil, fmt.Errorf("git_sync.repos[%d] (%q): %w", i, r.Name, err) + } + } + } + // Validate budget reset_day range. if cfg.Engine.Budget.ResetDay < 1 || cfg.Engine.Budget.ResetDay > 28 { if cfg.Engine.Budget.ResetMode == budget.ResetModeRolling { From def990f2bbc54e1c1b0c0e6c4e59dd35ecdfa49a Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sun, 19 Apr 2026 21:49:20 -0400 Subject: [PATCH 23/29] fix(migration): rework git_repos enabled index to composite (team_id, enabled) Rename idx_git_repos_enabled to idx_git_repos_team_enabled and make it a composite (team_id, enabled) partial index. Queries that filter by team and enabled status now hit a single index instead of intersecting two. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/db/migrations/019_git_repos.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/engine/internal/db/migrations/019_git_repos.sql b/packages/engine/internal/db/migrations/019_git_repos.sql index 1717542..ab36d15 100644 --- a/packages/engine/internal/db/migrations/019_git_repos.sql +++ b/packages/engine/internal/db/migrations/019_git_repos.sql @@ -30,7 +30,7 @@ CREATE TABLE git_repos ( ); CREATE INDEX idx_git_repos_team ON git_repos(team_id); -CREATE INDEX idx_git_repos_enabled ON git_repos(enabled) WHERE enabled = TRUE; +CREATE INDEX idx_git_repos_team_enabled ON git_repos(team_id, enabled) WHERE enabled = TRUE; -- +goose Down DROP TABLE IF EXISTS git_repos; From 2d94a36916b7898949eb6bea2713fb6760db9979 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sun, 19 Apr 2026 21:51:02 -0400 Subject: [PATCH 24/29] fix(cli): add Long+Example to repos status/list; move repos to admin group; comment GitSync env binding - repos status and repos list were missing Long descriptions and Example blocks; add both, styled to match repos add. - newReposCommand belongs with admin/gitops infra config, not the workflow lifecycle group; move it to the admin group in root.go. - Add inline comment on GitSyncConfig.Repos explaining why no BindEnv calls are present (Viper cannot bind list-valued env vars). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/cli/repos.go | 14 ++++++++++++-- packages/engine/internal/cli/root.go | 2 +- packages/engine/internal/config/config.go | 3 +++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/engine/internal/cli/repos.go b/packages/engine/internal/cli/repos.go index cec3ba9..7fe69b7 100644 --- a/packages/engine/internal/cli/repos.go +++ b/packages/engine/internal/cli/repos.go @@ -99,7 +99,12 @@ func newReposStatusCommand() *cobra.Command { return &cobra.Command{ Use: "status ", Short: "Show detailed status for a registered repo", - Args: cobra.ExactArgs(1), + Long: `Displays all persisted fields for a registered repository, including the +URL, branch, polling interval, credential reference, enable state, and the +outcome of the most recent sync attempt (SHA, timestamp, and any error).`, + Example: ` mantle repos status acme + mantle repos status staging --database-url postgres://localhost/mantle`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { store, cleanup, err := newRepoStore(cmd) if err != nil { @@ -169,7 +174,12 @@ func newReposListCommand() *cobra.Command { return &cobra.Command{ Use: "list", Short: "List all registered GitOps repos", - Args: cobra.NoArgs, + Long: `Prints a table of all repositories registered for the current team, ordered +by name. Columns include the repo URL, target branch, auto-apply flag, enable +state, and timestamp of the last successful sync.`, + Example: ` mantle repos list + mantle repos list --database-url postgres://localhost/mantle`, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { store, cleanup, err := newRepoStore(cmd) if err != nil { diff --git a/packages/engine/internal/cli/root.go b/packages/engine/internal/cli/root.go index cfb25b7..00c869a 100644 --- a/packages/engine/internal/cli/root.go +++ b/packages/engine/internal/cli/root.go @@ -56,7 +56,6 @@ Full documentation: https://mantle.dvflw.co/docs`, newLogsCommand(), newStatusCommand(), newEnvCommand(), - newReposCommand(), ) // Server & triggers. @@ -83,6 +82,7 @@ Full documentation: https://mantle.dvflw.co/docs`, newLibraryCommand(), newCleanupCommand(), newBudgetCommand(), + newReposCommand(), ) // Info. diff --git a/packages/engine/internal/config/config.go b/packages/engine/internal/config/config.go index 4067221..ea8bf61 100644 --- a/packages/engine/internal/config/config.go +++ b/packages/engine/internal/config/config.go @@ -75,6 +75,9 @@ type StorageConfig struct { // entries into git_repos rows ships with the sync engine in a later // milestone. type GitSyncConfig struct { + // Repos is list-valued; Viper cannot bind individual list elements to env + // vars, so no BindEnv calls are present for git_sync.repos — this is + // intentional. Repos are configured exclusively via the config file. Repos []GitSyncRepo `mapstructure:"repos"` } From d52114bf67023a4112175c61a9322f75fc50a277 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sun, 19 Apr 2026 21:51:57 -0400 Subject: [PATCH 25/29] test: cover Path/PollInterval/Prune in GitSync config test; add empty-string git cred case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestLoadConfig_GitSyncRepos was only asserting Name, URL, Branch, Credential, AutoApply. Add explicit checks for Path, PollInterval, and Prune so the full parsed struct is verified. - TestGitCredentialType_ValidateRequiresTokenOrSSHKey gains a case for {"token": "", "ssh_key": ""} — present-but-empty values must also fail the RequireAtLeastOne check. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/config/config_test.go | 9 +++++++++ packages/engine/internal/secret/types_test.go | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/packages/engine/internal/config/config_test.go b/packages/engine/internal/config/config_test.go index 146f212..42b4dc5 100644 --- a/packages/engine/internal/config/config_test.go +++ b/packages/engine/internal/config/config_test.go @@ -502,4 +502,13 @@ git_sync: r.Branch != "main" || r.Credential != "github-pat" || !r.AutoApply { t.Errorf("parsed repo: %+v", r) } + if r.Path != "/" { + t.Errorf("Path: got %q, want %q", r.Path, "/") + } + if r.PollInterval != "60s" { + t.Errorf("PollInterval: got %q, want %q", r.PollInterval, "60s") + } + if !r.Prune { + t.Errorf("Prune: got false, want true") + } } diff --git a/packages/engine/internal/secret/types_test.go b/packages/engine/internal/secret/types_test.go index 409e9d4..9fec174 100644 --- a/packages/engine/internal/secret/types_test.go +++ b/packages/engine/internal/secret/types_test.go @@ -112,6 +112,10 @@ func TestGitCredentialType_ValidateRequiresTokenOrSSHKey(t *testing.T) { if err := ct.Validate(map[string]string{"username": "git"}); err == nil { t.Error("expected error when both token and ssh_key are empty") } + // Explicitly empty strings for both — should also fail. + if err := ct.Validate(map[string]string{"token": "", "ssh_key": ""}); err == nil { + t.Error("expected error when token and ssh_key are both present but empty") + } // token-only — should succeed. if err := ct.Validate(map[string]string{"token": "ghp_xxx"}); err != nil { t.Errorf("token-only: unexpected error %v", err) From 41e85acdc88b21f723c6340fd53573bdec8feda5 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sun, 19 Apr 2026 21:54:07 -0400 Subject: [PATCH 26/29] refactor(test): tighten empty-state assertion; extract seedRepo helper - TestReposList_EmptyState: replace strings.Contains with an exact strings.TrimSpace equality check so stray whitespace or extra output would surface as a failure rather than passing silently. - Extract the repeated four-line "seed a repo via add" block into a seedRepo(t, cfg, name) helper; replace all four inline copies. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/cli/repos_test.go | 80 +++++++--------------- 1 file changed, 26 insertions(+), 54 deletions(-) diff --git a/packages/engine/internal/cli/repos_test.go b/packages/engine/internal/cli/repos_test.go index bdf5d70..d00e4c8 100644 --- a/packages/engine/internal/cli/repos_test.go +++ b/packages/engine/internal/cli/repos_test.go @@ -57,6 +57,23 @@ func reposCtx(t *testing.T) (context.Context, *config.Config) { return ctx, cfg } +// seedRepo registers a single repo via the add command, failing the test on +// error. Shared by tests that need at least one row in git_repos. +func seedRepo(t *testing.T, cfg *config.Config, name string) { + t.Helper() + root := NewRootCommand() + var seedStderr bytes.Buffer + root.SetErr(&seedStderr) + root.SetArgs([]string{"repos", "add", name, + "--url", "https://example.com/a.git", + "--credential", "pat", + "--database-url", cfg.Database.URL, + }) + if err := root.Execute(); err != nil { + t.Fatalf("seedRepo(%q): %v\nstderr: %s", name, err, seedStderr.String()) + } +} + func TestReposAdd_PersistsRepo(t *testing.T) { _, cfg := reposCtx(t) root := NewRootCommand() @@ -78,20 +95,8 @@ func TestReposAdd_PersistsRepo(t *testing.T) { func TestReposList_ShowsRegisteredRepos(t *testing.T) { _, cfg := reposCtx(t) - // Seed one row by calling the add command. - { - root := NewRootCommand() - var seedStderr bytes.Buffer - root.SetErr(&seedStderr) - root.SetArgs([]string{"repos", "add", "acme", - "--url", "https://example.com/a.git", - "--credential", "pat", - "--database-url", cfg.Database.URL, - }) - if err := root.Execute(); err != nil { - t.Fatalf("seed add: %v\nstderr: %s", err, seedStderr.String()) - } - } + seedRepo(t, cfg, "acme") + root := NewRootCommand() var stdout, stderr bytes.Buffer root.SetOut(&stdout) @@ -116,26 +121,15 @@ func TestReposList_EmptyState(t *testing.T) { if err := root.Execute(); err != nil { t.Fatalf("list: %v\nstderr: %s", err, stderr.String()) } - if !strings.Contains(stdout.String(), "(no repos)") { + if strings.TrimSpace(stdout.String()) != "(no repos)" { t.Errorf("empty list output: %q", stdout.String()) } } func TestReposStatus_ShowsDetails(t *testing.T) { _, cfg := reposCtx(t) - { - root := NewRootCommand() - var seedStderr bytes.Buffer - root.SetErr(&seedStderr) - root.SetArgs([]string{"repos", "add", "acme", - "--url", "https://example.com/a.git", - "--credential", "pat", - "--database-url", cfg.Database.URL, - }) - if err := root.Execute(); err != nil { - t.Fatalf("seed add: %v\nstderr: %s", err, seedStderr.String()) - } - } + seedRepo(t, cfg, "acme") + root := NewRootCommand() var stdout, stderr bytes.Buffer root.SetOut(&stdout) @@ -154,19 +148,8 @@ func TestReposStatus_ShowsDetails(t *testing.T) { func TestReposRemove_RequiresYesFlag(t *testing.T) { _, cfg := reposCtx(t) - { - root := NewRootCommand() - var seedStderr bytes.Buffer - root.SetErr(&seedStderr) - root.SetArgs([]string{"repos", "add", "acme", - "--url", "https://example.com/a.git", - "--credential", "pat", - "--database-url", cfg.Database.URL, - }) - if err := root.Execute(); err != nil { - t.Fatalf("seed add: %v\nstderr: %s", err, seedStderr.String()) - } - } + seedRepo(t, cfg, "acme") + root := NewRootCommand() var stderr bytes.Buffer root.SetErr(&stderr) @@ -179,19 +162,8 @@ func TestReposRemove_RequiresYesFlag(t *testing.T) { func TestReposRemove_DeletesRow(t *testing.T) { _, cfg := reposCtx(t) - { - root := NewRootCommand() - var seedStderr bytes.Buffer - root.SetErr(&seedStderr) - root.SetArgs([]string{"repos", "add", "acme", - "--url", "https://example.com/a.git", - "--credential", "pat", - "--database-url", cfg.Database.URL, - }) - if err := root.Execute(); err != nil { - t.Fatalf("seed add: %v\nstderr: %s", err, seedStderr.String()) - } - } + seedRepo(t, cfg, "acme") + root := NewRootCommand() var stdout, stderr bytes.Buffer root.SetOut(&stdout) From be683a31bc8887b0bef416bdb25d927648563969 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sun, 19 Apr 2026 21:55:03 -0400 Subject: [PATCH 27/29] fix(repo): tighten validRepoNamePattern to reject trailing hyphens and underscores Change from ^[a-z0-9][a-z0-9_-]*$ to ^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$ so that names like "my-" or "repo_" are rejected. The updated pattern requires the last character to be alphanumeric while still permitting single-character names (pure alnum). Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/engine/internal/repo/types.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/engine/internal/repo/types.go b/packages/engine/internal/repo/types.go index 9bfd928..2b9f1c6 100644 --- a/packages/engine/internal/repo/types.go +++ b/packages/engine/internal/repo/types.go @@ -17,8 +17,9 @@ import ( const maxRepoNameLength = 63 // validRepoNamePattern enforces DNS-label-like names: lowercase -// alphanumerics, underscores, and hyphens, starting with an alphanumeric. -var validRepoNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) +// alphanumerics, underscores, and hyphens, starting and ending with an +// alphanumeric. Single-character names (pure alnum) are allowed. +var validRepoNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`) // Repo represents a registered git repository that Mantle pulls workflow // definitions from. From af33a5d7dfb07dc458ab69cf435d1b9d81a90457 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sun, 19 Apr 2026 22:10:15 -0400 Subject: [PATCH 28/29] fix(config): inline git_sync validators to break test import cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config → repo → auth chain caused `go test` to fail in both ./internal/auth and ./internal/repo with "import cycle not allowed in test". Fix by inlining validateGitSyncName, validateGitSyncPollInterval, and validateGitSyncURL as unexported helpers in config.go, removing the import edge from config → repo. The canonical exported symbols in internal/repo/types.go are unchanged. Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/config/config.go | 61 ++++++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/engine/internal/config/config.go b/packages/engine/internal/config/config.go index ea8bf61..4236cbf 100644 --- a/packages/engine/internal/config/config.go +++ b/packages/engine/internal/config/config.go @@ -9,13 +9,13 @@ import ( "log/slog" "net/url" "os" + "regexp" "strings" "time" "github.com/dvflw/mantle/internal/budget" "github.com/dvflw/mantle/internal/dbdefaults" "github.com/dvflw/mantle/internal/netutil" - "github.com/dvflw/mantle/internal/repo" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -363,19 +363,17 @@ func Load(cmd *cobra.Command) (*Config, error) { // it only needs to catch the "user@host" form that people may put in a // config file. Full store-level validation runs again at registration time. for i, r := range cfg.GitSync.Repos { - if err := repo.ValidateName(r.Name); err != nil { + if err := validateGitSyncName(r.Name); err != nil { return nil, fmt.Errorf("git_sync.repos[%d]: %w", i, err) } if r.URL == "" { return nil, fmt.Errorf("git_sync.repos[%d] (%q): url is required", i, r.Name) } - if parsed, err := url.Parse(r.URL); err != nil { - return nil, fmt.Errorf("git_sync.repos[%d] (%q): invalid url: %w", i, r.Name, err) - } else if parsed.User != nil { - return nil, fmt.Errorf("git_sync.repos[%d] (%q): url must not embed credentials", i, r.Name) + if err := validateGitSyncURL(r.URL); err != nil { + return nil, fmt.Errorf("git_sync.repos[%d] (%q): %w", i, r.Name, err) } if r.PollInterval != "" { - if err := repo.ValidatePollInterval(r.PollInterval); err != nil { + if err := validateGitSyncPollInterval(r.PollInterval); err != nil { return nil, fmt.Errorf("git_sync.repos[%d] (%q): %w", i, r.Name, err) } } @@ -415,3 +413,52 @@ func Load(cmd *cobra.Command) (*Config, error) { return &cfg, nil } + +// gitSyncNamePattern enforces DNS-label-like names: lowercase alphanumerics, +// underscores, and hyphens, starting and ending with an alphanumeric. +// This is a local copy of the pattern in packages/engine/internal/repo/types.go +// kept here to avoid a config → repo → auth test-time import cycle. +var gitSyncNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`) + +// validateGitSyncName is a local copy of repo.ValidateName to avoid a +// config → repo → auth test-time import cycle. Keep in sync with the +// canonical version in packages/engine/internal/repo/types.go. +func validateGitSyncName(name string) error { + if name == "" { + return fmt.Errorf("repo name is required") + } + if len(name) > 63 { + return fmt.Errorf("invalid repo name %q: length %d exceeds 63-char cap", name, len(name)) + } + if !gitSyncNamePattern.MatchString(name) { + return fmt.Errorf("invalid repo name %q: must match %s", name, gitSyncNamePattern.String()) + } + return nil +} + +// validateGitSyncPollInterval is a local copy of repo.ValidatePollInterval. +// See validateGitSyncName for the rationale. +func validateGitSyncPollInterval(interval string) error { + d, err := time.ParseDuration(interval) + if err != nil { + return fmt.Errorf("invalid poll_interval %q: %w", interval, err) + } + if d < 10*time.Second { + return fmt.Errorf("poll_interval %q below 10s minimum", interval) + } + return nil +} + +// validateGitSyncURL rejects any URL that embeds credentials in the +// userinfo component (https://user@host or https://user:pass@host). All +// auth material must flow through the Credential reference. +func validateGitSyncURL(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("invalid url %q: %w", raw, err) + } + if u.User != nil { + return fmt.Errorf("repo url must not embed credentials — use the Credential field instead") + } + return nil +} From 4a09c8e1554009c01d19f521d69db0899baf0e37 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 02:14:29 +0000 Subject: [PATCH 29/29] fix(git-sync): address PR #133 review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline (required): - config.go: validate that each git_sync.repos entry has a non-empty credential; the DB has credential NOT NULL, so a missing value would fail at persistence time with a cryptic constraint error - 019_git_repos.sql: drop webhook_secret column — storing an HMAC secret as plaintext TEXT is a security risk; Plan C will add a proper migration that stores it as an encrypted credential reference - store.go, types.go: remove webhook_secret from RETURNING/SELECT queries, scans, and the Repo struct to match the updated schema Nitpicks: - audit.go: align repo action verbs with the rest of the domain: repo.added → repo.created, repo.removed → repo.deleted (constant identifiers unchanged to minimise callsite churn) - repos_test.go: remove unused context return from reposCtx; callers already discarded it with _ and pass the DB URL via --database-url flags - config_test.go: add TestLoad_GitSyncRepos_Validation with table-driven negative cases covering invalid name, missing url, embedded credentials, url with no scheme/host, missing credential, and poll_interval below 10s - config.go: tighten validateGitSyncURL to reject strings that url.Parse accepts as relative references but lack both scheme and host, unless they match the scp-style SSH format (git@host:path) - 019_git_repos.sql: add comment explaining why credential is a plain name reference rather than a FK — prevents registration before the credential exists, which is a common IaC bootstrapping pattern https://claude.ai/code/session_01MqwQDFpdQTksPDHrMi4cKB --- packages/engine/internal/audit/audit.go | 4 +- packages/engine/internal/cli/repos_test.go | 24 +++--- packages/engine/internal/config/config.go | 21 ++++- .../engine/internal/config/config_test.go | 81 +++++++++++++++++++ .../internal/db/migrations/019_git_repos.sql | 16 +++- packages/engine/internal/repo/store.go | 18 ++--- packages/engine/internal/repo/types.go | 5 -- 7 files changed, 130 insertions(+), 39 deletions(-) diff --git a/packages/engine/internal/audit/audit.go b/packages/engine/internal/audit/audit.go index 44ec091..dfc7433 100644 --- a/packages/engine/internal/audit/audit.go +++ b/packages/engine/internal/audit/audit.go @@ -63,9 +63,9 @@ const ( ActionEnvironmentRevealed Action = "environment.revealed" // Git repo operations. - ActionRepoAdded Action = "repo.added" + ActionRepoAdded Action = "repo.created" ActionRepoUpdated Action = "repo.updated" - ActionRepoRemoved Action = "repo.removed" + ActionRepoRemoved Action = "repo.deleted" ) // Resource identifies the target of an audit event. diff --git a/packages/engine/internal/cli/repos_test.go b/packages/engine/internal/cli/repos_test.go index d00e4c8..078247d 100644 --- a/packages/engine/internal/cli/repos_test.go +++ b/packages/engine/internal/cli/repos_test.go @@ -2,7 +2,6 @@ package cli import ( "bytes" - "context" "os" "strings" "testing" @@ -17,10 +16,11 @@ import ( ) // reposCtx spins up a Postgres container, runs migrations, and returns a -// context with the DatabaseConfig attached so `newRepoStore` can load it. -func reposCtx(t *testing.T) (context.Context, *config.Config) { +// *config.Config with the DatabaseConfig attached. Callers pass the database +// URL via --database-url flags; the config is used only to read that URL. +func reposCtx(t *testing.T) *config.Config { t.Helper() - bg := context.Background() + bg := t.Context() pgContainer, err := postgres.Run(bg, dbdefaults.PostgresImage, postgres.WithDatabase(dbdefaults.TestDatabase), @@ -52,9 +52,7 @@ func reposCtx(t *testing.T) (context.Context, *config.Config) { if err := db.Migrate(bg, conn); err != nil { t.Fatalf("db.Migrate: %v", err) } - cfg := &config.Config{Database: dbCfg} - ctx := config.WithContext(bg, cfg) - return ctx, cfg + return &config.Config{Database: dbCfg} } // seedRepo registers a single repo via the add command, failing the test on @@ -75,7 +73,7 @@ func seedRepo(t *testing.T, cfg *config.Config, name string) { } func TestReposAdd_PersistsRepo(t *testing.T) { - _, cfg := reposCtx(t) + cfg := reposCtx(t) root := NewRootCommand() var stdout, stderr bytes.Buffer root.SetOut(&stdout) @@ -94,7 +92,7 @@ func TestReposAdd_PersistsRepo(t *testing.T) { } func TestReposList_ShowsRegisteredRepos(t *testing.T) { - _, cfg := reposCtx(t) + cfg := reposCtx(t) seedRepo(t, cfg, "acme") root := NewRootCommand() @@ -112,7 +110,7 @@ func TestReposList_ShowsRegisteredRepos(t *testing.T) { } func TestReposList_EmptyState(t *testing.T) { - _, cfg := reposCtx(t) + cfg := reposCtx(t) root := NewRootCommand() var stdout, stderr bytes.Buffer root.SetOut(&stdout) @@ -127,7 +125,7 @@ func TestReposList_EmptyState(t *testing.T) { } func TestReposStatus_ShowsDetails(t *testing.T) { - _, cfg := reposCtx(t) + cfg := reposCtx(t) seedRepo(t, cfg, "acme") root := NewRootCommand() @@ -147,7 +145,7 @@ func TestReposStatus_ShowsDetails(t *testing.T) { } func TestReposRemove_RequiresYesFlag(t *testing.T) { - _, cfg := reposCtx(t) + cfg := reposCtx(t) seedRepo(t, cfg, "acme") root := NewRootCommand() @@ -161,7 +159,7 @@ func TestReposRemove_RequiresYesFlag(t *testing.T) { } func TestReposRemove_DeletesRow(t *testing.T) { - _, cfg := reposCtx(t) + cfg := reposCtx(t) seedRepo(t, cfg, "acme") root := NewRootCommand() diff --git a/packages/engine/internal/config/config.go b/packages/engine/internal/config/config.go index 4236cbf..7fd76b5 100644 --- a/packages/engine/internal/config/config.go +++ b/packages/engine/internal/config/config.go @@ -372,6 +372,9 @@ func Load(cmd *cobra.Command) (*Config, error) { if err := validateGitSyncURL(r.URL); err != nil { return nil, fmt.Errorf("git_sync.repos[%d] (%q): %w", i, r.Name, err) } + if r.Credential == "" { + return nil, fmt.Errorf("git_sync.repos[%d] (%q): credential is required", i, r.Name) + } if r.PollInterval != "" { if err := validateGitSyncPollInterval(r.PollInterval); err != nil { return nil, fmt.Errorf("git_sync.repos[%d] (%q): %w", i, r.Name, err) @@ -420,6 +423,11 @@ func Load(cmd *cobra.Command) (*Config, error) { // kept here to avoid a config → repo → auth test-time import cycle. var gitSyncNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`) +// scpStyleSSHPattern matches scp-style SSH git URLs (e.g. git@github.com:user/repo.git). +// url.Parse does not recognise these as having a scheme or host, so they need a +// separate check after the standard parse-based validation. +var scpStyleSSHPattern = regexp.MustCompile(`^[^@\s]+@[^:\s]+:.+$`) + // validateGitSyncName is a local copy of repo.ValidateName to avoid a // config → repo → auth test-time import cycle. Keep in sync with the // canonical version in packages/engine/internal/repo/types.go. @@ -449,9 +457,11 @@ func validateGitSyncPollInterval(interval string) error { return nil } -// validateGitSyncURL rejects any URL that embeds credentials in the -// userinfo component (https://user@host or https://user:pass@host). All -// auth material must flow through the Credential reference. +// validateGitSyncURL rejects URLs that embed credentials in the userinfo +// component (https://user@host or https://user:pass@host) and also rejects +// strings that are neither a well-formed URL (scheme + host) nor a valid +// scp-style SSH reference (user@host:path). All auth material must flow +// through the Credential reference. func validateGitSyncURL(raw string) error { u, err := url.Parse(raw) if err != nil { @@ -460,5 +470,10 @@ func validateGitSyncURL(raw string) error { if u.User != nil { return fmt.Errorf("repo url must not embed credentials — use the Credential field instead") } + if u.Scheme == "" || u.Host == "" { + if !scpStyleSSHPattern.MatchString(raw) { + return fmt.Errorf("invalid url %q: must have scheme and host (e.g. https://host/path) or use scp-style SSH format (git@host:path)", raw) + } + } return nil } diff --git a/packages/engine/internal/config/config_test.go b/packages/engine/internal/config/config_test.go index 42b4dc5..799a625 100644 --- a/packages/engine/internal/config/config_test.go +++ b/packages/engine/internal/config/config_test.go @@ -512,3 +512,84 @@ git_sync: t.Errorf("Prune: got false, want true") } } + +func TestLoad_GitSyncRepos_Validation(t *testing.T) { + base := `version: 1 +database: + url: postgres://localhost/mantle +git_sync: + repos: +` + cases := []struct { + name string + repo string + wantErr string + }{ + { + name: "invalid name", + repo: ` - name: "Bad Name!" + url: https://example.com/a.git + credential: pat +`, + wantErr: "invalid repo name", + }, + { + name: "missing url", + repo: ` - name: acme + credential: pat +`, + wantErr: "url is required", + }, + { + name: "url with embedded credentials", + repo: ` - name: acme + url: https://user:pass@github.com/a.git + credential: pat +`, + wantErr: "must not embed credentials", + }, + { + name: "url with no scheme or host", + repo: ` - name: acme + url: "not a url" + credential: pat +`, + wantErr: "must have scheme and host", + }, + { + name: "missing credential", + repo: ` - name: acme + url: https://github.com/a.git +`, + wantErr: "credential is required", + }, + { + name: "poll_interval below minimum", + repo: ` - name: acme + url: https://github.com/a.git + credential: pat + poll_interval: 5s +`, + wantErr: "below 10s minimum", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "mantle.yaml") + if err := os.WriteFile(path, []byte(base+tc.repo), 0600); err != nil { + t.Fatalf("writing config: %v", err) + } + cmd := newTestCommand() + _ = cmd.Flags().Set("config", path) + _, err := Load(cmd) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tc.wantErr) + } + }) + } +} diff --git a/packages/engine/internal/db/migrations/019_git_repos.sql b/packages/engine/internal/db/migrations/019_git_repos.sql index ab36d15..21dc334 100644 --- a/packages/engine/internal/db/migrations/019_git_repos.sql +++ b/packages/engine/internal/db/migrations/019_git_repos.sql @@ -1,13 +1,19 @@ -- +goose Up -- git_repos stores configuration for GitOps workflow sources (issue #16). -- Each row represents a remote git repository that Mantle will pull from --- (via a k8s git-sync sidecar) and whose .yaml/.yml files will be applied --- as workflow definitions. The raw auth material lives in credentials — --- this row only references it by name. +-- and whose .yaml/.yml files will be applied as workflow definitions. +-- Raw auth material lives in the credentials table; this row references +-- it by name only. -- -- The `name` column is a human-readable identifier for CLI ergonomics -- (`mantle repos status `), unique per team. It does not derive -- from the repo URL because multiple teams may share the same upstream. +-- +-- credential is stored as a plain name reference (TEXT NOT NULL) rather +-- than a foreign key. A FK to credentials(team_id, name) would prevent +-- operators from registering a repo before creating the credential, which +-- is a common IaC pattern (define everything in one pass, then apply). +-- The sync engine resolves and validates the credential at sync time. CREATE TABLE git_repos ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), team_id UUID NOT NULL REFERENCES teams(id) ON DELETE RESTRICT DEFAULT '00000000-0000-0000-0000-000000000001', @@ -23,7 +29,9 @@ CREATE TABLE git_repos ( last_sync_sha TEXT, last_sync_at TIMESTAMPTZ, last_sync_error TEXT, - webhook_secret TEXT, + -- webhook_secret is intentionally absent in Plan A: the webhook receiver + -- (Plan C) will add a new migration that stores the HMAC secret as an + -- encrypted credential reference rather than plaintext TEXT. created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT git_repos_team_name_key UNIQUE(team_id, name) diff --git a/packages/engine/internal/repo/store.go b/packages/engine/internal/repo/store.go index 8df4f26..3246d93 100644 --- a/packages/engine/internal/repo/store.go +++ b/packages/engine/internal/repo/store.go @@ -183,7 +183,7 @@ func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo, var r Repo var lastSyncAt sql.NullTime - var lastSyncSHA, lastSyncError, webhookSecret sql.NullString + var lastSyncSHA, lastSyncError sql.NullString err = tx.QueryRowContext(ctx, `UPDATE git_repos SET branch = CASE WHEN $3 = '' THEN branch ELSE $3 END, @@ -193,12 +193,12 @@ func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo, WHERE name = $1 AND team_id = $2 RETURNING id, name, url, branch, path, poll_interval, credential, auto_apply, prune, enabled, last_sync_sha, last_sync_at, - last_sync_error, webhook_secret, created_at, updated_at`, + last_sync_error, created_at, updated_at`, name, teamID, p.Branch, p.Path, p.PollInterval, p.Credential, p.AutoApply, p.Prune, p.Enabled, ).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval, &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, - &lastSyncSHA, &lastSyncAt, &lastSyncError, &webhookSecret, + &lastSyncSHA, &lastSyncAt, &lastSyncError, &r.CreatedAt, &r.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("%w: %q", ErrNotFound, name) @@ -216,9 +216,6 @@ func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo, if lastSyncError.Valid { r.LastSyncError = lastSyncError.String } - if webhookSecret.Valid { - r.WebhookSecret = webhookSecret.String - } if err := audit.EmitTx(ctx, tx, audit.Event{ Timestamp: time.Now(), @@ -299,16 +296,16 @@ func (s *Store) Get(ctx context.Context, name string) (*Repo, error) { var r Repo var lastSyncAt sql.NullTime - var lastSyncSHA, lastSyncError, webhookSecret sql.NullString + var lastSyncSHA, lastSyncError sql.NullString err := s.DB.QueryRowContext(ctx, `SELECT id, name, url, branch, path, poll_interval, credential, auto_apply, prune, enabled, last_sync_sha, last_sync_at, - last_sync_error, webhook_secret, created_at, updated_at + last_sync_error, created_at, updated_at FROM git_repos WHERE name = $1 AND team_id = $2`, name, teamID, ).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval, &r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, - &lastSyncSHA, &lastSyncAt, &lastSyncError, &webhookSecret, + &lastSyncSHA, &lastSyncAt, &lastSyncError, &r.CreatedAt, &r.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("%w: %q", ErrNotFound, name) @@ -326,8 +323,5 @@ func (s *Store) Get(ctx context.Context, name string) (*Repo, error) { if lastSyncError.Valid { r.LastSyncError = lastSyncError.String } - if webhookSecret.Valid { - r.WebhookSecret = webhookSecret.String - } return &r, nil } diff --git a/packages/engine/internal/repo/types.go b/packages/engine/internal/repo/types.go index 2b9f1c6..7978e7a 100644 --- a/packages/engine/internal/repo/types.go +++ b/packages/engine/internal/repo/types.go @@ -37,11 +37,6 @@ type Repo struct { LastSyncSHA string LastSyncAt *time.Time LastSyncError string - // WebhookSecret is the HMAC shared secret used to verify inbound push - // webhooks from the git provider. SECURITY: this value is sensitive and - // MUST NOT be printed to terminal or logs. The CLI intentionally never - // renders it; any new caller must follow the same discipline. - WebhookSecret string CreatedAt time.Time UpdatedAt time.Time }