Skip to content

feat: git sync foundation (Plan A of issue #16)#133

Merged
michaelmcnees merged 29 commits into
mainfrom
feat/git-sync-foundation
Apr 27, 2026
Merged

feat: git sync foundation (Plan A of issue #16)#133
michaelmcnees merged 29 commits into
mainfrom
feat/git-sync-foundation

Conversation

@michaelmcnees

@michaelmcnees michaelmcnees commented Apr 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Plan A of 3 for issue #16 (GitOps sync, milestone v0.5.0). Ships the foundation — no sync behavior yet; that's Plan B.

  • Adds git credential type with token/ssh_key/username fields and at-least-one-of validation
  • Adds git_repos table (migration 019) and internal/repo CRUD store with transactional audit emission
  • Adds git_sync.repos block to mantle.yaml (parsed-only; startup reconciliation lands with Plan B)
  • Adds mantle repos add | list | status | remove CLI commands
  • Emits repo.added, repo.updated, repo.removed audit events

Plan breakdown

  • Plan A (this PR): Foundation — registry + CLI + config block.
  • Plan B (next): Sync engine — sidecar integration, file discovery, validate/plan/apply pipeline, prune, repos sync.
  • Plan C (after B): Webhook receiver (POST /hooks/git/<id>), REST API (POST /api/v1/repos), metrics.

What operators can do today

  • Register repos: mantle repos add acme --url https://github.com/acme/wf.git --credential github-pat
  • List and inspect: mantle repos list, mantle repos status acme
  • Remove: mantle repos remove acme --yes
  • Version repos in mantle.yaml under git_sync.repos: (parsed, not yet materialized — Plan B)

Last-sync fields (last_sync_sha, last_sync_at, last_sync_error, webhook_secret) are in the schema but stay null until Plan B/C populate them.

Pre-push review fixes

Four reviewer agents ran before pushing (Technical Writer, Product Manager, Legal Compliance, Reality Checker). Consolidated fixes landed in 431f822:

  • Drop "periodically pulled by the git-sync sidecar" overclaim in CLI Long, config doc, and CLAUDE.md; add Plan A scope caveats
  • Rewrite --prune flag help to describe the positive-default behavior
  • Mark WebhookSecret field on Repo struct with a SECURITY comment
  • Reject repo URLs that embed credentials inline (e.g., https://user:pass@host/...)

Test plan

  • go test ./... from packages/engine — all packages green
  • go test ./internal/repo/ -v — 13 tests pass (CRUD + validation + URL sanitization)
  • go test ./internal/cli/ -v — 6 TestRepos* CLI tests pass
  • go test ./internal/config/ -vTestLoadConfig_GitSyncRepos passes
  • go build ./cmd/mantle && ./mantle repos --help surfaces all four subcommands
  • Manual: mantle repos add persists a row; mantle repos list shows it; mantle repos status prints detail; mantle repos remove --yes deletes and emits repo.removed audit event

Closes part of #16. Plans B and C follow.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • GitOps repo management via CLI: add, list, status, remove (with confirmation) and persistent repo registry; readable list/status output with timestamps and errors.
  • Config

    • Configurable repos in mantle.yaml: name, URL, branch, path, poll interval, credential, auto-apply, prune, enabled.
  • Security / Credentials

    • New Git credential type accepting token or SSH key (username optional); validation requires token or ssh_key.
  • Database

    • Added persistent storage schema for registered repos with sync metadata.
  • Tests

    • Integration and unit tests for CLI, store, config, and credential validation.
  • Documentation

    • Updated docs describing CLI commands and GitOps config usage.

michaelmcnees and others added 17 commits April 17, 2026 20:48
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 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@michaelmcnees has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 36 minutes and 57 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 36 minutes and 57 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7f6c28d2-939b-4aa7-a531-696f1a882152

📥 Commits

Reviewing files that changed from the base of the PR and between af33a5d and 4a09c8e.

📒 Files selected for processing (7)
  • packages/engine/internal/audit/audit.go
  • packages/engine/internal/cli/repos_test.go
  • packages/engine/internal/config/config.go
  • packages/engine/internal/config/config_test.go
  • packages/engine/internal/db/migrations/019_git_repos.sql
  • packages/engine/internal/repo/store.go
  • packages/engine/internal/repo/types.go
📝 Walkthrough

Walkthrough

Adds GitOps repository management: CLI commands (repos add/list/status/remove), persistent git_repos table with CRUD store and audit events, config parsing/validation for git_sync.repos, a new repo model with validation, and a built-in git credential type requiring token or ssh_key.

Changes

Cohort / File(s) Summary
Documentation & Config
CLAUDE.md, packages/engine/internal/config/config.go, packages/engine/internal/config/config_test.go
Documented new CLI commands and git_sync.repos config; added GitSyncConfig/GitSyncRepo types and Load-time validation plus unit test for parsing repo entries.
CLI: repos
packages/engine/internal/cli/repos.go, packages/engine/internal/cli/repos_test.go, packages/engine/internal/cli/root.go
New mantle repos command group with add/list/status/remove, helper to open repo store from CLI context, flags/validation, output formatting, and integration tests (Postgres-backed).
Database migration & audit
packages/engine/internal/db/migrations/019_git_repos.sql, packages/engine/internal/audit/audit.go
Added git_repos migration (schema, indexes, constraints) and three audit action constants: repo.added, repo.updated, repo.removed.
Repo model & store
packages/engine/internal/repo/types.go, packages/engine/internal/repo/store.go, packages/engine/internal/repo/store_test.go
Introduced Repo model and validators, team-scoped transactional Store with Create/List/Get/Update/Delete (audit emission in TX), errors (ErrNotFound), validation for name/URL/poll interval, and integration tests.
Credential types
packages/engine/internal/secret/types.go, packages/engine/internal/secret/types_test.go
Added RequireAtLeastOne to credential type schema, introduced builtin git credential type (fields: token, ssh_key, username) and validation requiring token or ssh_key; updated tests.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as CLI (mantle repos)
  participant Store as Repo Store
  participant DB as Postgres
  participant Audit as Audit subsystem

  CLI->>Store: Create/List/Get/Update/Delete request
  Store->>DB: Begin TX / Execute SQL
  DB-->>Store: Row(s) or error
  Store->>Audit: Emit audit event (within same TX)
  Store->>DB: Commit TX
  DB-->>Store: Commit ack
  Store-->>CLI: Return result / formatted output
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I nibbled names and stitched a store,

Added commands to knock on the door,
DB hummed a tune, audits gave a wink,
Secrets tucked safe—no creds in the link,
A carrot CLI hop, and a git-sync corkscrew cork.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: implementing the foundation (Plan A) of GitOps sync, with a direct reference to the related issue (#16).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/git-sync-foundation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (9)
packages/engine/internal/db/migrations/019_git_repos.sql (1)

32-33: Partial index on enabled alone is likely low-value.

Since enabled defaults to TRUE, the partial predicate WHERE enabled = TRUE will match nearly every row, so this index won't offer much selectivity over a seq scan for the sync engine's "find enabled repos" query in Plan B. A composite (team_id, enabled) WHERE enabled = TRUE (or just relying on idx_git_repos_team plus a runtime filter) is usually the shape that actually gets used by the planner. Worth revisiting when Plan B defines the actual sync scan query.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/db/migrations/019_git_repos.sql` around lines 32 -
33, The partial index idx_git_repos_enabled on git_repos(enabled) WHERE enabled
= TRUE is low-value because enabled defaults to TRUE; replace or change it to a
composite partial index that includes team_id (e.g., idx_git_repos_team_enabled
on git_repos(team_id, enabled) WHERE enabled = TRUE) so the planner can use
team_id + enabled for the “find enabled repos” queries, or remove the redundant
idx_git_repos_enabled and rely on idx_git_repos_team plus a runtime enabled
filter; update the migration to create the composite index (or drop the partial
index) accordingly.
packages/engine/internal/config/config.go (1)

36-36: No env-var binding for git_sync.* keys.

All other new config sections added in this repo (storage, retention, engine., auth.oidc.) have explicit v.BindEnv calls in Load. git_sync.repos doesn't — which is fine given it's a list (Viper can't meaningfully bind MANTLE_GIT_SYNC_REPOS), but flagging for awareness so this doesn't get called out as an inconsistency in Plan B review. A short code comment noting "list-valued; file-only" would preempt that.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/config/config.go` at line 36, The GitSyncConfig
field (GitSync GitSyncConfig `mapstructure:"git_sync"`) lacks env-var bindings
in Load; add a short explanatory comment either above the GitSyncConfig type or
next to the GitSync field in config.go stating that git_sync.repos is
list-valued and therefore intentionally only read from files (Viper cannot bind
list env vars), so no v.BindEnv call is required—this keeps Load()’s omission of
v.BindEnv for git_sync.* explicit and avoids reviewer confusion.
packages/engine/internal/cli/root.go (1)

48-60: repos likely belongs outside the "Workflow Lifecycle" group.

mantle repos add|list|status|remove is a registry/admin surface, not part of the validate → plan → apply → run lifecycle that the "workflow" group is scoped to (per the help text on line 16 and CLAUDE.md). Grouping it with env works, but consider either placing it under admin alongside secrets/teams/users, or adding a dedicated gitops group so the help output stays legible as Plans B and C add more surface area. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/cli/root.go` around lines 48 - 60, The repos
commands (newReposCommand) are grouped under the "workflow" addToGroup call but
belong to admin/gitops surface, so remove newReposCommand from the
addToGroup(..., "workflow", ...) call and instead register it with a more
appropriate group (e.g., an "admin" or "gitops" addToGroup invocation alongside
newEnvCommand or the secrets/teams/users group); locate the addToGroup call that
currently lists newValidateCommand/newPlanCommand/... and take newReposCommand
out of that list and add it to the chosen separate group registration so help
output reflects correct command grouping.
packages/engine/internal/secret/types_test.go (1)

106-123: LGTM; optionally add an empty-value case.

The positive/negative coverage is sufficient. Consider also asserting that {"token": "", "ssh_key": ""} fails, since Validate treats empty strings as unset — this pins that behavior against future regressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/secret/types_test.go` around lines 106 - 123, Update
the TestGitCredentialType_ValidateRequiresTokenOrSSHKey test to include an
additional negative case that passes both "token" and "ssh_key" with empty
string values and asserts ct.Validate returns an error; locate the test by name
(TestGitCredentialType_ValidateRequiresTokenOrSSHKey) and the Validate call on
the ct value returned by GetType("git") and add an assertion similar to the
existing negative case but using map[string]string{"token": "", "ssh_key": ""}
to ensure empty strings are treated as unset.
packages/engine/internal/config/config_test.go (1)

500-504: Consider asserting the remaining parsed fields.

The YAML sets branch, path, poll_interval, and prune, but the assertion only checks Name, URL, Branch, Credential, and AutoApply. Adding checks for Path, PollInterval, and Prune would guard against a regression in any one mapstructure tag (e.g., a future rename/typo on poll_interval).

♻️ Proposed tightening
-	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)
-	}
+	if r.Name != "acme" || r.URL != "https://github.com/acme/workflows.git" ||
+		r.Branch != "main" || r.Path != "/" || r.PollInterval != "60s" ||
+		r.Credential != "github-pat" || !r.AutoApply || !r.Prune {
+		t.Errorf("parsed repo: %+v", r)
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/config/config_test.go` around lines 500 - 504, The
test currently only checks some fields of cfg.GitSync.Repos[0] (variable r);
extend the assertion to also validate r.Path, r.PollInterval and r.Prune to
match the YAML and prevent regressions in mapstructure tags; update the test
around cfg.GitSync.Repos[0] (where r is defined) to assert these fields (and use
t.Errorf/t.Fatalf as appropriate) so the parsed repo includes Path, PollInterval
and Prune with the expected values.
packages/engine/internal/cli/repos_test.go (2)

119-121: Consider asserting no stray content in empty-state output.

strings.Contains passes as long as (no repos) appears anywhere, so a stray table header wouldn't fail this test. Given the CLI prints either (no repos) or the table (mutually exclusive), a stricter check (e.g., strings.TrimSpace(stdout.String()) == "(no repos)") would catch regressions where both paths get executed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/cli/repos_test.go` around lines 119 - 121, The test
currently uses strings.Contains on stdout.String() which allows stray output;
change the assertion to require the trimmed stdout exactly equals "(no repos)"
by comparing strings.TrimSpace(stdout.String()) to that literal so the test
fails if a header or extra content is present, and keep the t.Errorf to print
the full stdout on mismatch (referencing stdout.String()).

82-94: Optional: extract the "seed one repo via add" block into a helper.

The same inline repos add acme ... seeding block is repeated verbatim across TestReposList_ShowsRegisteredRepos, TestReposStatus_ShowsDetails, TestReposRemove_RequiresYesFlag, and TestReposRemove_DeletesRow. A small seedRepo(t, cfg, name) helper would reduce noise and make intent clearer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/cli/repos_test.go` around lines 82 - 94, Extract the
repeated "seed one repo via add" block into a helper function (e.g., seedRepo(t
*testing.T, cfg Config, name string)) that constructs NewRootCommand(), sets its
stderr buffer, prepares args like {"repos","add", name, "--url",
"https://example.com/a.git", "--credential", "pat", "--database-url",
cfg.Database.URL"}, calls root.Execute(), and fails the test on error using
t.Fatalf with the stderr contents; replace the four inline blocks in
TestReposList_ShowsRegisteredRepos, TestReposStatus_ShowsDetails,
TestReposRemove_RequiresYesFlag, and TestReposRemove_DeletesRow with calls to
seedRepo to reduce duplication and clarify intent while keeping behavior
identical.
packages/engine/internal/cli/repos.go (1)

98-102: Optional: add Long/Example to status and list for parity with add/remove.

status and list only set Short, while add and remove include Long (and add also includes Example). Small polish for a consistent --help surface.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/cli/repos.go` around lines 98 - 102, Add the missing
detailed help text to the repo commands so CLI help is consistent: update
newReposStatusCommand and the repos list command (e.g., newReposListCommand) to
set Long (a longer description) and optionally Example fields similar to
newReposAddCommand/newReposRemoveCommand (and match the Example used by add if
appropriate). Ensure the Long string describes the command behavior in more
detail than Short and, for parity with add, include an Example demonstrating
typical usage of the status and list commands.
packages/engine/internal/repo/types.go (1)

19-21: Optional: reject trailing -/_ in repo names.

^[a-z0-9][a-z0-9_-]*$ accepts values like acme- or foo_. Given the stated intent to embed names into log lines, metric labels, and URL path segments without escaping, tightening the tail of the pattern avoids awkward boundary characters.

♻️ Suggested tightening
-var validRepoNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
+var validRepoNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?$`)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/repo/types.go` around lines 19 - 21, The current
validRepoNamePattern allows repo names to end with '-' or '_' (e.g., "acme-");
update the regexp assigned to validRepoNamePattern so the last character must be
an alphanumeric to reject trailing hyphens/underscores. Replace the current
pattern (`regexp.MustCompile(\`^[a-z0-9][a-z0-9_-]*$\`)`) with a tightened
pattern that requires the final character be [a-z0-9] while still allowing
single-character names (for example by using a grouped optional middle segment),
and update the variable validRepoNamePattern accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/engine/internal/config/config.go`:
- Around line 71-92: Load currently parses cfg.GitSync.Repos but doesn't run the
repo validators; update the Load function to iterate over cfg.GitSync.Repos
(GitSyncConfig / GitSyncRepo) and for each entry call the existing validators
repo.ValidateName, repo.ValidatePollInterval and validateURL (or the repo
package equivalent) and return an error if any validation fails so Load fails
fast; if importing internal/repo causes a cycle, either lift the validators to a
lower-level package or inline at minimum the URL credential rejection logic here
before returning the parsed config.

In `@packages/engine/internal/db/migrations/019_git_repos.sql`:
- Around line 11-30: Update the git_repos table definition so the team_id
foreign key uses an explicit ON DELETE RESTRICT; specifically modify the team_id
column definition (symbol: team_id in table git_repos) to read REFERENCES
teams(id) ON DELETE RESTRICT (keeping the existing DEFAULT
'00000000-0000-0000-0000-000000000001' and NOT NULL), so the column becomes
something like team_id UUID NOT NULL REFERENCES teams(id) ON DELETE RESTRICT
DEFAULT '00000000-0000-0000-0000-000000000001'.

In `@packages/engine/internal/repo/store.go`:
- Around line 166-172: The Update method (Store.Update) currently writes
p.Branch and p.Path even when empty; fix by either validating non-empty
p.Branch/p.Path and returning an error, or by preserving existing values when
incoming fields are empty: fetch the current repo (e.g., via s.Get(ctx, name) or
the repository getter used in this file), and before persisting, set branch :=
p.Branch; if branch=="" then branch = existing.Branch (same for path := p.Path
-> existing.Path); keep existing validation for PollInterval and Credential
as-is and ensure the final values written come from these resolved branch/path
variables.
- Around line 183-194: The Update SQL and Scan in the tx.QueryRowContext call
(function/method Update, variables tx, r) currently omit last_sync_sha,
last_sync_at, last_sync_error, and webhook_secret from the RETURNING list and
Scan, so returned *Repo values lose those fields; update the RETURNING clause to
include last_sync_sha, last_sync_at, last_sync_error, webhook_secret exactly as
Get does and extend the Scan to accept corresponding destinations (including
using the same nullable scanners/nullable types or helpers that Get uses) so
Update returns a fully populated Repo matching Get's column set.
- Around line 257-272: The validateURL function currently only rejects URLs with
a password but allows username-only userinfo (e.g., GitHub PAT in the username);
change validateURL to reject any non-empty u.User (i.e., if u.User != nil return
an error directing operators to use the Credential field/--credential) so no
credentials can be embedded in git_repos.url, and update the test
TestStore_Create_AllowsURLWithUsernameOnly to expect rejection (or adjust any
callers that relied on username hints); alternatively, if you choose to strip
userinfo instead of rejecting, ensure the strip happens before
persisting/auditing (where git_repos.url is set) so credentials never land in
storage.

---

Nitpick comments:
In `@packages/engine/internal/cli/repos_test.go`:
- Around line 119-121: The test currently uses strings.Contains on
stdout.String() which allows stray output; change the assertion to require the
trimmed stdout exactly equals "(no repos)" by comparing
strings.TrimSpace(stdout.String()) to that literal so the test fails if a header
or extra content is present, and keep the t.Errorf to print the full stdout on
mismatch (referencing stdout.String()).
- Around line 82-94: Extract the repeated "seed one repo via add" block into a
helper function (e.g., seedRepo(t *testing.T, cfg Config, name string)) that
constructs NewRootCommand(), sets its stderr buffer, prepares args like
{"repos","add", name, "--url", "https://example.com/a.git", "--credential",
"pat", "--database-url", cfg.Database.URL"}, calls root.Execute(), and fails the
test on error using t.Fatalf with the stderr contents; replace the four inline
blocks in TestReposList_ShowsRegisteredRepos, TestReposStatus_ShowsDetails,
TestReposRemove_RequiresYesFlag, and TestReposRemove_DeletesRow with calls to
seedRepo to reduce duplication and clarify intent while keeping behavior
identical.

In `@packages/engine/internal/cli/repos.go`:
- Around line 98-102: Add the missing detailed help text to the repo commands so
CLI help is consistent: update newReposStatusCommand and the repos list command
(e.g., newReposListCommand) to set Long (a longer description) and optionally
Example fields similar to newReposAddCommand/newReposRemoveCommand (and match
the Example used by add if appropriate). Ensure the Long string describes the
command behavior in more detail than Short and, for parity with add, include an
Example demonstrating typical usage of the status and list commands.

In `@packages/engine/internal/cli/root.go`:
- Around line 48-60: The repos commands (newReposCommand) are grouped under the
"workflow" addToGroup call but belong to admin/gitops surface, so remove
newReposCommand from the addToGroup(..., "workflow", ...) call and instead
register it with a more appropriate group (e.g., an "admin" or "gitops"
addToGroup invocation alongside newEnvCommand or the secrets/teams/users group);
locate the addToGroup call that currently lists
newValidateCommand/newPlanCommand/... and take newReposCommand out of that list
and add it to the chosen separate group registration so help output reflects
correct command grouping.

In `@packages/engine/internal/config/config_test.go`:
- Around line 500-504: The test currently only checks some fields of
cfg.GitSync.Repos[0] (variable r); extend the assertion to also validate r.Path,
r.PollInterval and r.Prune to match the YAML and prevent regressions in
mapstructure tags; update the test around cfg.GitSync.Repos[0] (where r is
defined) to assert these fields (and use t.Errorf/t.Fatalf as appropriate) so
the parsed repo includes Path, PollInterval and Prune with the expected values.

In `@packages/engine/internal/config/config.go`:
- Line 36: The GitSyncConfig field (GitSync GitSyncConfig
`mapstructure:"git_sync"`) lacks env-var bindings in Load; add a short
explanatory comment either above the GitSyncConfig type or next to the GitSync
field in config.go stating that git_sync.repos is list-valued and therefore
intentionally only read from files (Viper cannot bind list env vars), so no
v.BindEnv call is required—this keeps Load()’s omission of v.BindEnv for
git_sync.* explicit and avoids reviewer confusion.

In `@packages/engine/internal/db/migrations/019_git_repos.sql`:
- Around line 32-33: The partial index idx_git_repos_enabled on
git_repos(enabled) WHERE enabled = TRUE is low-value because enabled defaults to
TRUE; replace or change it to a composite partial index that includes team_id
(e.g., idx_git_repos_team_enabled on git_repos(team_id, enabled) WHERE enabled =
TRUE) so the planner can use team_id + enabled for the “find enabled repos”
queries, or remove the redundant idx_git_repos_enabled and rely on
idx_git_repos_team plus a runtime enabled filter; update the migration to create
the composite index (or drop the partial index) accordingly.

In `@packages/engine/internal/repo/types.go`:
- Around line 19-21: The current validRepoNamePattern allows repo names to end
with '-' or '_' (e.g., "acme-"); update the regexp assigned to
validRepoNamePattern so the last character must be an alphanumeric to reject
trailing hyphens/underscores. Replace the current pattern
(`regexp.MustCompile(\`^[a-z0-9][a-z0-9_-]*$\`)`) with a tightened pattern that
requires the final character be [a-z0-9] while still allowing single-character
names (for example by using a grouped optional middle segment), and update the
variable validRepoNamePattern accordingly.

In `@packages/engine/internal/secret/types_test.go`:
- Around line 106-123: Update the
TestGitCredentialType_ValidateRequiresTokenOrSSHKey test to include an
additional negative case that passes both "token" and "ssh_key" with empty
string values and asserts ct.Validate returns an error; locate the test by name
(TestGitCredentialType_ValidateRequiresTokenOrSSHKey) and the Validate call on
the ct value returned by GetType("git") and add an assertion similar to the
existing negative case but using map[string]string{"token": "", "ssh_key": ""}
to ensure empty strings are treated as unset.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 15a2d429-ec50-42f7-81db-3f8efc443c80

📥 Commits

Reviewing files that changed from the base of the PR and between ac6a6dd and 431f822.

📒 Files selected for processing (13)
  • CLAUDE.md
  • packages/engine/internal/audit/audit.go
  • packages/engine/internal/cli/repos.go
  • packages/engine/internal/cli/repos_test.go
  • packages/engine/internal/cli/root.go
  • packages/engine/internal/config/config.go
  • packages/engine/internal/config/config_test.go
  • packages/engine/internal/db/migrations/019_git_repos.sql
  • packages/engine/internal/repo/store.go
  • packages/engine/internal/repo/store_test.go
  • packages/engine/internal/repo/types.go
  • packages/engine/internal/secret/types.go
  • packages/engine/internal/secret/types_test.go

Comment thread packages/engine/internal/config/config.go
Comment thread packages/engine/internal/db/migrations/019_git_repos.sql
Comment thread packages/engine/internal/repo/store.go
Comment thread packages/engine/internal/repo/store.go
Comment thread packages/engine/internal/repo/store.go Outdated
michaelmcnees and others added 9 commits April 18, 2026 23:51
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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
… 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) <noreply@anthropic.com>
…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) <noreply@anthropic.com>
…-string git cred case

- 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
…d 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) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
packages/engine/internal/config/config.go (1)

