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.
93 lines
2.5 KiB
Go
93 lines
2.5 KiB
Go
// Package httpclient provides a small JSON HTTP client shared by the AI
|
|
// providers and webhook senders. It bounds response sizes and applies a
|
|
// configurable timeout so a slow upstream cannot hang a scan or watch frame.
|
|
package httpclient
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// DefaultTimeout is the per-request timeout when none is configured.
|
|
const DefaultTimeout = 60 * time.Second
|
|
|
|
// maxResponseBytes caps the body size read from an upstream. AI and webhook
|
|
// responses are expected to be well under 1 MiB; 4 MiB is a safety valve.
|
|
const maxResponseBytes = 4 << 20
|
|
|
|
// Client is a JSON HTTP client with a bounded response size and a timeout.
|
|
type Client struct {
|
|
httpc *http.Client
|
|
}
|
|
|
|
// Option configures a Client (functional options pattern).
|
|
type Option func(*Client)
|
|
|
|
// WithTimeout overrides the per-request timeout.
|
|
func WithTimeout(d time.Duration) Option {
|
|
return func(c *Client) {
|
|
c.httpc.Timeout = d
|
|
}
|
|
}
|
|
|
|
// WithTransport allows injecting a custom http.RoundTripper (useful for
|
|
// testing with httptest or for TLS/mTLS configuration).
|
|
func WithTransport(rt http.RoundTripper) Option {
|
|
return func(c *Client) {
|
|
c.httpc.Transport = rt
|
|
}
|
|
}
|
|
|
|
// New builds a Client with sensible defaults.
|
|
func New(opts ...Option) *Client {
|
|
c := &Client{httpc: &http.Client{Timeout: DefaultTimeout}}
|
|
for _, opt := range opts {
|
|
opt(c)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// PostJSON sends a JSON payload to url with the given headers and decodes the
|
|
// response body into out. Non-2xx status codes are errors.
|
|
func (c *Client) PostJSON(ctx context.Context, url string, headers map[string]string, payload, out any) error {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("httpclient: encode request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("httpclient: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
for k, v := range headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
resp, err := c.httpc.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("httpclient: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
|
if err != nil {
|
|
return fmt.Errorf("httpclient: read response: %w", err)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("httpclient: %s: %s", resp.Status, strings.TrimSpace(string(data)))
|
|
}
|
|
if out != nil {
|
|
if err := json.Unmarshal(data, out); err != nil {
|
|
return fmt.Errorf("httpclient: decode response: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|