diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go index 30f7c15..741f2ce 100644 --- a/internal/httpclient/client.go +++ b/internal/httpclient/client.go @@ -83,8 +83,10 @@ func (c *Client) PostJSON(ctx context.Context, url string, headers map[string]st if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("httpclient: %s: %s", resp.Status, strings.TrimSpace(string(data))) } - if err := json.Unmarshal(data, out); err != nil { - return fmt.Errorf("httpclient: decode response: %w", err) + if out != nil { + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("httpclient: decode response: %w", err) + } } return nil } diff --git a/internal/webhook/discord.go b/internal/webhook/discord.go new file mode 100644 index 0000000..59e69a8 --- /dev/null +++ b/internal/webhook/discord.go @@ -0,0 +1,85 @@ +package webhook + +import ( + "context" + "fmt" + "strings" + "time" + + "gitea.oblak.solutions/dimitar/gitFlow/internal/httpclient" +) + +// DiscordSender posts to a Discord webhook URL using a single rich embed. +type DiscordSender struct { + cfg Config + http *httpclient.Client +} + +func newDiscord(cfg Config) *DiscordSender { + return &DiscordSender{cfg: cfg, http: httpclient.New(httpclient.WithTimeout(cfg.TimeoutOrDefault()))} +} + +func (s *DiscordSender) Name() string { return s.cfg.Name } + +type discordMessage struct { + Embeds []discordEmbed `json:"embeds"` +} + +type discordEmbed struct { + Title string `json:"title"` + Description string `json:"description"` + Color int `json:"color"` + Footer *discordFooter `json:"footer,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +type discordFooter struct { + Text string `json:"text"` +} + +// Status-to-color mapping: green=clean, yellow=attention, red=errors. +func embedColor(s Payload) int { + switch { + case s.Summary.Errored > 0: + return 15158332 // red + case s.Summary.Attention > 0: + return 16776960 // yellow + default: + return 3066993 // green + } +} + +func (s *DiscordSender) Send(ctx context.Context, payload Payload) error { + msg := discordMessage{ + Embeds: []discordEmbed{{ + Title: "gitflow scan", + Description: discordBody(payload), + Color: embedColor(payload), + Footer: &discordFooter{Text: "gitflow"}, + Timestamp: payload.Timestamp.Format(time.RFC3339), + }}, + } + return s.http.PostJSON(ctx, s.cfg.URL, nil, msg, nil) +} + +func discordBody(p Payload) string { + var b strings.Builder + s := p.Summary + fmt.Fprintf(&b, "**%s** — %d repos: %d clean, %d attention, %d errors\n\n", + p.ParentDir, s.Total, s.Clean, s.Attention, s.Errored) + if len(p.Changed) > 0 { + b.WriteString("**Changed:**\n") + for _, r := range p.Changed { + sym := statusSymbol(r.Status) + line := fmt.Sprintf("%s **%s** — %s (%s)", sym, r.Name, r.Status, r.Branch) + if r.AheadBy > 0 || r.BehindBy > 0 { + line += fmt.Sprintf(" +%d/-%d", r.AheadBy, r.BehindBy) + } + if n := r.FileCount(); n > 0 { + line += fmt.Sprintf(" %d file(s)", n) + } + b.WriteString(line + "\n") + } + } + return b.String() +} diff --git a/internal/webhook/generic.go b/internal/webhook/generic.go new file mode 100644 index 0000000..1d46a7f --- /dev/null +++ b/internal/webhook/generic.go @@ -0,0 +1,23 @@ +package webhook + +import ( + "context" + + "gitea.oblak.solutions/dimitar/gitFlow/internal/httpclient" +) + +// GenericSender posts the Payload as JSON to a user-defined URL. +type GenericSender struct { + cfg Config + http *httpclient.Client +} + +func newGeneric(cfg Config) *GenericSender { + return &GenericSender{cfg: cfg, http: httpclient.New(httpclient.WithTimeout(cfg.TimeoutOrDefault()))} +} + +func (s *GenericSender) Name() string { return s.cfg.Name } + +func (s *GenericSender) Send(ctx context.Context, payload Payload) error { + return s.http.PostJSON(ctx, s.cfg.URL, s.cfg.Headers, payload, nil) +} diff --git a/internal/webhook/slack.go b/internal/webhook/slack.go new file mode 100644 index 0000000..1a99394 --- /dev/null +++ b/internal/webhook/slack.go @@ -0,0 +1,84 @@ +package webhook + +import ( + "context" + "fmt" + "strings" + "time" + + "gitea.oblak.solutions/dimitar/gitFlow/internal/httpclient" +) + +// SlackSender posts to a Slack incoming webhook using Block Kit (mrkdwn). +type SlackSender struct { + cfg Config + http *httpclient.Client +} + +func newSlack(cfg Config) *SlackSender { + return &SlackSender{cfg: cfg, http: httpclient.New(httpclient.WithTimeout(cfg.TimeoutOrDefault()))} +} + +func (s *SlackSender) Name() string { return s.cfg.Name } + +type slackMessage struct { + Blocks []slackBlock `json:"blocks"` +} + +type slackBlock struct { + Type string `json:"type"` + Text *slackText `json:"text,omitempty"` +} + +type slackText struct { + Type string `json:"type"` // "mrkdwn" or "plain_text" + Text string `json:"text"` +} + +func (s *SlackSender) Send(ctx context.Context, payload Payload) error { + msg := slackMessage{ + Blocks: []slackBlock{ + {Type: "header", Text: &slackText{Type: "plain_text", Text: "gitflow: scan results"}}, + {Type: "section", Text: &slackText{Type: "mrkdwn", Text: slackSummary(payload)}}, + }, + } + if len(payload.Changed) > 0 { + msg.Blocks = append(msg.Blocks, + slackBlock{Type: "divider"}, + slackBlock{Type: "section", Text: &slackText{Type: "mrkdwn", Text: slackChanges(payload)}}, + ) + } + msg.Blocks = append(msg.Blocks, + slackBlock{Type: "context", Text: &slackText{Type: "mrkdwn", Text: slackFooter(payload)}}, + ) + + return s.http.PostJSON(ctx, s.cfg.URL, nil, msg, nil) +} + +func slackSummary(p Payload) string { + s := p.Summary + return fmt.Sprintf("Scanned *%s* — %d repos: %d clean, %d need attention, %d errors", + p.ParentDir, s.Total, s.Clean, s.Attention, s.Errored) +} + +func slackChanges(p Payload) string { + var b strings.Builder + b.WriteString("*Changed repositories:*\n") + for _, r := range p.Changed { + sym := statusSymbol(r.Status) + line := fmt.Sprintf("• %s `%s` — %s _(%s)_", + sym, r.Name, r.Status, r.Branch) + if r.AheadBy > 0 || r.BehindBy > 0 { + line += fmt.Sprintf(" +%d/-%d", r.AheadBy, r.BehindBy) + } + if n := r.FileCount(); n > 0 { + line += fmt.Sprintf(" %d change(s)", n) + } + b.WriteString(line + "\n") + } + return b.String() +} + +func slackFooter(p Payload) string { + return fmt.Sprintf("_%s | gitflow_", p.Timestamp.Format(time.RFC3339)) +} diff --git a/internal/webhook/util.go b/internal/webhook/util.go new file mode 100644 index 0000000..7cc14ab --- /dev/null +++ b/internal/webhook/util.go @@ -0,0 +1,30 @@ +package webhook + +import ( + "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" +) + +// statusSymbol maps RepoStatus to an emoji marker suitable for webhook +// messages (Slack and Discord rich text). +func statusSymbol(s status.RepoStatus) string { + switch s { + case status.StatusClean: + return "✅" + case status.StatusModified: + return "✏️" + case status.StatusAhead: + return "⬆️" + case status.StatusBehind: + return "⬇️" + case status.StatusDiverged: + return "🔀" + case status.StatusDetached: + return "📌" + case status.StatusBare: + return "📦" + case status.StatusError: + return "❌" + default: + return "❓" + } +} diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go new file mode 100644 index 0000000..1661d5d --- /dev/null +++ b/internal/webhook/webhook.go @@ -0,0 +1,183 @@ +// Package webhook sends scan results to external services (Slack, Discord, +// generic HTTP) when repositories change during a watch session. +package webhook + +import ( + "context" + "fmt" + "log" + "strings" + "sync" + "time" + + "gitea.oblak.solutions/dimitar/gitFlow/internal/httpclient" + "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" +) + +// Payload is the structured data sent to every webhook. +type Payload struct { + Timestamp time.Time `json:"timestamp"` + ParentDir string `json:"parent_dir"` + Summary status.Summary `json:"summary"` + Changed []status.RepoInfo `json:"changed"` + All []status.RepoInfo `json:"all,omitempty"` +} + +// Sender is the interface every webhook provider implements. +type Sender interface { + Name() string + Send(ctx context.Context, payload Payload) error +} + +// 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"` +} + +// RetryConfig controls retry behaviour per webhook. +type RetryConfig struct { + MaxAttempts int `yaml:"max_attempts"` + Backoff time.Duration `yaml:"backoff"` +} + +// NewSender builds a Sender from a configuration. +func NewSender(cfg Config) (Sender, error) { + switch cfg.Type { + case "slack": + return newSlack(cfg), nil + case "discord": + return newDiscord(cfg), nil + case "generic": + return newGeneric(cfg), nil + default: + return nil, fmt.Errorf("webhook: unknown type %q (want slack, discord, or generic)", cfg.Type) + } +} + +// Dispatcher fans a payload out to every sender, respecting per-sender +// rate limits and retry policies. It is safe for concurrent use. +type Dispatcher struct { + senders []senderHandle +} + +type senderHandle struct { + sender Sender + cfg Config + http *httpclient.Client + lastSent time.Time + mu sync.Mutex +} + +// NewDispatcher builds a Dispatcher from a list of webhook configs. Senders +// whose creation fails are logged and skipped (a single bad webhook should +// not prevent others from firing). +func NewDispatcher(cfgs []Config) *Dispatcher { + d := &Dispatcher{} + for _, cfg := range cfgs { + s, err := NewSender(cfg) + if err != nil { + log.Printf("webhook: %s: %v", cfg.Name, err) + continue + } + d.senders = append(d.senders, senderHandle{ + sender: s, + cfg: cfg, + http: httpclient.New(httpclient.WithTimeout(cfg.TimeoutOrDefault())), + }) + } + return d +} + +// Dispatch sends the payload to every sender whose filters match. Errors +// from individual senders are collected and returned; a failing sender +// never prevents others from firing. +func (d *Dispatcher) Dispatch(ctx context.Context, payload Payload, changedCount int) []error { + var errs []error + for i := range d.senders { + h := &d.senders[i] + if h.cfg.OnChangeOnly && changedCount == 0 { + continue + } + if !h.cfg.allowNow() { + continue + } + h.mu.Lock() + h.lastSent = time.Now() + h.mu.Unlock() + + if err := h.sendWithRetry(ctx, payload); err != nil { + errs = append(errs, fmt.Errorf("webhook %s: %w", h.cfg.Name, err)) + } + } + return errs +} + +// allowNow checks whether the rate limit has elapsed since lastSend. +func (c Config) allowNow() bool { + return c.RateLimit <= 0 +} + +// allowNow checks the sender-level rate limit. +func (h *senderHandle) allowNow() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.cfg.RateLimit <= 0 || time.Since(h.lastSent) >= h.cfg.RateLimit +} + +// sendWithRetry attempts to send with exponential backoff for transient +// failures. Non-retryable errors (4xx client errors) fail immediately. +func (h *senderHandle) sendWithRetry(ctx context.Context, payload Payload) error { + maxAttempts := h.cfg.Retry.MaxAttempts + if maxAttempts < 1 { + maxAttempts = 1 + } + backoff := h.cfg.Retry.Backoff + if backoff <= 0 { + backoff = 1 * time.Second + } + + var lastErr error + for attempt := 1; attempt <= maxAttempts; attempt++ { + lastErr = h.sender.Send(ctx, payload) + if lastErr == nil { + return nil + } + if !isRetryable(lastErr) { + return lastErr + } + if attempt < maxAttempts { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff * time.Duration(1<<(attempt-1))): + } + } + } + return fmt.Errorf("%d attempts: %w", maxAttempts, lastErr) +} + +func isRetryable(err error) bool { + // 4xx = client error (non-retryable); 5xx/timeout/DNS = retryable. + if strings.Contains(err.Error(), "400") || strings.Contains(err.Error(), "401") || + strings.Contains(err.Error(), "403") || strings.Contains(err.Error(), "404") { + return false + } + return true +} + +// TimeoutOrDefault returns the configured timeout or 10s when unset. +func (c Config) TimeoutOrDefault() time.Duration { + if c.Timeout > 0 { + return c.Timeout + } + return 10 * time.Second +} diff --git a/internal/webhook/webhook_test.go b/internal/webhook/webhook_test.go new file mode 100644 index 0000000..4e89e81 --- /dev/null +++ b/internal/webhook/webhook_test.go @@ -0,0 +1,260 @@ +package webhook + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" +) + +func samplePayload() Payload { + return Payload{ + Timestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + ParentDir: "/home/user/projects", + Summary: status.Summary{Total: 3, Clean: 1, Attention: 2, Errored: 0}, + Changed: []status.RepoInfo{ + {Name: "api", Branch: "main", Status: status.StatusBehind, BehindBy: 5}, + {Name: "web", Branch: "feat/login", Status: status.StatusModified, ModifiedFiles: []string{"a.go"}, AheadBy: 3}, + }, + } +} + +func TestStatusSymbol(t *testing.T) { + cases := []struct { + s status.RepoStatus + want string + }{ + {status.StatusClean, "✅"}, + {status.StatusModified, "✏️"}, + {status.StatusAhead, "⬆️"}, + {status.StatusBehind, "⬇️"}, + {status.StatusDiverged, "🔀"}, + {status.StatusDetached, "📌"}, + {status.StatusBare, "📦"}, + {status.StatusError, "❌"}, + } + for _, tc := range cases { + if got := statusSymbol(tc.s); got != tc.want { + t.Errorf("statusSymbol(%v) = %q, want %q", tc.s, got, tc.want) + } + } +} + +func TestNewSender(t *testing.T) { + for _, typ := range []string{"slack", "discord", "generic"} { + s, err := NewSender(Config{Name: "test", Type: typ, URL: "http://localhost"}) + if err != nil { + t.Errorf("NewSender(%q): %v", typ, err) + } + if s.Name() != "test" { + t.Errorf("NewSender(%q).Name() = %q, want test", typ, s.Name()) + } + } + if _, err := NewSender(Config{Name: "bad", Type: "nope"}); err == nil { + t.Error("NewSender(nope) succeeded, want error") + } +} + +func TestSlackSender(t *testing.T) { + var got slackMessage + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte("{}")) + })) + defer srv.Close() + + s := newSlack(Config{Name: "test", URL: srv.URL}) + if err := s.Send(t.Context(), samplePayload()); err != nil { + t.Fatalf("Send: %v", err) + } + if len(got.Blocks) < 3 { + t.Fatalf("got %d blocks, want >= 3", len(got.Blocks)) + } + body := got.Blocks[1].Text.Text + if !strings.Contains(body, "3 repos") || !strings.Contains(body, "1 clean") { + t.Errorf("summary = %q", body) + } +} + +func TestDiscordSender(t *testing.T) { + var got discordMessage + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte("{}")) + })) + defer srv.Close() + + s := newDiscord(Config{Name: "test", URL: srv.URL}) + if err := s.Send(t.Context(), samplePayload()); err != nil { + t.Fatalf("Send: %v", err) + } + if len(got.Embeds) != 1 { + t.Fatalf("got %d embeds, want 1", len(got.Embeds)) + } + emb := got.Embeds[0] + if emb.Title != "gitflow scan" { + t.Errorf("title = %q", emb.Title) + } + if !strings.Contains(emb.Description, "3 repos") { + t.Errorf("description = %q", emb.Description) + } + if emb.Color != 16776960 { + t.Errorf("color = %d, want 16776960 (yellow/attention)", emb.Color) + } +} + +func TestDiscordErrorColor(t *testing.T) { + p := samplePayload() + p.Summary.Errored = 1 + var got discordMessage + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&got) + _, _ = w.Write([]byte("{}")) + })) + defer srv.Close() + + s := newDiscord(Config{Name: "test", URL: srv.URL}) + _ = s.Send(t.Context(), p) + if got.Embeds[0].Color != 15158332 { + t.Errorf("color = %d, want 15158332 (red/errors)", got.Embeds[0].Color) + } +} + +func TestGenericSender(t *testing.T) { + var got Payload + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + s := newGeneric(Config{Name: "test", URL: srv.URL}) + if err := s.Send(t.Context(), samplePayload()); err != nil { + t.Fatalf("Send: %v", err) + } + if got.Summary.Total != 3 { + t.Errorf("payload.Summary.Total = %d, want 3", got.Summary.Total) + } +} + +func TestDispatcherOnChangeOnly(t *testing.T) { + var mu sync.Mutex + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var p Payload + json.NewDecoder(r.Body).Decode(&p) + mu.Lock() + for _, r := range p.Changed { + paths = append(paths, r.Name) + } + mu.Unlock() + _, _ = w.Write([]byte("{}")) + })) + defer srv.Close() + + d := NewDispatcher([]Config{{ + Name: "test", Type: "generic", URL: srv.URL, OnChangeOnly: true, + }}) + payload := samplePayload() + // No changes → skipped. + errs := d.Dispatch(context.Background(), payload, 0) + if len(errs) > 0 { + t.Fatalf("Dispatch: %v", errs) + } + mu.Lock() + noChangeCalls := len(paths) + mu.Unlock() + if noChangeCalls != 0 { + t.Errorf("dispatch with changedCount=0 sent %d requests, want 0", noChangeCalls) + } +} + +func TestHTTPErrorSurfaced(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + s := newGeneric(Config{Name: "test", URL: srv.URL, Retry: RetryConfig{MaxAttempts: 1}}) + if err := s.Send(t.Context(), samplePayload()); err == nil { + t.Error("Send(500) succeeded, want error") + } +} + +func TestRetryOnTransientErrors(t *testing.T) { + var mu sync.Mutex + var attempts int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + attempts++ + n := attempts + mu.Unlock() + if n < 3 { + http.Error(w, "gateway timeout", http.StatusGatewayTimeout) + return + } + _, _ = w.Write([]byte("{}")) + })) + defer srv.Close() + + d := NewDispatcher([]Config{{ + Name: "test", + Type: "generic", + URL: srv.URL, + Retry: RetryConfig{MaxAttempts: 3, Backoff: 1 * time.Millisecond}, + }}) + errs := d.Dispatch(context.Background(), samplePayload(), 1) + if len(errs) > 0 { + t.Fatalf("Dispatch: %v", errs) + } + mu.Lock() + if attempts != 3 { + t.Errorf("attempts = %d, want 3", attempts) + } + mu.Unlock() +} + +func TestDispatcherMultipleSenders(t *testing.T) { + var mu sync.Mutex + var s1, s2 bool + srv1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + s1 = true + mu.Unlock() + _, _ = w.Write([]byte("{}")) + })) + defer srv1.Close() + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + s2 = true + mu.Unlock() + _, _ = w.Write([]byte("{}")) + })) + defer srv2.Close() + + d := NewDispatcher([]Config{ + {Name: "w1", Type: "generic", URL: srv1.URL}, + {Name: "w2", Type: "generic", URL: srv2.URL}, + }) + errs := d.Dispatch(context.Background(), samplePayload(), 1) + if len(errs) > 0 { + t.Fatalf("Dispatch: %v", errs) + } + mu.Lock() + if !s1 || !s2 { + t.Errorf("s1=%v s2=%v, want both true", s1, s2) + } + mu.Unlock() +}