feat: M6 — webhook config + watch wiring

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.
This commit is contained in:
dimitar 2026-08-02 09:23:52 +02:00
parent 55117c5a8a
commit 68930b4a05
4 changed files with 144 additions and 24 deletions

View File

@ -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.

View File

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

View File

@ -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()

View File

@ -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.