diff --git a/internal/ai/anthropic.go b/internal/ai/anthropic.go index fc25d39..9a18146 100644 --- a/internal/ai/anthropic.go +++ b/internal/ai/anthropic.go @@ -5,6 +5,7 @@ import ( "fmt" "gitea.oblak.solutions/dimitar/gitFlow/internal/config" + "gitea.oblak.solutions/dimitar/gitFlow/internal/httpclient" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" ) @@ -18,7 +19,7 @@ type AnthropicProvider struct { model string baseURL string apiKey string - client *client + http *httpclient.Client } // NewAnthropic builds an Anthropic provider. base_url and model fall back @@ -32,7 +33,7 @@ func NewAnthropic(cfg config.AIConfig, apiKey string) *AnthropicProvider { if model == "" { model = "claude-3-5-haiku-latest" } - return &AnthropicProvider{model: model, baseURL: baseURL, apiKey: apiKey, client: newClient()} + return &AnthropicProvider{model: model, baseURL: baseURL, apiKey: apiKey, http: httpclient.New()} } // Name implements Provider. @@ -65,7 +66,7 @@ func (p *AnthropicProvider) Suggest(ctx context.Context, result status.ScanResul } var resp anthropicResponse - err := p.client.postJSON(ctx, p.baseURL+"/messages", + err := p.http.PostJSON(ctx, p.baseURL+"/messages", map[string]string{"x-api-key": p.apiKey, "anthropic-version": anthropicVersion}, req, &resp) if err != nil { return nil, err diff --git a/internal/ai/client.go b/internal/ai/client.go deleted file mode 100644 index 47635bc..0000000 --- a/internal/ai/client.go +++ /dev/null @@ -1,56 +0,0 @@ -package ai - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" -) - -// client is a small LLM HTTP client with a bounded response size and a -// timeout so a slow or chatty upstream cannot hang a scan. -type client struct { - httpc *http.Client -} - -func newClient() *client { - return &client{httpc: &http.Client{Timeout: 60 * time.Second}} -} - -// postJSON sends payload as JSON and decodes the response body into out. -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("ai: encode request: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("ai: %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("ai: %w", err) - } - defer resp.Body.Close() - - data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) // 4 MiB cap - if err != nil { - return fmt.Errorf("ai: read response: %w", err) - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("ai: %s: %s", resp.Status, strings.TrimSpace(string(data))) - } - if err := json.Unmarshal(data, out); err != nil { - return fmt.Errorf("ai: decode response: %w", err) - } - return nil -} diff --git a/internal/ai/ollama.go b/internal/ai/ollama.go index 8362f9a..16a6765 100644 --- a/internal/ai/ollama.go +++ b/internal/ai/ollama.go @@ -5,6 +5,7 @@ import ( "fmt" "gitea.oblak.solutions/dimitar/gitFlow/internal/config" + "gitea.oblak.solutions/dimitar/gitFlow/internal/httpclient" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" ) @@ -14,7 +15,7 @@ const defaultOllamaBaseURL = "http://localhost:11434" type OllamaProvider struct { model string baseURL string - client *client + http *httpclient.Client } // NewOllama builds an Ollama provider. base_url and model fall back to @@ -28,7 +29,7 @@ func NewOllama(cfg config.AIConfig) *OllamaProvider { if model == "" { model = "llama3.2" } - return &OllamaProvider{model: model, baseURL: baseURL, client: newClient()} + return &OllamaProvider{model: model, baseURL: baseURL, http: httpclient.New()} } // Name implements Provider. @@ -59,7 +60,7 @@ func (p *OllamaProvider) Suggest(ctx context.Context, result status.ScanResult) } var resp ollamaResponse - if err := p.client.postJSON(ctx, p.baseURL+"/api/chat", nil, req, &resp); err != nil { + if err := p.http.PostJSON(ctx, p.baseURL+"/api/chat", nil, req, &resp); err != nil { return nil, err } if resp.Error != "" { diff --git a/internal/ai/openai.go b/internal/ai/openai.go index e1f3175..f45bdea 100644 --- a/internal/ai/openai.go +++ b/internal/ai/openai.go @@ -5,6 +5,7 @@ import ( "fmt" "gitea.oblak.solutions/dimitar/gitFlow/internal/config" + "gitea.oblak.solutions/dimitar/gitFlow/internal/httpclient" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" ) @@ -15,7 +16,7 @@ type OpenAIProvider struct { model string baseURL string apiKey string - client *client + http *httpclient.Client } // NewOpenAI builds an OpenAI provider. base_url and model fall back to @@ -29,7 +30,7 @@ func NewOpenAI(cfg config.AIConfig, apiKey string) *OpenAIProvider { if model == "" { model = "gpt-4o" } - return &OpenAIProvider{model: model, baseURL: baseURL, apiKey: apiKey, client: newClient()} + return &OpenAIProvider{model: model, baseURL: baseURL, apiKey: apiKey, http: httpclient.New()} } // Name implements Provider. @@ -72,7 +73,7 @@ func (p *OpenAIProvider) Suggest(ctx context.Context, result status.ScanResult) } var resp chatResponse - err := p.client.postJSON(ctx, p.baseURL+"/chat/completions", + err := p.http.PostJSON(ctx, p.baseURL+"/chat/completions", map[string]string{"Authorization": "Bearer " + p.apiKey}, req, &resp) if err != nil { return nil, err diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go new file mode 100644 index 0000000..30f7c15 --- /dev/null +++ b/internal/httpclient/client.go @@ -0,0 +1,90 @@ +// 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 err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("httpclient: decode response: %w", err) + } + return nil +} diff --git a/internal/httpclient/client_test.go b/internal/httpclient/client_test.go new file mode 100644 index 0000000..676bce6 --- /dev/null +++ b/internal/httpclient/client_test.go @@ -0,0 +1,78 @@ +package httpclient + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestPostJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-Type") != "application/json" { + t.Error("Content-Type not set") + } + var in map[string]any + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + t.Fatal(err) + } + if v, _ := in["key"].(string); v != "value" { + t.Errorf(`key = %q, want "value"`, v) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer srv.Close() + + var out struct { + OK bool `json:"ok"` + } + c := New() + if err := c.PostJSON(t.Context(), srv.URL, nil, map[string]string{"key": "value"}, &out); err != nil { + t.Fatalf("PostJSON: %v", err) + } + if !out.OK { + t.Error("OK = false, want true") + } +} + +func TestPostJSONHTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer srv.Close() + + c := New() + if err := c.PostJSON(t.Context(), srv.URL, nil, nil, nil); err == nil { + t.Error("PostJSON(500) succeeded, want error") + } +} + +func TestPostJSONCustomHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Custom"); got != "abc" { + t.Errorf("X-Custom = %q, want abc", got) + } + _, _ = w.Write([]byte(`{"x":1}`)) + })) + defer srv.Close() + + var out map[string]int + c := New() + if err := c.PostJSON(t.Context(), srv.URL, map[string]string{"X-Custom": "abc"}, nil, &out); err != nil { + t.Fatalf("PostJSON: %v", err) + } +} + +func TestWithTimeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := New(WithTimeout(10 * time.Millisecond)) + if err := c.PostJSON(t.Context(), srv.URL, nil, nil, nil); err == nil { + t.Error("PostJSON with short timeout succeeded, want error") + } +} diff --git a/tui.md b/tui.md new file mode 100644 index 0000000..1e3eae6 --- /dev/null +++ b/tui.md @@ -0,0 +1,560 @@ +# TUI & Webhooks — Comprehensive Implementation Plan + +## Overview + +Two deferred features from Phase 6: +- **Interactive TUI** — bubbletea-based terminal UI for navigating repositories in a + watch session, expanding details, and triggering AI suggestions on demand +- **Webhook alerts** — Slack, Discord, and generic HTTP webhooks that fire when + watch detects changes, complementing the existing desktop notifications + +## Current state (baseline) + +The codebase is well-structured for these additions: + +- `pkg/status` — domain types (`RepoInfo`, `ScanResult`, `Summary`, `Changed`) +- `internal/app` — `App.ScanOnce(ctx) (ScanResult, error)` is the entry point + both TUI and webhooks consume +- `internal/scheduler` — generic interval loop; run callback receives `ctx` +- `internal/presenter` — `Formatter` interface (`Format(w, result) error`), + color/theme plumbing via `Options`, `Suggestions()` and `Flags()` renderers +- `internal/ai` — `Provider.Suggest(ctx, result) ([]Suggestion, error)` is the + interface TUI can call on demand +- `internal/notify` — `Send(title, body)` for desktop notifications; webhooks + complement this at a higher fidelity +- `internal/config` — viper-based section model; new `webhooks:` and `tui:` + sections drop in naturally +- `cmd/gitflow/watch.go` — the watch run closure is where both notifications and + webhooks would fire; a TUI watch command would be a new cobra command + +Test coverage is 77–91% across domain packages (except `cmd/gitflow` at 0%, +which is intentionally thin). The existing suite gives us a safety net. + +--- + +## Part 1: Interactive TUI (`internal/tui`) + +### 1.1 Architecture + +Bubbletea follows the Elm Architecture: **Model**, **Init**, **Update**, **View**. + +``` +cmd/gitflow/tui.go # new cobra command: gitflow tui +internal/tui/ +├── tui.go # Program constructor, main model +├── model.go # Model struct: repos list, cursor, detail panel, ... +├── update.go # Update(msg) (Model, Cmd) — keybindings, messages +├── view.go # View(model) string — lipgloss rendering +├── views/ +│ ├── repo_list.go # main repository table view +│ ├── repo_detail.go # expanded single-repo detail pane +│ ├── ai_panel.go # AI suggestion panel (appears on demand) +│ ├── status_bar.go # bottom bar: scan time, countdown, help hint +│ └── help.go # ? overlay with keybindings +├── styles.go # lipgloss.Style palette (theme-aware, dark/light) +└── tui_test.go # model logic tests +``` + +### 1.2 Model design + +```go +type Model struct { + // Core data + result status.ScanResult + prevResult status.ScanResult + aiSugs []ai.Suggestion + + // Navigation + cursor int // selected repo index in the list + detail bool // detail pane visible? + aiPanel bool // AI suggestion panel visible? + help bool // help overlay visible? + + // State + loading bool // scan in progress + aiLoading bool // AI request in flight + error string // last error to show, cleared on next update + first bool + + // Components + tableHeight int // rows visible in repo list (terminal-size aware) + width, height int // terminal dimensions + + // Dependencies (injected by tui.New) + app *app.App + ai ai.Provider + opts presenter.Options + cfg *config.Config +} +``` + +### 1.3 Messages (Bubbletea `Msg` types) + +```go +type scanTickMsg struct{} // interval timer fired +type scanResultMsg struct{ result status.ScanResult } // scan completed +type aiResultMsg struct{ suggestions []ai.Suggestion } // AI response +type aiErrorMsg struct{ err error } +type scanErrorMsg struct{ err error } +type terminalResizeMsg struct{ width, height int } +``` + +### 1.4 Keybindings + +| Key | Action | +|---|---| +| `j` / `↓` | Move cursor down | +| `k` / `↑` | Move cursor up | +| `g` / `Home` | Jump to top | +| `G` / `End` | Jump to bottom | +| `Enter` / `Space` | Toggle detail pane for selected repo | +| `a` | Request AI suggestions (if provider configured) | +| `r` | Trigger immediate rescan (when not in interval mode) | +| `?` | Toggle help overlay | +| `q` / `Ctrl-C` | Quit | + +### 1.5 View structure (lipgloss) + +``` +┌─ Repo list (top 75%) ───────────────────────────────────────┐ +│ REPOSITORY BRANCH STATUS A/B CHANGES FLAGS│ +│ ~/projects/api main ✓ clean 0/0 - │ +│ > ~/projects/web feat/login ✗ mod.. 3/0 2M 1U dirty │ ← cursor +│ ~/projects/lib main ⚠ behind 0/5 - stale│ +│ ... │ +├─ Detail pane (on Enter, bottom 25%) ────────────────────────┤ +│ ~/projects/web [dirty] │ +│ branch: feat/login | remote: origin | 3 ahead 0 behind │ +│ staged: 0 modified: 2 untracked: 1 stash: 0 │ +│ modified: internal/handler.go, cmd/server/main.go │ +│ untracked: config/local.yaml │ +├─ AI panel (on 'a', overlays bottom) ────────────────────────┤ +│ AI SUGGESTIONS │ +│ [high] commit — "commit the config change and the handler │ +│ update" $ git add -A && git commit -m wip │ +├─ Status bar ────────────────────────────────────────────────┤ +│ 12 repos | 5 clean | 6 need attention | 1 error next: 23s │ +│ ? help q quit ↑↓ nav Enter detail a AI r refresh │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 1.6 Styling + +Use `lipgloss` Styles — map the existing `presenter.renderer` colors to lipgloss +foreground/background styles. Both dark and light themes map to the same palette +the presenter uses: + +```go +func NewStyles(opts presenter.Options) Styles { + // Dark: green=lipgloss.Color("2"), yellow="3", red="1", cyan="6", blue="4" + // Light: green=lipgloss.Color("10"), yellow="11", red="9", cyan="14", blue="12" +} +``` + +Status symbols (✓ ✗ ↑ ↓ ⇄ ◉ ▢ !) come from `presenter.statusSymbol`. + +### 1.7 CLI command + +``` +gitflow tui [flags] Start interactive TUI with periodic scanning +``` + +Shares flags with `scan`/`watch`: `--dir`, `--interval`, `--exclude`, +`--max-depth`, `--workers`, `--ai`, `--ai-provider`, `--ai-model`, `--theme`, +`--color` (ignored — TUI is always color via lipgloss; still respects NO_COLOR by +reverting to a monochrome stylesheet). + +### 1.8 Program flow + +```go +func NewTUI(cfg *config.Config, a *app.App, aiProvider ai.Provider, opts presenter.Options) *tea.Program { + m := Model{ + app: a, + ai: aiProvider, + opts: opts, + cfg: cfg, + first: true, + } + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + // Launch a goroutine for the scan ticker that sends scanTickMsg + go scanLoop(p, cfg.Interval) + return p +} +``` + +The `scanLoop` goroutine respects cancellation via a `context.Context` from +`signal.NotifyContext`. Scans run via `a.ScanOnce(ctx)` and send `scanResultMsg`. +On scan error it sends `scanErrorMsg` (the model displays it, but the loop +continues — the user sees the last successful result). + +### 1.9 Testing + +Bubbletea has a `test` subpackage (`github.com/charmbracelet/bubbletea-test`) or +the `Send(msg)` method on Program for programmatic testing. Key test scenarios: + +- `TestModelNavigation` — cursor moves, wrap-around at bounds +- `TestModelDetailToggle` — Enter toggles detail pane +- `TestModelAISuggestions` — 'a' triggers AI and renders results +- `TestModelResize` — lipgloss reflow on terminal resize +- `TestModelQuit` — 'q' exits, Ctrl-C exits +- `TestScanTickUpdatesModel` — received scanResultMsg updates repo list +- `TestErrorDisplay` — scanErrorMsg sets error text, cleared on next scan +- `TestModelStateTransitions` — loading flag prevents concurrent scans + +`Model` should be testable without a real terminal: views return `string`, update +returns `(Model, Cmd)`, and the `test` package lets us assert the rendered output. + +### 1.10 Dependencies + +| Dependency | Version | Purpose | +|---|---|---| +| `github.com/charmbracelet/bubbletea` | v1.3+ | TUI framework (Elm architecture) | +| `github.com/charmbracelet/lipgloss` | v0.15+ | Declarative terminal styling | +| `github.com/charmbracelet/bubbletea-test` | v0.1+ | Programmatic TUI tests (dev dep) | + +Both are well-maintained, widely used in the Go ecosystem, and already power +tools like `lazygit`, `glow`, and `gum`. + +--- + +## Part 2: Webhooks (`internal/webhook`) + +### 2.1 Architecture + +```go +// Sender is the interface every webhook provider implements. +type Sender interface { + // Name identifies the provider (e.g. "slack", "discord"). + Name() string + // Send posts a payload describing the scan and which repositories changed. + // It must be safe to call from multiple goroutines and should return an + // error only when the upstream rejects the payload in a non-retryable way. + Send(ctx context.Context, payload Payload) error +} + +// Payload is the structured data sent to every webhook. +type Payload struct { + Timestamp time.Time `json:"timestamp"` + ParentDir string `json:"parent_dir"` + Summary status.Summary `json:"summary"` + Changed []status.RepoInfo `json:"changed"` + All []status.RepoInfo `json:"all,omitempty"` // when send_all: true +} +``` + +### 2.2 Providers + +#### Slack incoming webhook + +```go +type SlackSender struct { + url string // https://hooks.slack.com/services/... + client *client // shared HTTP client from internal/ai +} +``` + +Uses Slack Block Kit (`mrkdwn` sections + context blocks) to render a compact +message: summary line + changed repos with status emoji markers + a footer with +the scan time. The webhook URL is configured per-target; the payload is a Slack +`Message` struct marshalled to JSON. + +#### Discord webhook + +```go +type DiscordSender struct { + url string // https://discord.com/api/webhooks/... + client *client +} +``` + +Discord webhooks accept a simple JSON payload (`content`, `embeds`). Renders a +single rich embed with a markdown table of changed repos, color-coded by whether +the scan found attention-worthy things (green for clean, yellow for some +attention, red for errors). + +#### Generic HTTP webhook + +```go +type GenericSender struct { + url string + headers map[string]string // custom auth headers + client *client +} +``` + +Posts the `Payload` as JSON to any URL. Useful for piping into custom dashboards, +CI pipelines, or webhook relay services (Zapier, n8n, Pipedream). + +### 2.3 Configuration + +New `webhooks:` section in `~/.gitflow.yaml` (no flags — config/env only, like +`rules`): + +```yaml +webhooks: + - name: team-slack + type: slack + url: https://hooks.slack.com/services/... + send_all: false # when true, include every repo, not just changed + on_change_only: true # only fire when something changed + min_priority: 0 # 0=any, 1=medium+, 2=high only + rate_limit: 5m # minimum interval between sends per webhook + timeout: 10s # HTTP timeout per send + retry: + max_attempts: 2 + backoff: 1s + - name: ops-discord + type: discord + url: https://discord.com/api/webhooks/... + send_all: false + on_change_only: true + - name: custom-dashboard + type: generic + url: https://dashboard.internal/api/gitflow + headers: + Authorization: Bearer ${DASHBOARD_TOKEN} + X-Source: gitflow +``` + +Config struct: + +```go +type WebhookConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` // slack | discord | generic + URL string `yaml:"url"` + SendAll bool `yaml:"send_all"` + OnChangeOnly bool `yaml:"on_change_only"` + MinPriority int `yaml:"min_priority"` + RateLimit time.Duration `yaml:"rate_limit"` + Timeout time.Duration `yaml:"timeout"` + Headers map[string]string `yaml:"headers,omitempty"` + Retry RetryConfig `yaml:"retry"` +} +type RetryConfig struct { + MaxAttempts int `yaml:"max_attempts"` + Backoff time.Duration `yaml:"backoff"` +} +``` + +Validation: `type` must be `slack`/`discord`/`generic`, `url` must be non-empty +and start with `http://` or `https://`, `min_priority` clamped 0–2, `rate_limit` +≥ 1s, `retry.max_attempts` ≤ 5. + +### 2.4 Dispatcher + +```go +type Dispatcher struct { + senders []senderHandle +} + +type senderHandle struct { + sender Sender + cfg WebhookConfig + lastSent time.Time + mu sync.Mutex +} + +// Dispatch sends the payload to every sender whose filters match. +func (d *Dispatcher) Dispatch(ctx context.Context, payload Payload, changedCount int) []error +``` + +Each sender is guarded by: +- `on_change_only`: skip if `changedCount == 0` +- `min_priority`: skip if no changed repo's rule-flag priority meets the threshold +- `rate_limit`: skip if `lastSent + rate_limit > now` +- Retry with exponential backoff on transient errors (5xx, DNS, timeout); + non-retryable errors (4xx) are logged and dropped immediately + +### 2.5 Integration points + +Webhooks fire from the same watch run closure that already handles desktop +notifications: + +```go +// In cmd/gitflow/watch.go, inside the run closure: +if !first && cfg.Notify { + notifyChanges(prev, result) +} +if len(cfg.Webhooks) > 0 { + go dispatchWebhooks(ctx, cfg.Webhooks, prev, result) +} +``` + +They run in a background goroutine so a slow webhook never blocks the scan +interval. The dispatcher manages its own context with a deadline from the +webhook config timeout. + +### 2.6 Testing + +- **Provider tests** (`internal/webhook/slack_test.go`, etc.): `httptest` server + captures the JSON payload, asserts the Block Kit structure, tests the + `content`/`embeds` shape. Verifies that `Payload → Slack/Discord/Geneic JSON` + conversion is correct and that HTTP errors surface properly. +- **Dispatcher tests**: multiple senders, rate-limit checks, on_change_only + filtering, retry behavior, concurrent dispatch (no races). +- **Config tests**: valid webhook configs parse; invalid ones (`type: nope`, + `url: ""`, `rate_limit: 0s`) are rejected. +- **Integration smoke**: a local `httptest` server stands in for Slack; a watch + loop fires a scan; the webhook payload arrives. + +### 2.7 Dependencies + +No new external dependencies — the shared HTTP `client` from `internal/ai` is +reused (`internal/ai/client.go` currently is unexported; it would be promoted to +`internal/httputil` or a new shared `internal/httpclient` package so both `ai` +and `webhook` import it without a cycle). + +--- + +## Part 3: Shared concerns + +### 3.1 HTTP client refactor + +Both `internal/ai` and `internal/webhook` need an HTTP client. Currently +`internal/ai/client.go` exports a package-private `client` type. The refactor: + +``` +internal/httpclient/ +├── client.go # exported Client with PostJSON(ctx, url, headers, body, out) error +└── client_test.go +``` + +`internal/ai` imports `internal/httpclient` (replaces its current private +`client`). `internal/webhook` imports the same package. This is a zero-behavior +change; the existing AI provider tests continue to pass unmodified. + +### 3.2 AI provider exposure to TUI + +The TUI model needs an `ai.Provider` to call `Suggest` on demand. Currently +`renderSuggestions` in `cmd/gitflow/scan.go` creates the provider via +`ai.NewProvider(cfg.AI)` and calls it once. For the TUI, the provider is created +up-front in `tui.New()` so the model can call it on every 'a' keypress: + +```go +var provider ai.Provider +if cfg.AI.Enabled { + provider, _ = ai.NewProvider(cfg.AI) // error handled at startup +} +``` + +A nil provider means the AI panel is unavailable (keybinding 'a' shows a +"provider not configured" hint). + +### 3.3 Terminal resize and signal handling + +Bubbletea handles `SIGWINCH` natively — `tea.WithAltScreen()` and +`tea.WithMouseCellMotion()` are passed to `tea.NewProgram`. The terminal size is +exposed in `tea.WindowSizeMsg`. The model updates `width`/`height` and the view +reflows. + +For `SIGINT`/`SIGTERM`, bubbletea intercepts these and sends `tea.KeyMsg` with +`ctrl+c`. The update handler maps this to `tea.Quit`. Cleanup (stopping the scan +goroutine) happens in the deferred function inside `tui.New()`. + +### 3.4 Config additions + +New `config.go` fields: + +```go +type Config struct { + // ... existing fields ... + Webhooks []config.WebhookConfig `yaml:"webhooks,omitempty"` +} +``` + +`RegisterFlags` is unchanged (webhooks are config-file-only, like rules). A new +`validateWebhooks` check runs in `Config.Validate()`. The `Dump()` method +includes the webhooks section. + +--- + +## Part 4: Milestone schedule + +| Milestone | Scope | Effort | +|---|---|---| +| **M1 — HTTP client refactor** | Extract `internal/httpclient` from `internal/ai/client.go`; update AI providers | 0.5 day | +| **M2 — TUI skeleton** | `internal/tui` package, `Model` struct, bubbletea program bootstrap, `gitflow tui` cobra command, lipgloss styles, repo list view | 1 day | +| **M3 — TUI interaction** | Cursor navigation, detail pane toggle, help overlay, status bar, scan loop goroutine with ticker | 1 day | +| **M4 — TUI AI + polish** | AI suggestion panel on 'a', resize handling, loading states, error display, monochrome/no-color stylesheet | 0.5 day | +| **M5 — Webhook core** | `internal/webhook` package, `Sender` interface, Slack + Discord + Generic implementations, dispatcher with rate-limit and retry | 1.5 days | +| **M6 — Webhook config + wiring** | Config section, validation, watch-closure integration, httptest-based provider tests, dispatcher tests | 1 day | +| **M7 — Integration & docs** | End-to-end TUI smoke tests, webhook round-trip against httptest, README update, `tui.md` status | 0.5 day | + +**Total**: ~5–6 days + +--- + +## Part 5: Risks and trade-offs + +1. **Bubbletea adds ~15 transitive deps** (lipgloss + x/term + x/ansi + a few + more) — but they're all from charmbracelet or golang.org/x, well-maintained, + and carry no build-time surprises. The binary grows ~800 KB, which is + acceptable for a TUI tool. + +2. **TUI and terminal multiplexers (tmux)** — bubbletea works well in tmux but + the `alt screen` buffer requires `tmux set-option -g default-terminal + "screen-256color"`. Minor documentation note. + +3. **Webhook secrets in config** — webhook URLs often carry secrets (e.g. + `https://hooks.slack.com/services/T00/B00/TOKEN`). The config file should be + `chmod 600`. A future hardening could support reading URLs from environment + variables (`url: ${SLACK_WEBHOOK_URL}`) — `os.ExpandEnv` already handles this + if we run the URL through it during sender construction. + +4. **TUI on Windows** — bubbletea supports Windows Terminal and ConPTY. + Verifying cross-compilation and basic rendering on Windows should be part of + M7 (or documented as "best-effort"). + +5. **Webhook TLS** — the `http.Client` reuses the AI client's defaults (system + cert pool, no custom CA). If someone needs a custom CA for an internal + webhook endpoint, that's a future `webhook.tls_ca_file` setting. Out of scope + for v1. + +6. **TUI "compact" persona** — it's tempting to make the TUI respond to + `--format`; that flag is for the CLI renderers. The TUI always uses its own + layout. A `--tui-simple` flag for minimal rendering (ASCII borders, no + lipgloss) is a possible later addition for constrained environments. + +--- + +## Part 6: Acceptance criteria (per milestone) + +### M1 — HTTP client refactor +- `internal/httpclient` package with `Client.PostJSON` method +- `internal/ai` providers switched to it; all ai tests pass unchanged +- `go test -race ./...` green + +### M2 — TUI skeleton +- `gitflow tui` command launches, renders a static repo list, quits on 'q' +- lipgloss styles match the CLI's dark/light theme palette +- `tea.NewProgram` wired with `WithAltScreen()` + +### M3 — TUI interaction +- Arrow keys and j/k navigate the list; Enter toggles detail pane +- Status bar shows scan time, countdown, and keybinding hints +- `?` shows help overlay; resize reflows the layout +- Periodic scan loop sends `scanResultMsg` and the list updates + +### M4 — TUI AI + polish +- 'a' triggers `ai.Suggest` and renders the AI panel; loading/error states +- Ctrl-C quits gracefully (no goroutine leaks) +- Model logic tests cover navigation, detail, AI panel, error, quit + +### M5 — Webhook core +- `SlackSender`, `DiscordSender`, `GenericSender` implement `Sender` +- `Dispatcher.Dispatch` respects `on_change_only`, `min_priority`, `rate_limit` +- Retry with backoff on 5xx; 4xx returns error immediately +- httptest-based tests for each sender's payload shape + +### M6 — Webhook config + wiring +- `webhooks:` section parseable from `~/.gitflow.yaml` with full validation +- Watch closure fires `Dispatch` in a goroutine after each scan +- Config dump includes webhooks section +- Dispatcher tests cover multiple senders, rate-limit, filtering + +### M7 — Integration & docs +- Full TUI smoke test: launch, navigate, expand detail, trigger AI, quit +- Webhook smoke: httptest server receives payload through the dispatcher +- README updated with `gitflow tui` and webhooks sections +- Cross-compile verification (linux/amd64, darwin/arm64, windows/amd64)