360-382: Missing credential fail-fast check — config path is looser than CLI.

Store.Create (store.go lines 59-61) rejects an empty Credential, but Load here does not. An operator can commit a mantle.yaml with a git_sync.repos entry that omits credential, and it will parse cleanly today — the error surfaces only when Plan B tries to materialize it. The same fail-fast reasoning that added ValidateName/URL/ValidatePollInterval here applies to credential.

Optionally, detecting duplicate names upfront would also be cheap insurance (the git_repos_team_name_key UNIQUE constraint catches it at DB time, but not until materialization).

♻️ Suggested
 	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.Credential == "" {
+			return nil, fmt.Errorf("git_sync.repos[%d] (%q): credential is required", 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)
 			}
 		}
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/config/config.go` around lines 360 - 382, The
git_sync.Repos validation currently checks Name, URL, and PollInterval but does
not fail-fast when a repo's Credential is empty; add a check in the same loop
over cfg.GitSync.Repos to return an error if r.Credential == "" (e.g.,
fmt.Errorf("git_sync.repos[%d] (%q): credential is required", i, r.Name)) so
behavior matches Store.Create's rejection of empty Credential; optionally also
detect duplicate repo names by tracking seen names in a map and returning a
similar error if a duplicate is found to catch the UNIQUE constraint earlier.
packages/engine/internal/cli/repos.go (2)

130-135: SHA will render blank when LastSyncSHA is empty but LastSyncAt is set.

If a future sync path writes last_sync_at without a SHA (e.g., a sync that failed after receiving the timestamp), status prints Last Sync: 2026-04-20 ... UTC (SHA ). Low-impact since Plan A never populates these, but a guard here would be cheap insurance against surprises in Plan B:

♻️ Suggested
 			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)
+				ts := r.LastSyncAt.UTC().Format("2006-01-02 15:04:05 UTC")
+				if r.LastSyncSHA != "" {
+					fmt.Fprintf(out, "Last Sync:    %s (SHA %s)\n", ts, r.LastSyncSHA)
+				} else {
+					fmt.Fprintf(out, "Last Sync:    %s\n", ts)
+				}
 			} else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/cli/repos.go` around lines 130 - 135, The current
