gitFlow/internal/webhook/discord.go
dimitar 55789ab7d7 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.
2026-08-02 09:20:45 +02:00

86 lines
2.2 KiB
Go

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()
}