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.
85 lines
2.2 KiB
Go
85 lines
2.2 KiB
Go
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))
|
|
}
|