output always appends " (SHA %s)" even when r.LastSyncSHA is empty, producing
"SHA " blanks; in the printing logic in repos.go that checks r.LastSyncAt (the
fmt.Fprintf call that uses r.LastSyncAt.UTC().Format(...) and r.LastSyncSHA),
change the formatting to conditionally include the " (SHA ...)" suffix only when
r.LastSyncSHA is non-empty (e.g., check r.LastSyncSHA != "" and use one
fmt.Fprintf variant with the SHA or another without it) so the status line omits
the empty SHA token.

33-46: Cleanup closure drops database.Close() error.

Silently discarding the close error is fine for the CLI happy path, but it does hide failures like "broken pipe during tx commit flush" during debugging. Consider logging at debug level:

♻️ Suggested
-	cleanup := func() { database.Close() }
+	cleanup := func() {
+		if err := database.Close(); err != nil {
+			slog.Debug("closing repo store db", "error", err)
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/cli/repos.go` around lines 33 - 46, The cleanup
closure in newRepoStore currently ignores the error returned by
database.Close(); change the cleanup function to capture the Close() error and
log it at debug level instead of discarding it. Locate newRepoStore and its
cleanup closure, call database.Close() into a variable, and emit a debug log
including that error (use the package's existing logger from context/config if
available, otherwise use the standard logger) so shutdown errors like "broken
pipe during tx commit flush" are visible when debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/engine/internal/repo/store.go`:
- Around line 278-293: The validateURL function currently rejects any URL with
u.User != nil which incorrectly blocks legitimate SSH URLs like
ssh://git@host/..., so change the logic in validateURL to only treat embedded
userinfo as an error for HTTP(S) schemes (check u.Scheme == "http" || u.Scheme
== "https") while allowing SSH schemes (e.g., "ssh") to have a username; apply
the identical change to the corresponding validator in config.go (the
validateURL/URL validation function there), and add unit tests covering
ssh://git@host/path.git and ssh://host/path.git to ensure both forms are
accepted while HTTP(S) URLs with user:pass remain rejected.

---

Nitpick comments:
In `@packages/engine/internal/cli/repos.go`:
- Around line 130-135: The current output always appends " (SHA %s)" even when
r.LastSyncSHA is empty, producing "SHA " blanks; in the printing logic in
repos.go that checks r.LastSyncAt (the fmt.Fprintf call that uses
r.LastSyncAt.UTC().Format(...) and r.LastSyncSHA), change the formatting to
conditionally include the " (SHA ...)" suffix only when r.LastSyncSHA is
non-empty (e.g., check r.LastSyncSHA != "" and use one fmt.Fprintf variant with
the SHA or another without it) so the status line omits the empty SHA token.
- Around line 33-46: The cleanup closure in newRepoStore currently ignores the
error returned by database.Close(); change the cleanup function to capture the
Close() error and log it at debug level instead of discarding it. Locate
newRepoStore and its cleanup closure, call database.Close() into a variable, and
emit a debug log including that error (use the package's existing logger from
context/config if available, otherwise use the standard logger) so shutdown
errors like "broken pipe during tx commit flush" are visible when debugging.

In `@packages/engine/internal/config/config.go`:
- Around line 360-382: The git_sync.Repos validation currently checks Name, URL,
and PollInterval but does not fail-fast when a repo's Credential is empty; add a
check in the same loop over cfg.GitSync.Repos to return an error if r.Credential
== "" (e.g., fmt.Errorf("git_sync.repos[%d] (%q): credential is required", i,
r.Name)) so behavior matches Store.Create's rejection of empty Credential;
optionally also detect duplicate repo names by tracking seen names in a map and
returning a similar error if a duplicate is found to catch the UNIQUE constraint
earlier.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 22e9adbd-952a-4278-87a1-8ccd402d9664

📥 Commits

Reviewing files that changed from the base of the PR and between 431f822 and be683a3.

📒 Files selected for processing (10)
  • packages/engine/internal/cli/repos.go
  • packages/engine/internal/cli/repos_test.go
  • packages/engine/internal/cli/root.go
  • packages/engine/internal/config/config.go
  • packages/engine/internal/config/config_test.go
  • packages/engine/internal/db/migrations/019_git_repos.sql
  • packages/engine/internal/repo/store.go
  • packages/engine/internal/repo/store_test.go
  • packages/engine/internal/repo/types.go
  • packages/engine/internal/secret/types_test.go
✅ Files skipped from review due to trivial changes (4)
  • packages/engine/internal/config/config_test.go
  • packages/engine/internal/db/migrations/019_git_repos.sql
  • packages/engine/internal/cli/repos_test.go
  • packages/engine/internal/repo/store_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/engine/internal/secret/types_test.go
  • packages/engine/internal/cli/root.go

Comment on lines +278 to +293
// 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 {
return fmt.Errorf("repo url must not embed credentials — use the --credential flag instead")
}
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the two SSH URL forms behave differently under net/url.
cat <<'EOF' > /tmp/ssh_url_probe.go
package main

import (
	"fmt"
	"net/url"
)

func main() {
	for _, raw := range []string{
		"ssh://git@github.com/acme/repo.git",
		"git@github.com:acme/wf.git",
		"https://user:pass@github.com/acme/repo.git",
		"https://token@github.com/acme/repo.git",
	} {
		u, err := url.Parse(raw)
		fmt.Printf("%-55s err=%v scheme=%q host=%q user=%v path=%q\n",
			raw, err, u.Scheme, u.Host, u.User, u.Path)
	}
}
EOF
go run /tmp/ssh_url_probe.go 2>/dev/null || echo "go toolchain not available in sandbox; maintainer should run locally"
# Also check if the repo's tests already cover ssh:// form:
rg -nP 'ssh://' -g '*.go'

Repository: dvflw/mantle

Length of output: 252


🏁 Script executed:

# Read the exact validateURL implementation from store.go
cat -n packages/engine/internal/repo/store.go | sed -n '278,293p'

Repository: dvflw/mantle

Length of output: 827


🏁 Script executed:

# Search for tests of validateURL function
rg -n 'validateURL|TestValidateURL' packages/engine/internal/repo/ -g '*.go'

Repository: dvflw/mantle

Length of output: 337


🏁 Script executed:

# Check config.go around lines 372-376
cat -n packages/engine/internal/repo/config.go | sed -n '370,380p'

Repository: dvflw/mantle

Length of output: 127


🏁 Script executed:

# Re-run the URL parsing test to see scp-like behavior
cat > /tmp/complete_url_test.go << 'EOF'
package main

import (
	"fmt"
	"net/url"
)

func main() {
	urls := []string{
		"ssh://git@github.com/acme/repo.git",
		"git@github.com:acme/wf.git",
		"https://user:pass@github.com/acme/repo.git",
		"https://token@github.com/acme/repo.git",
	}
	
	for _, raw := range urls {
		u, err := url.Parse(raw)
		user := ""
		if u.User != nil {
			user = u.User.String()
		}
		fmt.Printf("URL: %-55s | Scheme: %-6s | User: %-20s | Host: %-20s\n", 
			raw, u.Scheme, user, u.Host)
	}
}
EOF
python3 << 'PYEOF'
import subprocess
result = subprocess.run(['go', 'run', '/tmp/complete_url_test.go'], capture_output=True, text=True)
if result.returncode == 0:
    print(result.stdout)
else:
    print("Go not available in sandbox. Checking URL parsing reference...")
    # Parse URLs using Python to show the behavior
    from urllib.parse import urlparse
    for raw in ["ssh://git@github.com/acme/repo.git", "git@github.com:acme/wf.git"]:
        parsed = urlparse(raw)
        print(f"Python parse {raw}: scheme={parsed.scheme} netloc={parsed.netloc} user={parsed.username}")
PYEOF

Repository: dvflw/mantle

Length of output: 276


🏁 Script executed:

# Search for any existing tests related to repo URL validation
fd -e '_test.go' packages/engine/internal/repo/ -x grep -l 'validateURL\|URL.*test\|ssh://' {}

Repository: dvflw/mantle

Length of output: 481


🏁 Script executed:

# Search for config.go anywhere in the repo
find . -name 'config.go' -type f 2>/dev/null | head -20

Repository: dvflw/mantle

Length of output: 99


🏁 Script executed:

# Check repos.go line 58 for the example
fd -type f -name 'repos.go' | xargs cat -n | sed -n '50,70p'

Repository: dvflw/mantle

Length of output: 226


🏁 Script executed:

# Search for tests covering URL validation or git repo URLs
fd -type f -name '*_test.go' packages/engine/ | xargs grep -l 'URL\|url' 2>/dev/null | head -5

Repository: dvflw/mantle

Length of output: 226


🏁 Script executed:

# Check if there's a test file for store.go
fd -type f -name 'store_test.go' packages/engine/

Repository: dvflw/mantle

Length of output: 226


🏁 Script executed:

# Check config.go at correct location around line 372
cat -n packages/engine/internal/config/config.go | sed -n '370,380p'

Repository: dvflw/mantle

Length of output: 663


🏁 Script executed:

# Search for repos.go and check the example
find . -name 'repos.go' -type f 2>/dev/null -exec cat -n {} \; | sed -n '50,70p'

Repository: dvflw/mantle

Length of output: 988


🏁 Script executed:

# Search for URL validation tests more carefully
find . -name '*_test.go' -path '*/engine/internal/repo/*' 2>/dev/null

Repository: dvflw/mantle

Length of output: 101


🏁 Script executed:

# Look for any test of validateURL or git repo URLs
rg 'validateURL|ssh://.*github' packages/engine/ --type go

Repository: dvflw/mantle

Length of output: 440


🏁 Script executed:

# Check store_test.go for validateURL tests
cat -n packages/engine/internal/repo/store_test.go | grep -A 10 -B 2 'validateURL\|TestValidate\|ssh://'

Repository: dvflw/mantle

Length of output: 38


🏁 Script executed:

# Get a broader view of store_test.go to see what's tested
wc -l packages/engine/internal/repo/store_test.go
head -100 packages/engine/internal/repo/store_test.go | cat -n

Repository: dvflw/mantle

Length of output: 3559


🏁 Script executed:

# Search for any test covering SSH URLs in the entire engine package
rg 'ssh://|git@github' packages/engine/ --type go -B 2 -A 2

Repository: dvflw/mantle

Length of output: 590


validateURL rejects legitimate ssh://git@host/... URLs.

Blanket-rejecting any URL with u.User != nil blocks the standard SSH URL form ssh://git@github.com/acme/repo.git, where git is the SSH login name (not a credential). url.Parse sets u.User = url.User("git") for that input, so it fails this validator.

The scp-like shorthand git@github.com:acme/wf.git (shown in the add command's example) happens to survive because url.Parse treats the whole string as an opaque path without populating User — creating inconsistent behavior across two forms of the same SSH URL.

This same overly-broad validation exists in packages/engine/internal/config/config.go lines 374–375, so both must be fixed together.

Consider allowing well-known SSH login names (git, hg) or, more robustly, only rejecting userinfo for HTTP(S) schemes where credential-in-URL is the actual attack surface:

🛡️ Suggested fix
 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 {
-		return fmt.Errorf("repo url must not embed credentials — use the --credential flag instead")
-	}
+	// Credentials-in-URL is an HTTP(S) attack vector; SSH URLs legitimately
+	// carry a login name (e.g., ssh://git@github.com/...).
+	if u.User != nil {
+		scheme := strings.ToLower(u.Scheme)
+		if scheme == "http" || scheme == "https" {
+			return fmt.Errorf("repo url must not embed credentials — use the --credential flag instead")
+		}
+		if _, hasPwd := u.User.Password(); hasPwd {
+			return fmt.Errorf("repo url must not embed credentials — use the --credential flag instead")
+		}
+	}
 	return nil
 }

Apply the same logic to packages/engine/internal/config/config.go lines 372–376 and add test cases for both ssh://git@host/path.git and ssh://host/path.git forms.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/repo/store.go` around lines 278 - 293, The
validateURL function currently rejects any URL with u.User != nil which
incorrectly blocks legitimate SSH URLs like ssh://git@host/..., so change the
logic in validateURL to only treat embedded userinfo as an error for HTTP(S)
schemes (check u.Scheme == "http" || u.Scheme == "https") while allowing SSH
schemes (e.g., "ssh") to have a username; apply the identical change to the
corresponding validator in config.go (the validateURL/URL validation function
there), and add unit tests covering ssh://git@host/path.git and
ssh://host/path.git to ensure both forms are accepted while HTTP(S) URLs with
user:pass remain rejected.

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 <noreply@anthropic.com>
@michaelmcnees

Copy link
Copy Markdown
Collaborator Author

@coderabbitai this is ready for re-review

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

@michaelmcnees Sure! Kicking off a full re-review of PR #133 now.


🧠 Learnings used
Learnt from: michaelmcnees
Repo: dvflw/mantle PR: 0
File: :0-0
Timestamp: 2026-03-28T01:37:57.692Z
Learning: When reviewing a PR for the dvflw/mantle repository, if all issues are resolved and the PR is ready for release, mark it as approved using the  action tag, as requested by michaelmcnees.
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (6)
packages/engine/internal/config/config_test.go (1)

471-514: Add negative-path tests for the new git_sync.repos validators.

The added test exercises only the happy path. The new validateGitSyncName, validateGitSyncURL, and validateGitSyncPollInterval helpers — which are explicitly flagged in the source as "keep in sync with canonical version" — have no negative coverage here. Given the duplication, a regression (e.g., the 10s minimum drifting or the userinfo check being silently removed) would go undetected. Consider table-driven negative cases:

🧪 Suggested additional test
func TestLoadConfig_GitSyncRepos_ValidationErrors(t *testing.T) {
	cases := []struct {
		name    string
		repo    string
		wantErr string
	}{
		{"missing url", `- name: acme
      credential: c`, "url is required"},
		{"embedded creds", `- name: acme
      url: https://user:pw@github.com/a/b.git
      credential: c`, "must not embed credentials"},
		{"poll too small", `- name: acme
      url: https://github.com/a/b.git
      credential: c
      poll_interval: 1s`, "below 10s minimum"},
		{"bad name", `- name: BadName!
      url: https://github.com/a/b.git
      credential: c`, "invalid repo name"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			dir := t.TempDir()
			path := filepath.Join(dir, "mantle.yaml")
			yaml := "version: 1\ngit_sync:\n  repos:\n    " + tc.repo + "\n"
			require.NoError(t, os.WriteFile(path, []byte(yaml), 0600))
			cmd := newTestCommand()
			_ = cmd.Flags().Set("config", path)
			_, err := Load(cmd)
			require.Error(t, err)
			assert.Contains(t, err.Error(), tc.wantErr)
		})
	}
}

