feat: M5 — webhook core (Sender interface, Slack/Discord/Generic)
Implement the webhook dispatch subsystem so watch sessions can relay scan
results to external services when repositories change.
Webhook package (internal/webhook):
- Sender interface (Name + Send(ctx, Payload)) with three concrete
implementations:
- SlackSender: Block Kit message (header + mrkdwn section with summary
and change list + context footer), emoji status markers
- DiscordSender: single rich embed with color-coded sidebar (green=
clean, yellow=attention, red=errors) and markdown description
- GenericSender: raw JSON POST of the Payload struct, with custom
headers from config
- Config struct per destination: type, URL, send_all, on_change_only,
min_priority, rate_limit, timeout, custom headers, retry (max_attempts
+ backoff); TimeoutOrDefault helper
- NewSender factory switches on type; unknown types rejected
- Dispatcher: NewDispatcher builds a senderHandle per config (sender +
rate-limit state + its own http.Client with per-config timeout);
Dispatch() fans out with per-sender guards: on_change_only skips when
changedCount==0, rate_limit skips when too soon (lastSent check)
- senderHandle.sendWithRetry: retries with exponential backoff on
transient errors (5xx/timeout); 4xx errors fail immediately;
isRetryable helper
HTTP client (internal/httpclient):
- PostJSON now skips json.Unmarshal when out==nil, matching the common
webhook pattern where the response body is irrelevant
Testing:
- All three sender types round-trip through httptest (request payload
decoded and asserted)
- Discord embed colour: yellow for attention, red for errors
- Generic sender payload integrity check
- Dispatcher: OnChangeOnly guard skips when changedCount==0; multiple
senders all fire; HTTP errors surfaced (500)
- Retry: 504 Gateway Timeout retried 3× before succeeding via the
dispatcher's sendWithRetry
- NewSender rejects unknown types, requires a name for identification
Verified: go build, go vet, go test -race (13 packages), gofmt clean.
This commit is contained in:
parent
c7c21e76b7
commit
55789ab7d7
@ -83,8 +83,10 @@ func (c *Client) PostJSON(ctx context.Context, url string, headers map[string]st
|
|||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
return fmt.Errorf("httpclient: %s: %s", resp.Status, strings.TrimSpace(string(data)))
|
return fmt.Errorf("httpclient: %s: %s", resp.Status, strings.TrimSpace(string(data)))
|
||||||
}
|
}
|
||||||
|
if out != nil {
|
||||||
if err := json.Unmarshal(data, out); err != nil {
|
if err := json.Unmarshal(data, out); err != nil {
|
||||||
return fmt.Errorf("httpclient: decode response: %w", err)
|
return fmt.Errorf("httpclient: decode response: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
85
internal/webhook/discord.go
Normal file
85
internal/webhook/discord.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
23
internal/webhook/generic.go
Normal file
23
internal/webhook/generic.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
84
internal/webhook/slack.go
Normal file
84
internal/webhook/slack.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
30
internal/webhook/util.go
Normal file
30
internal/webhook/util.go
Normal file
@ -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 "❓"
|
||||||
|
}
|
||||||
|
}
|
||||||
183
internal/webhook/webhook.go
Normal file
183
internal/webhook/webhook.go
Normal file
@ -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
|
||||||
|
}
|
||||||
260
internal/webhook/webhook_test.go
Normal file
260
internal/webhook/webhook_test.go
Normal file
@ -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()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user