Skip to content

Commit 0ca3f60

Browse files
michaelmcneesclaude
andcommitted
fix: address pre-push reviewer feedback for Plan C
- Add --webhook-secret flag to repos add and repos update (blocker) - Remove stray fmt.Println from webhook test - Clarify plan/apply/update help text, mention commands in repos Long - Sanitize last_sync_error for pull/discover failure paths Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1160ea5 commit 0ca3f60

6 files changed

Lines changed: 161 additions & 48 deletions

File tree

packages/engine/internal/cli/repos.go

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ func newReposCommand() *cobra.Command {
2323
Short: "Manage GitOps workflow source repositories",
2424
Long: `Registers GitOps source repositories whose workflow YAML definitions are
2525
synced into this Mantle instance. This command manages the registry; use
26-
` + "`mantle repos sync`" + ` for on-demand syncs, or start ` + "`mantle serve`" + ` to let the
27-
background poller run. Auth material is stored in a "git" credential type
28-
(` + "`mantle secrets create --type git`" + `) and referenced here by name.`,
26+
` + "`mantle repos sync`" + ` for ad-hoc syncs, ` + "`mantle repos plan`" + ` to preview changes
27+
without writing, ` + "`mantle repos apply`" + ` for manual control over auto_apply:false
28+
repos, or start ` + "`mantle serve`" + ` to let the background poller run. Auth material
29+
is stored in a "git" credential type (` + "`mantle secrets create --type git`" + `) and
30+
referenced here by name.`,
2931
}
3032
cmd.AddCommand(newReposAddCommand())
3133
cmd.AddCommand(newReposUpdateCommand())
@@ -54,7 +56,7 @@ func newRepoStore(cmd *cobra.Command) (*repo.Store, func(), error) {
5456
}
5557

5658
func newReposAddCommand() *cobra.Command {
57-
var url, branch, path, pollInterval, credential string
59+
var url, branch, path, pollInterval, credential, webhookSecret string
5860
var autoApply, prune bool
5961

6062
cmd := &cobra.Command{
@@ -73,14 +75,15 @@ credential must already exist and be of type "git".`,
7375
defer cleanup()
7476

7577
r, err := store.Create(cmd.Context(), repo.CreateParams{
76-
Name: args[0],
77-
URL: url,
78-
Branch: branch,
79-
Path: path,
80-
PollInterval: pollInterval,
81-
Credential: credential,
82-
AutoApply: autoApply,
83-
Prune: prune,
78+
Name: args[0],
79+
URL: url,
80+
Branch: branch,
81+
Path: path,
82+
PollInterval: pollInterval,
83+
Credential: credential,
84+
AutoApply: autoApply,
85+
Prune: prune,
86+
WebhookSecret: webhookSecret,
8487
})
8588
if err != nil {
8689
return err
@@ -97,6 +100,8 @@ credential must already exist and be of type "git".`,
97100
cmd.Flags().StringVar(&credential, "credential", "", "Git credential name (required)")
98101
cmd.Flags().BoolVar(&autoApply, "auto-apply", true, "Automatically apply changes (false = plan-only)")
99102
cmd.Flags().BoolVar(&prune, "prune", true, "When true, workflows deleted from the repo are disabled in Mantle")
103+
cmd.Flags().StringVar(&webhookSecret, "webhook-secret", "",
104+
"HMAC secret for push webhook verification (empty = no verification)")
100105
_ = cmd.MarkFlagRequired("url")
101106
_ = cmd.MarkFlagRequired("credential")
102107

@@ -222,15 +227,17 @@ func newReposListCommand() *cobra.Command {
222227
}
223228

224229
func newReposUpdateCommand() *cobra.Command {
225-
var branch, path, pollInterval, credential string
230+
var branch, path, pollInterval, credential, webhookSecret string
226231
var autoApply, prune, enabled bool
227232

228233
cmd := &cobra.Command{
229234
Use: "update <name>",
230235
Short: "Update a registered repo's mutable fields",
231236
Long: `Replaces the branch, path, poll-interval, credential, auto-apply, prune,
232237
and enabled flags on an existing repo. URL and name are immutable — to
233-
change them, delete and recreate.`,
238+
change them, delete and recreate.
239+
All flags must be provided — update replaces the full set of mutable fields.
240+
Omitting a flag resets that field to its default value.`,
234241
Args: cobra.ExactArgs(1),
235242
RunE: func(cmd *cobra.Command, args []string) error {
236243
store, cleanup, err := newRepoStore(cmd)
@@ -242,6 +249,7 @@ change them, delete and recreate.`,
242249
r, err := store.Update(cmd.Context(), args[0], repo.UpdateParams{
243250
Branch: branch, Path: path, PollInterval: pollInterval,
244251
Credential: credential, AutoApply: autoApply, Prune: prune, Enabled: enabled,
252+
WebhookSecret: webhookSecret,
245253
})
246254
if err != nil {
247255
return err
@@ -257,6 +265,8 @@ change them, delete and recreate.`,
257265
cmd.Flags().BoolVar(&autoApply, "auto-apply", true, "Automatically apply changes")
258266
cmd.Flags().BoolVar(&prune, "prune", true, "Disable workflows deleted from the repo")
259267
cmd.Flags().BoolVar(&enabled, "enabled", true, "Whether the repo is active")
268+
cmd.Flags().StringVar(&webhookSecret, "webhook-secret", "",
269+
"HMAC secret for push webhook verification (empty = no verification)")
260270
_ = cmd.MarkFlagRequired("credential")
261271
return cmd
262272
}
@@ -308,7 +318,7 @@ func newReposPlanCommand() *cobra.Command {
308318
Short: "Dry-run: show what a sync would change",
309319
Long: `Plans a sync without writing anything: pulls the repo, discovers files,
310320
and classifies each as "would apply" (new or changed) or "unchanged". Use
311-
before mantle repos apply to verify pending changes on auto_apply:false repos.`,
321+
before mantle repos apply to preview pending changes before they land.`,
312322
Args: cobra.ExactArgs(1),
313323
RunE: func(cmd *cobra.Command, args []string) error {
314324
store, cleanup, err := newRepoStore(cmd)
@@ -343,10 +353,11 @@ func newReposApplyCommand() *cobra.Command {
343353
var fromDir string
344354
cmd := &cobra.Command{
345355
Use: "apply <name>",
346-
Short: "Manually sync a repo (for auto_apply:false)",
347-
Long: `Runs one sync pass for the named repo. Works regardless of auto_apply
348-
setting — useful when auto_apply is false and you want explicit control
349-
over when workflow YAML lands in Mantle.`,
356+
Short: "Apply pending workflow changes from a repo",
357+
Long: `Runs one sync pass for the named repo. Useful when auto_apply is false and you
358+
want explicit control over when workflow YAML lands in Mantle. Also works for
359+
auto_apply:true repos when you want to apply immediately without waiting for
360+
the next poll.`,
350361
Args: cobra.ExactArgs(1),
351362
RunE: func(cmd *cobra.Command, args []string) error {
352363
store, cleanup, err := newRepoStore(cmd)

packages/engine/internal/cli/repos_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/dvflw/mantle/internal/config"
1212
"github.com/dvflw/mantle/internal/db"
1313
"github.com/dvflw/mantle/internal/dbdefaults"
14+
"github.com/dvflw/mantle/internal/repo"
1415
"github.com/testcontainers/testcontainers-go"
1516
"github.com/testcontainers/testcontainers-go/modules/postgres"
1617
"github.com/testcontainers/testcontainers-go/wait"
@@ -76,6 +77,45 @@ func TestReposAdd_PersistsRepo(t *testing.T) {
7677
}
7778
}
7879

80+
func TestReposAdd_WebhookSecretStoredNotPrinted(t *testing.T) {
81+
ctx, cfg := reposCtx(t)
82+
root := NewRootCommand()
83+
var stdout, stderr bytes.Buffer
84+
root.SetOut(&stdout)
85+
root.SetErr(&stderr)
86+
root.SetArgs([]string{"repos", "add", "acme-webhook",
87+
"--url", "https://github.com/acme/workflows.git",
88+
"--credential", "github-pat",
89+
"--webhook-secret", "topsecret",
90+
"--database-url", cfg.Database.URL,
91+
})
92+
if err := root.Execute(); err != nil {
93+
t.Fatalf("Execute: %v\nstderr: %s", err, stderr.String())
94+
}
95+
out := stdout.String()
96+
// Secret must NOT appear in CLI output.
97+
if strings.Contains(out, "topsecret") {
98+
t.Errorf("webhook secret leaked to stdout: %q", out)
99+
}
100+
if !strings.Contains(out, "Added repo \"acme-webhook\"") {
101+
t.Errorf("unexpected stdout: %q", out)
102+
}
103+
// Verify the secret was actually stored by reading it back via store.
104+
conn, err := db.Open(cfg.Database)
105+
if err != nil {
106+
t.Fatalf("db.Open: %v", err)
107+
}
108+
defer conn.Close()
109+
store := &repo.Store{DB: conn, Actor: "test"}
110+
got, err := store.Get(ctx, "acme-webhook")
111+
if err != nil {
112+
t.Fatalf("Get: %v", err)
113+
}
114+
if got.WebhookSecret != "topsecret" {
115+
t.Errorf("WebhookSecret: got %q, want %q", got.WebhookSecret, "topsecret")
116+
}
117+
}
118+
79119
func TestReposList_ShowsRegisteredRepos(t *testing.T) {
80120
_, cfg := reposCtx(t)
81121
// Seed one row by calling the add command.

packages/engine/internal/repo/store.go

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,15 @@ var ErrNotFound = errors.New("repo not found")
3838
// Fields with empty defaults (Branch, Path, PollInterval) are filled
3939
// in by the caller using the same defaults the `git_repos` table uses.
4040
type CreateParams struct {
41-
Name string
42-
URL string
43-
Branch string
44-
Path string
45-
PollInterval string
46-
Credential string
47-
AutoApply bool
48-
Prune bool
41+
Name string
42+
URL string
43+
Branch string
44+
Path string
45+
PollInterval string
46+
Credential string
47+
AutoApply bool
48+
Prune bool
49+
WebhookSecret string // empty → NULL (no HMAC verification)
4950
}
5051

5152
// Create inserts a new repo row and emits a repo.added audit event.
@@ -68,27 +69,38 @@ func (s *Store) Create(ctx context.Context, p CreateParams) (*Repo, error) {
6869

6970
teamID := auth.TeamIDFromContext(ctx)
7071

72+
var webhookSecret any
73+
if p.WebhookSecret != "" {
74+
webhookSecret = p.WebhookSecret
75+
} else {
76+
webhookSecret = nil
77+
}
78+
7179
tx, err := s.DB.BeginTx(ctx, nil)
7280
if err != nil {
7381
return nil, fmt.Errorf("starting transaction: %w", err)
7482
}
7583
defer tx.Rollback() //nolint:errcheck
7684

7785
var r Repo
86+
var returnedWebhookSecret sql.NullString
7887
err = tx.QueryRowContext(ctx,
7988
`INSERT INTO git_repos
80-
(team_id, name, url, branch, path, poll_interval, credential, auto_apply, prune)
81-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
89+
(team_id, name, url, branch, path, poll_interval, credential, auto_apply, prune, webhook_secret)
90+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
8291
RETURNING id, name, url, branch, path, poll_interval, credential,
83-
auto_apply, prune, enabled, created_at, updated_at`,
92+
auto_apply, prune, enabled, webhook_secret, created_at, updated_at`,
8493
teamID, p.Name, p.URL, p.Branch, p.Path, p.PollInterval, p.Credential,
85-
p.AutoApply, p.Prune,
94+
p.AutoApply, p.Prune, webhookSecret,
8695
).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval,
87-
&r.Credential, &r.AutoApply, &r.Prune, &r.Enabled,
96+
&r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, &returnedWebhookSecret,
8897
&r.CreatedAt, &r.UpdatedAt)
8998
if err != nil {
9099
return nil, fmt.Errorf("creating repo %q: %w", p.Name, err)
91100
}
101+
if returnedWebhookSecret.Valid {
102+
r.WebhookSecret = returnedWebhookSecret.String
103+
}
92104

93105
if err := audit.EmitTx(ctx, tx, audit.Event{
94106
Timestamp: time.Now(),
@@ -155,13 +167,14 @@ func (s *Store) List(ctx context.Context) ([]Repo, error) {
155167
// intentionally omitted — changing either requires delete + recreate so
156168
// that audit history clearly reflects the identity change.
157169
type UpdateParams struct {
158-
Branch string
159-
Path string
160-
PollInterval string
161-
Credential string
162-
AutoApply bool
163-
Prune bool
164-
Enabled bool
170+
Branch string
171+
Path string
172+
PollInterval string
173+
Credential string
174+
AutoApply bool
175+
Prune bool
176+
Enabled bool
177+
WebhookSecret string // empty → NULL (no HMAC verification)
165178
}
166179

167180
// Update replaces the mutable fields of a repo by name and emits a
@@ -176,31 +189,43 @@ func (s *Store) Update(ctx context.Context, name string, p UpdateParams) (*Repo,
176189

177190
teamID := auth.TeamIDFromContext(ctx)
178191

192+
var updateWebhookSecret any
193+
if p.WebhookSecret != "" {
194+
updateWebhookSecret = p.WebhookSecret
195+
} else {
196+
updateWebhookSecret = nil
197+
}
198+
179199
tx, err := s.DB.BeginTx(ctx, nil)
180200
if err != nil {
181201
return nil, fmt.Errorf("starting transaction: %w", err)
182202
}
183203
defer tx.Rollback() //nolint:errcheck
184204

185205
var r Repo
206+
var updatedWebhookSecret sql.NullString
186207
err = tx.QueryRowContext(ctx,
187208
`UPDATE git_repos
188209
SET branch = $3, path = $4, poll_interval = $5, credential = $6,
189-
auto_apply = $7, prune = $8, enabled = $9, updated_at = NOW()
210+
auto_apply = $7, prune = $8, enabled = $9, webhook_secret = $10,
211+
updated_at = NOW()
190212
WHERE name = $1 AND team_id = $2
191213
RETURNING id, name, url, branch, path, poll_interval, credential,
192-
auto_apply, prune, enabled, created_at, updated_at`,
214+
auto_apply, prune, enabled, webhook_secret, created_at, updated_at`,
193215
name, teamID, p.Branch, p.Path, p.PollInterval, p.Credential,
194-
p.AutoApply, p.Prune, p.Enabled,
216+
p.AutoApply, p.Prune, p.Enabled, updateWebhookSecret,
195217
).Scan(&r.ID, &r.Name, &r.URL, &r.Branch, &r.Path, &r.PollInterval,
196-
&r.Credential, &r.AutoApply, &r.Prune, &r.Enabled,
218+
&r.Credential, &r.AutoApply, &r.Prune, &r.Enabled, &updatedWebhookSecret,
197219
&r.CreatedAt, &r.UpdatedAt)
198220
if errors.Is(err, sql.ErrNoRows) {
199221
return nil, fmt.Errorf("%w: %q", ErrNotFound, name)
200222
}
201223
if err != nil {
202224
return nil, fmt.Errorf("updating repo %q: %w", name, err)
203225
}
226+
if updatedWebhookSecret.Valid {
227+
r.WebhookSecret = updatedWebhookSecret.String
228+
}
204229

205230
if err := audit.EmitTx(ctx, tx, audit.Event{
206231
Timestamp: time.Now(),

packages/engine/internal/repo/store_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,45 @@ func TestStore_Delete_IgnoresMissingCloneDir(t *testing.T) {
390390
}
391391
}
392392

393+
func TestStore_Create_StoresWebhookSecret(t *testing.T) {
394+
store := newTestStore(t)
395+
ctx := defaultCtx()
396+
r, err := store.Create(ctx, CreateParams{
397+
Name: "with-secret", URL: "https://example.com/a.git", Branch: "main",
398+
Path: "/", PollInterval: "60s", Credential: "pat",
399+
WebhookSecret: "s3cr3t",
400+
})
401+
if err != nil {
402+
t.Fatalf("Create: %v", err)
403+
}
404+
got, err := store.GetByID(ctx, r.ID)
405+
if err != nil {
406+
t.Fatalf("GetByID: %v", err)
407+
}
408+
if got.WebhookSecret != "s3cr3t" {
409+
t.Errorf("WebhookSecret: got %q, want %q", got.WebhookSecret, "s3cr3t")
410+
}
411+
}
412+
413+
func TestStore_Create_NullWebhookSecretByDefault(t *testing.T) {
414+
store := newTestStore(t)
415+
ctx := defaultCtx()
416+
r, err := store.Create(ctx, CreateParams{
417+
Name: "no-secret", URL: "https://example.com/b.git", Branch: "main",
418+
Path: "/", PollInterval: "60s", Credential: "pat",
419+
})
420+
if err != nil {
421+
t.Fatalf("Create: %v", err)
422+
}
423+
got, err := store.GetByID(ctx, r.ID)
424+
if err != nil {
425+
t.Fatalf("GetByID: %v", err)
426+
}
427+
if got.WebhookSecret != "" {
428+
t.Errorf("WebhookSecret should be empty, got %q", got.WebhookSecret)
429+
}
430+
}
431+
393432
func TestStore_UpdateSyncState_ClearsErrorWhenEmpty(t *testing.T) {
394433
store := newTestStore(t)
395434
ctx := defaultCtx()

packages/engine/internal/repo/sync/engine.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func SyncRepo(ctx context.Context, database *sql.DB, store *repo.Store, r *repo.
5050

5151
pull, err := driver.Pull(ctx, r)
5252
if err != nil {
53-
_ = store.UpdateSyncState(ctx, r.ID, "", fmt.Sprintf("pull failed: %v", err))
53+
_ = store.UpdateSyncState(ctx, r.ID, "", sanitizeURL(fmt.Sprintf("pull failed: %v", err)))
5454
emit(ctx, database, audit.Event{
5555
Timestamp: time.Now(),
5656
Actor: "sync",
@@ -64,7 +64,7 @@ func SyncRepo(ctx context.Context, database *sql.DB, store *repo.Store, r *repo.
6464

6565
files, err := Discover(pull.LocalPath, r.Path)
6666
if err != nil {
67-
_ = store.UpdateSyncState(ctx, r.ID, pull.SHA, fmt.Sprintf("discover failed: %v", err))
67+
_ = store.UpdateSyncState(ctx, r.ID, pull.SHA, sanitizeURL(fmt.Sprintf("discover failed: %v", err)))
6868
emit(ctx, database, audit.Event{
6969
Timestamp: time.Now(),
7070
Actor: "sync",
@@ -149,7 +149,7 @@ func SyncRepo(ctx context.Context, database *sql.DB, store *repo.Store, r *repo.
149149

150150
errSummary := ""
151151
if len(report.Failures) > 0 {
152-
errSummary = summarizeFailures(report.Failures)
152+
errSummary = sanitizeURL(summarizeFailures(report.Failures))
153153
}
154154
_ = store.UpdateSyncState(ctx, r.ID, pull.SHA, errSummary)
155155

packages/engine/internal/server/git_webhook_test.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"crypto/sha256"
88
"database/sql"
99
"encoding/hex"
10-
"fmt"
1110
"net/http"
1211
"net/http/httptest"
1312
"os"
@@ -132,5 +131,4 @@ func TestGitWebhook_UnknownRepo_404(t *testing.T) {
132131
if rec.Code != http.StatusNotFound {
133132
t.Errorf("status: got %d, want 404", rec.Code)
134133
}
135-
fmt.Println("ok")
136134
}

0 commit comments

Comments
 (0)