Also consider renaming to TestLoad_GitSyncRepos to match the existing TestLoad_* convention in this file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/config/config_test.go` around lines 471 - 514, Add
table-driven negative tests for git_sync.repos validation by creating a new test
(or renaming to TestLoad_GitSyncRepos to match convention) that constructs
malformed repo YAML cases and asserts Load(cmd) returns an error containing the
expected message; cover cases exercising validateGitSyncName, validateGitSyncURL
(embedded credentials and missing url), and validateGitSyncPollInterval (below
10s) so regressions in those helpers are caught, and reuse newTestCommand and
setting the "config" flag as in the existing TestLoadConfig_GitSyncRepos.
packages/engine/internal/audit/audit.go (1)

65-68: Optional: consider aligning verbs with other audit actions.

Other domain lifecycle actions in this file use created/updated/deleted (e.g., environment.created/updated/deleted, user.created/deleted, credential.created/deleted), whereas the new repo actions use added/removed. The mix is harmless but queries filtering by verb (action LIKE '%.created') will not pick up repo.added. If the intent is to mirror the CLI verbs (repos add/remove), leave as-is; otherwise consider repo.created/repo.deleted for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/audit/audit.go` around lines 65 - 68, Change the
repo audit action verbs to match existing domain lifecycle verbs: replace
ActionRepoAdded and ActionRepoRemoved with names using "created" and "deleted"
(e.g., repo.created/repo.deleted) so they align with other actions like
environment.created/updated/deleted; update the ActionRepoAdded and
ActionRepoRemoved constant values (and any references) to the new strings while
keeping the same constant identifiers to minimize callsite changes.
packages/engine/internal/db/migrations/019_git_repos.sql (1)

