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.
261 lines
6.8 KiB
Go
261 lines
6.8 KiB
Go
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()
|
|
}
|