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.
317 lines
8.4 KiB
Go
317 lines
8.4 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/pflag"
|
|
)
|
|
|
|
// clearEnv removes every GITFLOW_ variable so tests start from a known
|
|
// state regardless of the developer's shell.
|
|
func clearEnv(t *testing.T) {
|
|
t.Helper()
|
|
for _, kv := range os.Environ() {
|
|
if strings.HasPrefix(kv, "GITFLOW_") {
|
|
key := strings.SplitN(kv, "=", 2)[0]
|
|
os.Unsetenv(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadWithFlags(t *testing.T, set func(f *pflag.FlagSet)) (*Config, error) {
|
|
t.Helper()
|
|
fs := NewFlagSet()
|
|
if set != nil {
|
|
set(fs)
|
|
}
|
|
return Load(fs)
|
|
}
|
|
|
|
func TestDefaults(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home) // no config file present
|
|
|
|
cfg, err := loadWithFlags(t, nil)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Dir != "." {
|
|
t.Errorf("Dir = %q, want %q", cfg.Dir, ".")
|
|
}
|
|
if cfg.Interval != 0 || cfg.MaxDepth != 0 {
|
|
t.Errorf("Interval/MaxDepth = %v/%d, want 0/0", cfg.Interval, cfg.MaxDepth)
|
|
}
|
|
if cfg.Format != "table" || cfg.Workers != 8 {
|
|
t.Errorf("Format/Workers = %q/%d, want table/8", cfg.Format, cfg.Workers)
|
|
}
|
|
if cfg.AI.Provider != "openai" || cfg.AI.Model != "gpt-4o" || cfg.AI.APIKeyEnv != "OPENAI_API_KEY" {
|
|
t.Errorf("AI defaults wrong: %+v", cfg.AI)
|
|
}
|
|
}
|
|
|
|
func TestFlagOverrides(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
cfg, err := loadWithFlags(t, func(f *pflag.FlagSet) {
|
|
if err := f.Set("format", "compact"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Set("dir", "/tmp/x"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Set("workers", "4"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Format != "compact" || cfg.Dir != "/tmp/x" || cfg.Workers != 4 {
|
|
t.Errorf("flag overrides not applied: %+v", cfg)
|
|
}
|
|
}
|
|
|
|
func TestEnvOverrides(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("GITFLOW_FORMAT", "json")
|
|
t.Setenv("GITFLOW_MAX_DEPTH", "3")
|
|
t.Setenv("GITFLOW_AI_PROVIDER", "ollama")
|
|
|
|
cfg, err := loadWithFlags(t, nil)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Format != "json" || cfg.MaxDepth != 3 || cfg.AI.Provider != "ollama" {
|
|
t.Errorf("env overrides not applied: %+v", cfg)
|
|
}
|
|
}
|
|
|
|
func TestFlagBeatsEnv(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("GITFLOW_FORMAT", "json")
|
|
|
|
cfg, err := loadWithFlags(t, func(f *pflag.FlagSet) {
|
|
if err := f.Set("format", "compact"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Format != "compact" {
|
|
t.Errorf("Format = %q, want compact (flag must beat env)", cfg.Format)
|
|
}
|
|
}
|
|
|
|
func TestConfigFile(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
content := "dir: /home/user/projects\nformat: json\nmax_depth: 2\ninterval: 5m\nexclude:\n - node_modules\n - vendor\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 cfg.Dir != "/home/user/projects" || cfg.Format != "json" || cfg.MaxDepth != 2 {
|
|
t.Errorf("file values not applied: %+v", cfg)
|
|
}
|
|
if cfg.Interval != 5*time.Minute {
|
|
t.Errorf("Interval = %v, want 5m", cfg.Interval)
|
|
}
|
|
if len(cfg.Exclude) != 2 || cfg.Exclude[0] != "node_modules" || cfg.Exclude[1] != "vendor" {
|
|
t.Errorf("Exclude = %v, want [node_modules vendor]", cfg.Exclude)
|
|
}
|
|
if cfg.ConfigFile != filepath.Join(home, ".gitflow.yaml") {
|
|
t.Errorf("ConfigFile = %q, want %q", cfg.ConfigFile, filepath.Join(home, ".gitflow.yaml"))
|
|
}
|
|
}
|
|
|
|
func TestConfigPathEnvOverride(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
alt := filepath.Join(home, "custom.yaml")
|
|
if err := os.WriteFile(alt, []byte("format: compact\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("GITFLOW_CONFIG", alt)
|
|
|
|
cfg, err := loadWithFlags(t, nil)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.Format != "compact" {
|
|
t.Errorf("Format = %q, want compact from GITFLOW_CONFIG file", cfg.Format)
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsBadFormat(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
|
|
if err := f.Set("format", "xml"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}); err == nil {
|
|
t.Error("Load(bad format) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsBadWorkers(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
|
|
if err := f.Set("workers", "0"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}); err == nil {
|
|
t.Error("Load(workers=0) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
func TestConfigRules(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
content := "rules:\n - name: stale\n field: behind\n op: '>='\n value: 10\n label: stale\n - name: dirty\n field: changes\n op: '>='\n value: 1\n label: dirty\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.Rules) != 2 {
|
|
t.Fatalf("Rules = %+v, want 2 rules", cfg.Rules)
|
|
}
|
|
if cfg.Rules[0].Field != "behind" || cfg.Rules[0].Op != ">=" || cfg.Rules[0].Value != 10 || cfg.Rules[0].Label != "stale" {
|
|
t.Errorf("rule[0] = %+v", cfg.Rules[0])
|
|
}
|
|
}
|
|
|
|
func TestConfigRejectsInvalidRules(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
content := "rules:\n - name: bad\n field: nope\n op: '>='\n value: 1\n label: x\n"
|
|
if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := loadWithFlags(t, nil); err == nil {
|
|
t.Error("Load(invalid rule) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsBadTheme(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
|
|
if err := f.Set("theme", "neon"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}); err == nil {
|
|
t.Error("Load(bad theme) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsBadProvider(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
|
|
if err := f.Set("ai", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Set("ai-provider", "magic"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}); err == nil {
|
|
t.Error("Load(bad ai provider) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
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()
|
|
t.Setenv("HOME", home)
|
|
|
|
cfg, err := loadWithFlags(t, nil)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
out, err := cfg.Dump()
|
|
if err != nil {
|
|
t.Fatalf("Dump: %v", err)
|
|
}
|
|
for _, want := range []string{"dir:", "interval:", "format:", "workers:", "ai:", "provider:"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("Dump() missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|