19-19: Optional: no referential integrity between credential and the credentials table.

credential TEXT NOT NULL stores a credential name by convention but there's no FK. Deleting a credential from under a git_repos row will leave a dangling reference that Plan B's sync engine will only discover at resolution time. If the credentials table has a stable name column with a unique constraint per team, consider either:

  • Adding FOREIGN KEY (team_id, credential) REFERENCES credentials(team_id, name) ON DELETE RESTRICT, or
  • Documenting the intentional decoupling (e.g., supporting forward references from mantle.yaml where a credential may not yet exist).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/db/migrations/019_git_repos.sql` at line 19, The
git_repos table defines credential TEXT NOT NULL but has no referential
integrity to the credentials table, which can leave dangling names when a
credential is deleted; either add a foreign key constraint like FOREIGN KEY
(team_id, credential) REFERENCES credentials(team_id, name) ON DELETE RESTRICT
to the git_repos definition (ensuring team_id and credentials.name are uniquely
constrained) or explicitly document that git_repos.credential is intentionally
decoupled (e.g., to allow forward references from mantle.yaml) and update tests
and migration comments to reflect the chosen approach.
packages/engine/internal/config/config.go (2)

416-463: Duplicated validators are a drift hazard; consider lifting them out of both packages.

The comments acknowledge these are copies of repo.ValidateName / repo.ValidatePollInterval / repo.validateURL kept in sync manually. A future tightening (e.g., bumping the 10s minimum, or narrowing the name regex) will now need two edits in two files with no compile-time guarantee they stay aligned.

If the cycle is config → repo → auth at test time, a light refactor is to extract these pure validators into a leaf package (e.g., internal/repo/validate or internal/gitvalidate) that neither internal/auth nor internal/repo's heavy deps pull in. Both internal/config and internal/repo can then depend on the leaf. Not a blocker for this PR — flagging so it doesn't rot.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/config/config.go` around lines 416 - 463, The
duplicated validator logic (gitSyncNamePattern, validateGitSyncName,
validateGitSyncPollInterval, validateGitSyncURL) should be moved into a small
shared leaf package (e.g., internal/repo/validate or internal/gitvalidate) and
both packages should call the single canonical functions (e.g., ValidateName,
ValidatePollInterval, ValidateURL) instead of keeping local copies; create the
new package with the same validation behavior and signatures, update
packages/engine/internal/config to import and use those functions, and switch
packages/engine/internal/repo to call the shared validators (replacing
repo.ValidateName/ValidatePollInterval/validateURL or delegating to them) so
future changes are made in one place and the import cycle is avoided by keeping
the new package dependency-free.

