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:
parent
55117c5a8a
commit
68930b4a05
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user