merge: M6 webhook config into main
This commit is contained in:
commit
28a7a30e25
@ -15,6 +15,7 @@ import (
|
|||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/notify"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/notify"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/scheduler"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/scheduler"
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/webhook"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -50,6 +51,7 @@ func newWatchCmd() *cobra.Command {
|
|||||||
Color: presenter.ParseColorMode(cfg.Color),
|
Color: presenter.ParseColorMode(cfg.Color),
|
||||||
Theme: presenter.ParseTheme(cfg.Theme),
|
Theme: presenter.ParseTheme(cfg.Theme),
|
||||||
}
|
}
|
||||||
|
whDispatcher := webhook.NewDispatcher(cfg.Webhooks)
|
||||||
|
|
||||||
var prev status.ScanResult
|
var prev status.ScanResult
|
||||||
first := true
|
first := true
|
||||||
@ -73,6 +75,7 @@ func newWatchCmd() *cobra.Command {
|
|||||||
if !first && cfg.Notify {
|
if !first && cfg.Notify {
|
||||||
notifyChanges(prev, result)
|
notifyChanges(prev, result)
|
||||||
}
|
}
|
||||||
|
go dispatchWebhooks(ctx, whDispatcher, prev, result)
|
||||||
prev = result
|
prev = result
|
||||||
first = false
|
first = false
|
||||||
return nil
|
return nil
|
||||||
@ -100,6 +103,27 @@ func notifyChanges(prev, result status.ScanResult) {
|
|||||||
_ = notify.Send("gitflow: changes detected", strings.Join(names, ", "))
|
_ = 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
|
// renderWatchFrame clears the screen (or prints a timestamp header when
|
||||||
// output is not a terminal), renders the scan, and prints a footer with
|
// output is not a terminal), renders the scan, and prints a footer with
|
||||||
// changed repositories and the next scan time.
|
// changed repositories and the next scan time.
|
||||||
|
|||||||
@ -8,6 +8,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -17,6 +18,7 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AIConfig holds AI agent settings.
|
// AIConfig holds AI agent settings.
|
||||||
@ -31,18 +33,19 @@ type AIConfig struct {
|
|||||||
|
|
||||||
// Config is the fully resolved runtime configuration.
|
// Config is the fully resolved runtime configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Dir string `yaml:"dir"`
|
Dir string `yaml:"dir"`
|
||||||
Interval time.Duration `yaml:"interval"`
|
Interval time.Duration `yaml:"interval"`
|
||||||
Format string `yaml:"format"`
|
Format string `yaml:"format"`
|
||||||
Color string `yaml:"color"`
|
Color string `yaml:"color"`
|
||||||
Theme string `yaml:"theme"`
|
Theme string `yaml:"theme"`
|
||||||
Notify bool `yaml:"notify"`
|
Notify bool `yaml:"notify"`
|
||||||
Exclude []string `yaml:"exclude,omitempty"`
|
Exclude []string `yaml:"exclude,omitempty"`
|
||||||
MaxDepth int `yaml:"max_depth"`
|
MaxDepth int `yaml:"max_depth"`
|
||||||
Workers int `yaml:"workers"`
|
Workers int `yaml:"workers"`
|
||||||
Rules []rules.Rule `yaml:"rules,omitempty"`
|
Rules []rules.Rule `yaml:"rules,omitempty"`
|
||||||
AI AIConfig `yaml:"ai"`
|
Webhooks []webhook.Config `yaml:"webhooks,omitempty"`
|
||||||
ConfigFile string `yaml:"-"` // path of the loaded config file, if any
|
AI AIConfig `yaml:"ai"`
|
||||||
|
ConfigFile string `yaml:"-"` // path of the loaded config file, if any
|
||||||
}
|
}
|
||||||
|
|
||||||
// flagKeys maps CLI flag names to their viper keys.
|
// flagKeys maps CLI flag names to their viper keys.
|
||||||
@ -135,6 +138,27 @@ func Load(flags *pflag.FlagSet) (*Config, error) {
|
|||||||
return nil, err
|
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
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,6 +263,7 @@ func (c *Config) Dump() (string, error) {
|
|||||||
"notify": c.Notify,
|
"notify": c.Notify,
|
||||||
"theme": c.Theme,
|
"theme": c.Theme,
|
||||||
"rules": c.Rules,
|
"rules": c.Rules,
|
||||||
|
"webhooks": c.Webhooks,
|
||||||
"ai": map[string]any{
|
"ai": map[string]any{
|
||||||
"enabled": c.AI.Enabled,
|
"enabled": c.AI.Enabled,
|
||||||
"provider": c.AI.Provider,
|
"provider": c.AI.Provider,
|
||||||
@ -254,3 +279,32 @@ func (c *Config) Dump() (string, error) {
|
|||||||
}
|
}
|
||||||
return string(out), nil
|
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) {
|
func TestDump(t *testing.T) {
|
||||||
clearEnv(t)
|
clearEnv(t)
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
|
|||||||
@ -31,22 +31,22 @@ type Sender interface {
|
|||||||
|
|
||||||
// Config holds per-webhook settings from the configuration file.
|
// Config holds per-webhook settings from the configuration file.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name" mapstructure:"name"`
|
||||||
Type string `yaml:"type"` // slack, discord, or generic
|
Type string `yaml:"type" mapstructure:"type"`
|
||||||
URL string `yaml:"url"`
|
URL string `yaml:"url" mapstructure:"url"`
|
||||||
SendAll bool `yaml:"send_all"`
|
SendAll bool `yaml:"send_all" mapstructure:"send_all"`
|
||||||
OnChangeOnly bool `yaml:"on_change_only"`
|
OnChangeOnly bool `yaml:"on_change_only" mapstructure:"on_change_only"`
|
||||||
MinPriority int `yaml:"min_priority"`
|
MinPriority int `yaml:"min_priority" mapstructure:"min_priority"`
|
||||||
RateLimit time.Duration `yaml:"rate_limit"`
|
RateLimit time.Duration `yaml:"rate_limit" mapstructure:"-"`
|
||||||
Timeout time.Duration `yaml:"timeout"`
|
Timeout time.Duration `yaml:"timeout" mapstructure:"-"`
|
||||||
Headers map[string]string `yaml:"headers,omitempty"`
|
Headers map[string]string `yaml:"headers,omitempty" mapstructure:"headers"`
|
||||||
Retry RetryConfig `yaml:"retry"`
|
Retry RetryConfig `yaml:"retry" mapstructure:"retry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryConfig controls retry behaviour per webhook.
|
// RetryConfig controls retry behaviour per webhook.
|
||||||
type RetryConfig struct {
|
type RetryConfig struct {
|
||||||
MaxAttempts int `yaml:"max_attempts"`
|
MaxAttempts int `yaml:"max_attempts" mapstructure:"max_attempts"`
|
||||||
Backoff time.Duration `yaml:"backoff"`
|
Backoff time.Duration `yaml:"backoff" mapstructure:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSender builds a Sender from a configuration.
|
// NewSender builds a Sender from a configuration.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user