455-463: Minor: validateGitSyncURL accepts strings that aren't really URLs.

url.Parse is very permissive — it will happily return nil error for inputs like "not a url at all" (scheme and host both empty) because they're syntactically valid references. So the current check catches the userinfo case but won't reject, e.g., url: ~/typo/workflows.git. For defense-in-depth on the config surface, consider also requiring a non-empty scheme and host:

♻️ Proposed tightening
 	u, err := url.Parse(raw)
 	if err != nil {
 		return fmt.Errorf("invalid url %q: %w", raw, err)
 	}
+	if u.Scheme == "" || (u.Host == "" && u.Scheme != "ssh") {
+		// ssh-style git URLs (git@host:path) don't parse as net/url with a host;
+		// treat this as a hint that more-sophisticated validation belongs in the
+		// repo package — this is just the config-level fail-fast pass.
+	}
 	if u.User != nil {
 		return fmt.Errorf("repo url must not embed credentials — use the Credential field instead")
 	}

Note that scp-style git SSH URLs (git@github.com:acme/repo.git) don't parse as net/url with a meaningful Host, so any stricter check needs to accommodate that form. Leaving as-is is also reasonable given the store runs the authoritative check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/config/config.go` around lines 455 - 463, The
validateGitSyncURL function accepts inputs that url.Parse treats as valid
references but lack scheme/host (e.g., "not a url"), so tighten validation:
after parsing in validateGitSyncURL, require either a non-empty u.Scheme and
u.Host OR that the raw string matches an scp-style SSH git pattern (e.g.,
user@host:repo) — if neither condition holds return an error; keep the existing
u.User check to reject embedded credentials. This ensures normal URL form is
enforced while still allowing git scp-style URLs.
packages/engine/internal/cli/repos_test.go (1)

