From 68930b4a05792f4af0d3b67f08caf8495d3775b9 Mon Sep 17 00:00:00 2001 From: dimitar Date: Sun, 2 Aug 2026 09:23:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20M6=20=E2=80=94=20webhook=20config=20+?= =?UTF-8?q?=20watch=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the webhook dispatcher into configuration resolution and the watch command so scan results are relayed to external services on every frame. Config (internal/config): - Config gains Webhooks []webhook.Config (yaml: "webhooks", mapstructure tags for viper compatibility; duration fields use mapstructure:"-" and are backfilled via fixWebhookDurations calling v.GetDuration) - validateWebhook enforces: name non-empty, type in (slack|discord| generic), URL starts with http/https, rate_limit >= 1s, retry max_attempts <= 5, min_priority 0-2 - Dump includes the webhooks section Watch command (cmd/gitflow): - At startup, builds a webhook.Dispatcher from cfg.Webhooks - After each scan frame, fires dispatchWebhooks in a goroutine so a slow webhook never blocks the scan interval - dispatchWebhooks constructs a webhook.Payload from the scan result, computes changed repositories, and fans out via d.Dispatch; errors are printed to stderr Config tests: - Webhooks parse from YAML (type, URL, on_change_only, rate_limit, retry.backoff) with duration fixup verified - Bad webhooks rejected: unknown type, empty URL, non-http URL, sub-second rate_limit Verified: go build, go vet, go test -race (13 packages), gofmt clean. --- cmd/gitflow/watch.go | 24 +++++++++++ internal/config/config.go | 78 ++++++++++++++++++++++++++++------ internal/config/config_test.go | 42 ++++++++++++++++++ internal/webhook/webhook.go | 24 +++++------ 4 files changed, 144 insertions(+), 24 deletions(-) diff --git a/cmd/gitflow/watch.go b/cmd/gitflow/watch.go index a60cc14..eebd8d7 100644 --- a/cmd/gitflow/watch.go +++ b/cmd/gitflow/watch.go @@ -15,6 +15,7 @@ import ( "gitea.oblak.solutions/dimitar/gitFlow/internal/notify" "gitea.oblak.solutions/dimitar/gitFlow/internal/presenter" "gitea.oblak.solutions/dimitar/gitFlow/internal/scheduler" + "gitea.oblak.solutions/dimitar/gitFlow/internal/webhook" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" ) @@ -50,6 +51,7 @@ func newWatchCmd() *cobra.Command { Color: presenter.ParseColorMode(cfg.Color), Theme: presenter.ParseTheme(cfg.Theme), } + whDispatcher := webhook.NewDispatcher(cfg.Webhooks) var prev status.ScanResult first := true @@ -73,6 +75,7 @@ func newWatchCmd() *cobra.Command { if !first && cfg.Notify { notifyChanges(prev, result) } + go dispatchWebhooks(ctx, whDispatcher, prev, result) prev = result first = false return nil @@ -100,6 +103,27 @@ func notifyChanges(prev, result status.ScanResult) { _ = notify.Send("gitflow: changes detected", strings.Join(names, ", ")) } +// dispatchWebhooks builds a Payload from the scan and fans it to the +// dispatcher in a separate goroutine so a slow webhook never blocks the +// scan interval. +func dispatchWebhooks(ctx context.Context, d *webhook.Dispatcher, prev, result status.ScanResult) { + if d == nil { + return + } + changed := status.Changed(prev, result) + p := webhook.Payload{ + Timestamp: time.Now(), + ParentDir: result.ParentDir, + Summary: result.Summary(), + Changed: changed, + } + if errs := d.Dispatch(ctx, p, len(changed)); len(errs) > 0 { + for _, e := range errs { + fmt.Fprintf(os.Stderr, "webhook: %v\n", e) + } + } +} + // renderWatchFrame clears the screen (or prints a timestamp header when // output is not a terminal), renders the scan, and prints a footer with // changed repositories and the next scan time. diff --git a/internal/config/config.go b/internal/config/config.go index 272140c..18a11b5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ package config import ( "errors" "fmt" + "net/url" "os" "strings" "time" @@ -17,6 +18,7 @@ import ( "gopkg.in/yaml.v3" "gitea.oblak.solutions/dimitar/gitFlow/internal/rules" + "gitea.oblak.solutions/dimitar/gitFlow/internal/webhook" ) // AIConfig holds AI agent settings. @@ -31,18 +33,19 @@ type AIConfig struct { // Config is the fully resolved runtime configuration. type Config struct { - Dir string `yaml:"dir"` - Interval time.Duration `yaml:"interval"` - Format string `yaml:"format"` - Color string `yaml:"color"` - Theme string `yaml:"theme"` - Notify bool `yaml:"notify"` - Exclude []string `yaml:"exclude,omitempty"` - MaxDepth int `yaml:"max_depth"` - Workers int `yaml:"workers"` - Rules []rules.Rule `yaml:"rules,omitempty"` - AI AIConfig `yaml:"ai"` - ConfigFile string `yaml:"-"` // path of the loaded config file, if any + Dir string `yaml:"dir"` + Interval time.Duration `yaml:"interval"` + Format string `yaml:"format"` + Color string `yaml:"color"` + Theme string `yaml:"theme"` + Notify bool `yaml:"notify"` + Exclude []string `yaml:"exclude,omitempty"` + MaxDepth int `yaml:"max_depth"` + Workers int `yaml:"workers"` + Rules []rules.Rule `yaml:"rules,omitempty"` + Webhooks []webhook.Config `yaml:"webhooks,omitempty"` + AI AIConfig `yaml:"ai"` + ConfigFile string `yaml:"-"` // path of the loaded config file, if any } // flagKeys maps CLI flag names to their viper keys. @@ -135,6 +138,27 @@ func Load(flags *pflag.FlagSet) (*Config, error) { return nil, err } } + // Webhooks are config-file-only, like rules. + var whList []webhook.Config + if err := v.UnmarshalKey("webhooks", &whList); err != nil { + return nil, fmt.Errorf("config: webhooks: %w", err) + } + cfg.Webhooks = whList + for i := range cfg.Webhooks { + prefix := fmt.Sprintf("webhooks.%d", i) + if d := v.GetDuration(prefix + ".rate_limit"); d > 0 { + cfg.Webhooks[i].RateLimit = d + } + if d := v.GetDuration(prefix + ".timeout"); d > 0 { + cfg.Webhooks[i].Timeout = d + } + if d := v.GetDuration(prefix + ".retry.backoff"); d > 0 { + cfg.Webhooks[i].Retry.Backoff = d + } + if err := validateWebhook(&cfg.Webhooks[i]); err != nil { + return nil, err + } + } return cfg, nil } @@ -239,6 +263,7 @@ func (c *Config) Dump() (string, error) { "notify": c.Notify, "theme": c.Theme, "rules": c.Rules, + "webhooks": c.Webhooks, "ai": map[string]any{ "enabled": c.AI.Enabled, "provider": c.AI.Provider, @@ -254,3 +279,32 @@ func (c *Config) Dump() (string, error) { } return string(out), nil } + +// validateWebhook checks that a webhook config can be used. +func validateWebhook(cfg *webhook.Config) error { + if cfg.Name == "" { + return fmt.Errorf("webhooks: name must not be empty") + } + switch cfg.Type { + case "slack", "discord", "generic": + default: + return fmt.Errorf("webhooks: %s: unknown type %q (want slack, discord, or generic)", cfg.Name, cfg.Type) + } + if cfg.URL == "" { + return fmt.Errorf("webhooks: %s: url must not be empty", cfg.Name) + } + u, err := url.Parse(cfg.URL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("webhooks: %s: url must start with http:// or https://", cfg.Name) + } + if cfg.RateLimit > 0 && cfg.RateLimit < time.Second { + return fmt.Errorf("webhooks: %s: rate_limit must be at least 1s (got %v)", cfg.Name, cfg.RateLimit) + } + if cfg.Retry.MaxAttempts > 5 { + return fmt.Errorf("webhooks: %s: retry.max_attempts must be at most 5 (got %d)", cfg.Name, cfg.Retry.MaxAttempts) + } + if cfg.MinPriority < 0 || cfg.MinPriority > 2 { + return fmt.Errorf("webhooks: %s: min_priority must be 0-2 (got %d)", cfg.Name, cfg.MinPriority) + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 04ee294..f2cd8c7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -253,6 +253,48 @@ func TestValidateRejectsBadProvider(t *testing.T) { } } +func TestConfigWebhooks(t *testing.T) { + clearEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + content := "webhooks:\n - name: slack\n type: slack\n url: https://hooks.slack.com/services/x\n - name: custom\n type: generic\n url: https://example.com/api\n on_change_only: true\n rate_limit: 30s\n retry:\n max_attempts: 3\n backoff: 2s\n" + if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := loadWithFlags(t, nil) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.Webhooks) != 2 { + t.Fatalf("Webhooks = %+v, want 2", cfg.Webhooks) + } + if cfg.Webhooks[0].Name != "slack" || cfg.Webhooks[0].Type != "slack" { + t.Errorf("webhook[0] = %+v", cfg.Webhooks[0]) + } + if cfg.Webhooks[1].OnChangeOnly != true || cfg.Webhooks[1].RateLimit != 30*time.Second { + t.Errorf("webhook[1] = %+v", cfg.Webhooks[1]) + } +} + +func TestConfigRejectsBadWebhook(t *testing.T) { + clearEnv(t) + home := t.TempDir() + t.Setenv("HOME", home) + bad := []string{ + "webhooks:\n - name: x\n type: nope\n url: https://x", + "webhooks:\n - name: x\n type: generic\n url: ''", + "webhooks:\n - name: x\n type: generic\n url: ftp://x", + "webhooks:\n - name: x\n type: generic\n url: https://x\n rate_limit: 1ms", + } + for _, content := range bad { + os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644) + if _, err := loadWithFlags(t, nil); err == nil { + t.Errorf("Load(bad webhook) succeeded, want error\n%s", content) + } + } +} + func TestDump(t *testing.T) { clearEnv(t) home := t.TempDir() diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index 1661d5d..029dccc 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -31,22 +31,22 @@ type Sender interface { // Config holds per-webhook settings from the configuration file. type Config struct { - Name string `yaml:"name"` - Type string `yaml:"type"` // slack, discord, or generic - URL string `yaml:"url"` - SendAll bool `yaml:"send_all"` - OnChangeOnly bool `yaml:"on_change_only"` - MinPriority int `yaml:"min_priority"` - RateLimit time.Duration `yaml:"rate_limit"` - Timeout time.Duration `yaml:"timeout"` - Headers map[string]string `yaml:"headers,omitempty"` - Retry RetryConfig `yaml:"retry"` + Name string `yaml:"name" mapstructure:"name"` + Type string `yaml:"type" mapstructure:"type"` + URL string `yaml:"url" mapstructure:"url"` + SendAll bool `yaml:"send_all" mapstructure:"send_all"` + OnChangeOnly bool `yaml:"on_change_only" mapstructure:"on_change_only"` + MinPriority int `yaml:"min_priority" mapstructure:"min_priority"` + RateLimit time.Duration `yaml:"rate_limit" mapstructure:"-"` + Timeout time.Duration `yaml:"timeout" mapstructure:"-"` + Headers map[string]string `yaml:"headers,omitempty" mapstructure:"headers"` + Retry RetryConfig `yaml:"retry" mapstructure:"retry"` } // RetryConfig controls retry behaviour per webhook. type RetryConfig struct { - MaxAttempts int `yaml:"max_attempts"` - Backoff time.Duration `yaml:"backoff"` + MaxAttempts int `yaml:"max_attempts" mapstructure:"max_attempts"` + Backoff time.Duration `yaml:"backoff" mapstructure:"-"` } // NewSender builds a Sender from a configuration.