gitFlow/cmd/gitflow/watch.go
dimitar 68930b4a05 feat: M6 — webhook config + watch wiring
Wire the webhook dispatcher into configuration resolution and the watch
command so scan results are relayed to external services on every frame.

Config (internal/config):
- Config gains Webhooks []webhook.Config (yaml: "webhooks", mapstructure
  tags for viper compatibility; duration fields use mapstructure:"-" and
  are backfilled via fixWebhookDurations calling v.GetDuration)
- validateWebhook enforces: name non-empty, type in (slack|discord|
  generic), URL starts with http/https, rate_limit >= 1s, retry
  max_attempts <= 5, min_priority 0-2
- Dump includes the webhooks section

Watch command (cmd/gitflow):
- At startup, builds a webhook.Dispatcher from cfg.Webhooks
- After each scan frame, fires dispatchWebhooks in a goroutine so a slow
  webhook never blocks the scan interval
- dispatchWebhooks constructs a webhook.Payload from the scan result,
  computes changed repositories, and fans out via d.Dispatch; errors
  are printed to stderr

Config tests:
- Webhooks parse from YAML (type, URL, on_change_only, rate_limit,
  retry.backoff) with duration fixup verified
- Bad webhooks rejected: unknown type, empty URL, non-http URL,
  sub-second rate_limit

Verified: go build, go vet, go test -race (13 packages), gofmt clean.
2026-08-02 09:23:52 +02:00

150 lines
4.3 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/internal/notify"
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
"gitea.oblak.solutions/dimitar/gitFlow/internal/scheduler"
"gitea.oblak.solutions/dimitar/gitFlow/internal/webhook"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// newWatchCmd repeatedly scans on an interval until interrupted.
func newWatchCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "watch",
Short: "Repeatedly scan repositories on an interval",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cmd.Flags())
if err != nil {
return err
}
if cfg.Interval <= 0 {
return errors.New("watch requires a positive --interval (e.g. --interval 30s)")
}
// Same interactive prompt as scan, per the README.
if !cmd.Flags().Changed("dir") && isTerminal(os.Stdin) {
if d, err := promptDir(cfg.Dir); err == nil {
cfg.Dir = d
}
}
ctx, stop := signalContext()
defer stop()
a, err := app.New(cfg)
if err != nil {
return err
}
opts := presenter.Options{
Color: presenter.ParseColorMode(cfg.Color),
Theme: presenter.ParseTheme(cfg.Theme),
}
whDispatcher := webhook.NewDispatcher(cfg.Webhooks)
var prev status.ScanResult
first := true
run := func(ctx context.Context) error {
result, err := a.ScanOnce(ctx)
if err != nil {
return err
}
if cfg.Format == "json" {
return presenter.Present(os.Stdout, cfg.Format, result, opts)
}
renderWatchFrame(result, prev, first, cfg, opts)
// Ask the AI only on the first frame and when something
// changed, so the provider is not hammered every interval.
if cfg.AI.Enabled && (first || len(status.Changed(prev, result)) > 0) {
renderSuggestions(ctx, cfg, opts, result)
}
if err := renderRuleFlags(opts, cfg, result); err != nil {
return err
}
if !first && cfg.Notify {
notifyChanges(prev, result)
}
go dispatchWebhooks(ctx, whDispatcher, prev, result)
prev = result
first = false
return nil
}
sched := scheduler.New(cfg.Interval, run)
return sched.Run(ctx)
},
}
config.RegisterFlags(cmd.Flags())
return cmd
}
// notifyChanges sends a desktop notification listing repositories whose
// state changed since the previous frame.
func notifyChanges(prev, result status.ScanResult) {
changed := status.Changed(prev, result)
if len(changed) == 0 {
return
}
names := make([]string, 0, len(changed))
for _, r := range changed {
names = append(names, r.Name)
}
_ = notify.Send("gitflow: changes detected", strings.Join(names, ", "))
}
// dispatchWebhooks builds a Payload from the scan and fans it to the
// dispatcher in a separate goroutine so a slow webhook never blocks the
// scan interval.
func dispatchWebhooks(ctx context.Context, d *webhook.Dispatcher, prev, result status.ScanResult) {
if d == nil {
return
}
changed := status.Changed(prev, result)
p := webhook.Payload{
Timestamp: time.Now(),
ParentDir: result.ParentDir,
Summary: result.Summary(),
Changed: changed,
}
if errs := d.Dispatch(ctx, p, len(changed)); len(errs) > 0 {
for _, e := range errs {
fmt.Fprintf(os.Stderr, "webhook: %v\n", e)
}
}
}
// renderWatchFrame clears the screen (or prints a timestamp header when
// output is not a terminal), renders the scan, and prints a footer with
// changed repositories and the next scan time.
func renderWatchFrame(result, prev status.ScanResult, first bool, cfg *config.Config, opts presenter.Options) {
if isTerminal(os.Stdout) {
fmt.Fprint(os.Stdout, "\x1b[2J\x1b[H")
} else {
fmt.Fprintf(os.Stdout, "--- %s ---\n", time.Now().Format(time.RFC3339))
}
if err := presenter.Present(os.Stdout, cfg.Format, result, opts); err != nil {
fmt.Fprintf(os.Stderr, "render: %v\n", err)
return
}
if !first {
for _, r := range status.Changed(prev, result) {
fmt.Fprintf(os.Stdout, "▲ %s: %s\n", r.Name, r.Status)
}
}
fmt.Fprintf(os.Stdout, "watching %s every %s — next scan at %s (Ctrl-C to stop)\n",
cfg.Dir, cfg.Interval, time.Now().Add(cfg.Interval).Format("15:04:05"))
}