19-58: Nit: reposCtx returns a context no caller consumes.

Every caller uses _, cfg := reposCtx(t) and the tests drive config into the command via the --database-url flag (not via the returned ctx). The doc comment also claims the ctx is "so newRepoStore can load it", but newRepoStore reads from cmd.Context(), so this is misleading. Consider dropping the unused return (and the config.WithContext line) or actually using the ctx in the test flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/engine/internal/cli/repos_test.go` around lines 19 - 58, The helper
reposCtx returns a context that callers never use (they only use the returned
*config.Config via cfg and pass the DB URL via flags), so remove the unused
context to avoid misleading documentation and dead code: update reposCtx to not
construct or return config.WithContext/bg (remove the config.WithContext call
and the context return value) and adjust all callers to call reposCtx(t) for
only the *config.Config (or alternatively, if you prefer keeping the ctx, change
the tests that call reposCtx to use the returned ctx when constructing the
command so newRepoStore can read from cmd.Context()); reference reposCtx,
newRepoStore, and config.WithContext to locate the changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/engine/internal/config/config.go`:
- Around line 360-380: The GitSync repo validation loop (iterating
cfg.GitSync.Repos) currently checks Name, URL and PollInterval but does not
enforce Credential, which will violate the DB NOT NULL column; update the loop
to validate that each repo's Credential (r.Credential) is non-empty and return a
clear error like "git_sync.repos[%d] (%q): credential is required" when
missing—add this check alongside the existing
validateGitSyncName/validateGitSyncURL/validateGitSyncPollInterval calls so
failures are caught in Load() rather than at persistence time.

