gitFlow/internal/config/config.go
dimitar 68930b4a05 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.
2026-08-02 09:23:52 +02:00

311 lines
9.6 KiB
Go

// Package config resolves and validates gitflow settings from CLI flags,
// environment variables, and an optional YAML configuration file.
//
// Precedence (highest first): flags, environment variables, config file,
// built-in defaults.
package config
import (
"errors"
"fmt"
"net/url"
"os"
"strings"
"time"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
"gitea.oblak.solutions/dimitar/gitFlow/internal/webhook"
)
// AIConfig holds AI agent settings.
type AIConfig struct {
Enabled bool `yaml:"enabled"`
Provider string `yaml:"provider"` // openai, ollama, or anthropic
Model string `yaml:"model"` // model name; empty lets the provider choose
APIKeyEnv string `yaml:"api_key_env"` // env var holding the API key
BaseURL string `yaml:"base_url"` // provider endpoint override
Execute bool `yaml:"execute"` // run confirmed AI-suggested commands (experimental)
}
// 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"`
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.
var flagKeys = []struct{ flag, key string }{
{"dir", "dir"},
{"interval", "interval"},
{"format", "format"},
{"color", "color"},
{"exclude", "exclude"},
{"max-depth", "max_depth"},
{"workers", "workers"},
{"notify", "notify"},
{"theme", "theme"},
{"ai", "ai.enabled"},
{"ai-provider", "ai.provider"},
{"ai-model", "ai.model"},
{"ai-execute", "ai.execute"},
}
// RegisterFlags defines every gitflow flag on f. Call Load with the same
// FlagSet to resolve the effective configuration.
func RegisterFlags(f *pflag.FlagSet) {
f.StringP("dir", "d", ".", "parent directory to scan")
f.DurationP("interval", "i", 0, "rescan interval (e.g. 30s, 5m); 0 runs once")
f.StringP("format", "f", "table", "output format: table, json, or compact")
f.String("color", "auto", "color output: auto, always, or never")
f.StringSlice("exclude", nil, "glob patterns of directories to skip (repeatable)")
f.Int("max-depth", 0, "maximum directory depth to scan (0 = unlimited)")
f.Int("workers", 8, "number of concurrent git scans")
f.Bool("notify", false, "send desktop notifications when watch sees changes")
f.String("theme", "dark", "color theme: dark or light")
f.Bool("ai", false, "enable AI suggestions")
f.String("ai-provider", "openai", "AI provider: openai, ollama, or anthropic")
f.String("ai-model", "gpt-4o", "AI model name")
f.Bool("ai-execute", false, "run confirmed AI-suggested commands (experimental)")
}
// NewFlagSet returns a FlagSet with every gitflow flag registered.
func NewFlagSet() *pflag.FlagSet {
fs := pflag.NewFlagSet("gitflow", pflag.ContinueOnError)
RegisterFlags(fs)
return fs
}
// Load resolves the effective configuration from flags, environment, and
// config file, then validates it.
func Load(flags *pflag.FlagSet) (*Config, error) {
v := viper.New()
applyDefaults(v)
v.SetEnvPrefix("GITFLOW")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if err := readConfigFile(v); err != nil {
return nil, err
}
bindFlags(v, flags)
cfg := &Config{
Dir: v.GetString("dir"),
Interval: v.GetDuration("interval"),
Format: v.GetString("format"),
Color: v.GetString("color"),
Theme: v.GetString("theme"),
Notify: v.GetBool("notify"),
Exclude: v.GetStringSlice("exclude"),
MaxDepth: v.GetInt("max_depth"),
Workers: v.GetInt("workers"),
ConfigFile: v.ConfigFileUsed(),
AI: AIConfig{
Enabled: v.GetBool("ai.enabled"),
Provider: v.GetString("ai.provider"),
Model: v.GetString("ai.model"),
APIKeyEnv: v.GetString("ai.api_key_env"),
BaseURL: v.GetString("ai.base_url"),
Execute: v.GetBool("ai.execute"),
},
}
if err := cfg.Validate(); err != nil {
return nil, err
}
// Rules are not exposed as flags; read them from the file/env only.
var rulesList []rules.Rule
if err := v.UnmarshalKey("rules", &rulesList); err != nil {
return nil, fmt.Errorf("config: rules: %w", err)
}
cfg.Rules = rulesList
for _, r := range cfg.Rules {
if err := r.Validate(); err != nil {
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
}
func applyDefaults(v *viper.Viper) {
v.SetDefault("dir", ".")
v.SetDefault("interval", 0)
v.SetDefault("format", "table")
v.SetDefault("color", "auto")
v.SetDefault("exclude", []string{})
v.SetDefault("max_depth", 0)
v.SetDefault("workers", 8)
v.SetDefault("notify", false)
v.SetDefault("theme", "dark")
v.SetDefault("ai.enabled", false)
v.SetDefault("ai.provider", "openai")
v.SetDefault("ai.model", "gpt-4o")
v.SetDefault("ai.api_key_env", "OPENAI_API_KEY")
v.SetDefault("ai.base_url", "")
v.SetDefault("ai.execute", false)
}
// readConfigFile loads ~/.gitflow.yaml (or $GITFLOW_CONFIG when set). A
// missing config file is not an error; a malformed one is.
func readConfigFile(v *viper.Viper) error {
if path := os.Getenv("GITFLOW_CONFIG"); path != "" {
v.SetConfigFile(path)
} else {
v.SetConfigName(".gitflow")
v.SetConfigType("yaml")
v.AddConfigPath("$HOME")
v.AddConfigPath(".")
}
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if errors.As(err, &notFound) {
return nil
}
return fmt.Errorf("config: %w", err)
}
return nil
}
func bindFlags(v *viper.Viper, flags *pflag.FlagSet) {
for _, fk := range flagKeys {
if fl := flags.Lookup(fk.flag); fl != nil {
_ = v.BindPFlag(fk.key, fl)
}
}
}
// Validate rejects configuration that cannot be used.
func (c *Config) Validate() error {
if c.Dir == "" {
return errors.New("config: dir must not be empty")
}
switch c.Format {
case "table", "json", "compact":
default:
return fmt.Errorf("config: unsupported format %q (want table, json, or compact)", c.Format)
}
switch c.Color {
case "auto", "always", "never":
default:
return fmt.Errorf("config: unsupported color mode %q (want auto, always, or never)", c.Color)
}
switch c.Theme {
case "dark", "light":
default:
return fmt.Errorf("config: unsupported theme %q (want dark or light)", c.Theme)
}
if c.Interval < 0 {
return errors.New("config: interval must not be negative")
}
if c.MaxDepth < 0 {
return errors.New("config: max-depth must not be negative")
}
if c.Workers < 1 {
return errors.New("config: workers must be at least 1")
}
if c.AI.Enabled {
switch c.AI.Provider {
case "openai", "ollama", "anthropic":
default:
return fmt.Errorf("config: unsupported ai provider %q (want openai, ollama, or anthropic)", c.AI.Provider)
}
}
return nil
}
// Dump renders the effective configuration as human-readable YAML, with the
// interval shown as a duration string.
func (c *Config) Dump() (string, error) {
v := map[string]any{
"config_file": c.ConfigFile,
"dir": c.Dir,
"interval": c.Interval.String(),
"format": c.Format,
"color": c.Color,
"exclude": c.Exclude,
"max_depth": c.MaxDepth,
"workers": c.Workers,
"notify": c.Notify,
"theme": c.Theme,
"rules": c.Rules,
"webhooks": c.Webhooks,
"ai": map[string]any{
"enabled": c.AI.Enabled,
"provider": c.AI.Provider,
"model": c.AI.Model,
"api_key_env": c.AI.APIKeyEnv,
"base_url": c.AI.BaseURL,
"execute": c.AI.Execute,
},
}
out, err := yaml.Marshal(v)
if err != nil {
return "", err
}
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
}