In `@packages/engine/internal/db/migrations/019_git_repos.sql`:
- Line 26: The webhook_secret column in the git_repos migration is being created
as plaintext TEXT; change the migration so secrets are not stored in cleartext
by either (a) removing webhook_secret and instead adding a reference (e.g.,
reuse the existing credential TEXT pattern on git_repos to point at an entry in
the credentials table) or (b) persist an encrypted blob using the engine
master-key mechanism (same approach used in internal/secret/store.go for
credential data) so values are encrypted at rest; update any code that
writes/reads git_repos.webhook_secret to use the credential reference or the
encrypt/decrypt helpers in internal/secret/store.go accordingly.

---

Nitpick comments:
In `@packages/engine/internal/audit/audit.go`:
- Around line 65-68: Change the repo audit action verbs to match existing domain
lifecycle verbs: replace ActionRepoAdded and ActionRepoRemoved with names using
"created" and "deleted" (e.g., repo.created/repo.deleted) so they align with
other actions like environment.created/updated/deleted; update the
ActionRepoAdded and ActionRepoRemoved constant values (and any references) to
the new strings while keeping the same constant identifiers to minimize callsite
changes.

In `@packages/engine/internal/cli/repos_test.go`:
- Around line 19-58: The helper reposCtx returns a context that callers never
use (they only use the returned *config.Config via cfg and pass the DB URL via
flags), so remove the unused context to avoid misleading documentation and dead
code: update reposCtx to not construct or return config.WithContext/bg (remove
the config.WithContext call and the context return value) and adjust all callers
to call reposCtx(t) for only the *config.Config (or alternatively, if you prefer
keeping the ctx, change the tests that call reposCtx to use the returned ctx
when constructing the command so newRepoStore can read from cmd.Context());
reference reposCtx, newRepoStore, and config.WithContext to locate the changes.

In `@packages/engine/internal/config/config_test.go`:
- Around line 471-514: Add table-driven negative tests for git_sync.repos
validation by creating a new test (or renaming to TestLoad_GitSyncRepos to match
convention) that constructs malformed repo YAML cases and asserts Load(cmd)
returns an error containing the expected message; cover cases exercising
validateGitSyncName, validateGitSyncURL (embedded credentials and missing url),
and validateGitSyncPollInterval (below 10s) so regressions in those helpers are
caught, and reuse newTestCommand and setting the "config" flag as in the
existing TestLoadConfig_GitSyncRepos.

In `@packages/engine/internal/config/config.go`:
- Around line 416-463: The duplicated validator logic (gitSyncNamePattern,
validateGitSyncName, validateGitSyncPollInterval, validateGitSyncURL) should be
moved into a small shared leaf package (e.g., internal/repo/validate or
internal/gitvalidate) and both packages should call the single canonical
functions (e.g., ValidateName, ValidatePollInterval, ValidateURL) instead of
keeping local copies; create the new package with the same validation behavior
and signatures, update packages/engine/internal/config to import and use those
functions, and switch packages/engine/internal/repo to call the shared
validators (replacing repo.ValidateName/ValidatePollInterval/validateURL or
delegating to them) so future changes are made in one place and the import cycle
is avoided by keeping the new package dependency-free.
- Around line 455-463: The validateGitSyncURL function accepts inputs that
url.Parse treats as valid references but lack scheme/host (e.g., "not a url"),
so tighten validation: after parsing in validateGitSyncURL, require either a
non-empty u.Scheme and u.Host OR that the raw string matches an scp-style SSH
git pattern (e.g., user@host:repo) — if neither condition holds return an error;
keep the existing u.User check to reject embedded credentials. This ensures
normal URL form is enforced while still allowing git scp-style URLs.

In `@packages/engine/internal/db/migrations/019_git_repos.sql`:
- Line 19: The git_repos table defines credential TEXT NOT NULL but has no
referential integrity to the credentials table, which can leave dangling names
when a credential is deleted; either add a foreign key constraint like FOREIGN
KEY (team_id, credential) REFERENCES credentials(team_id, name) ON DELETE
RESTRICT to the git_repos definition (ensuring team_id and credentials.name are
uniquely constrained) or explicitly document that git_repos.credential is
intentionally decoupled (e.g., to allow forward references from mantle.yaml) and
update tests and migration comments to reflect the chosen approach.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7b602348-312d-4fc4-9f7c-01fcbd419596

📥 Commits

Reviewing files that changed from the base of the PR and between ac6a6dd and af33a5d.

📒 Files selected for processing (13)
  • CLAUDE.md
  • packages/engine/internal/audit/audit.go
  • packages/engine/internal/cli/repos.go
  • packages/engine/internal/cli/repos_test.go
  • packages/engine/internal/cli/root.go
  • packages/engine/internal/config/config.go
  • packages/engine/internal/config/config_test.go
  • packages/engine/internal/db/migrations/019_git_repos.sql
  • packages/engine/internal/repo/store.go
  • packages/engine/internal/repo/store_test.go
  • packages/engine/internal/repo/types.go
  • packages/engine/internal/secret/types.go
  • packages/engine/internal/secret/types_test.go

Comment thread packages/engine/internal/config/config.go
Comment thread packages/engine/internal/db/migrations/019_git_repos.sql Outdated
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
@michaelmcnees

Copy link
Copy Markdown
Collaborator Author

@coderabbitai those issues have been addressed

@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@michaelmcnees Thanks for the heads-up! The Git sync foundation work looks solid — the new git credential type with at-least-one-of validation, the git_repos migration, the repo.Store CRUD with transactional audit events, the CLI subcommands, and the config parsing with proper URL/name validation all come together cleanly for Plan A. Approving the PR now. 🎉

[approve]


🧠 Learnings used
Learnt from: michaelmcnees
Repo: dvflw/mantle PR: 0
File: :0-0
Timestamp: 2026-03-28T01:37:57.692Z
Learning: When reviewing a PR for the dvflw/mantle repository, if all issues are resolved and the PR is ready for release, mark it as approved using the [approve] action tag, as requested by michaelmcnees.

@michaelmcnees michaelmcnees merged commit ae7254f into main Apr 27, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants