gitFlow/tui.md

586 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 7791% 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 02, `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**: ~56 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)
---
## Implementation Progress
All milestones from this plan are implemented and merged into `main`.
| Milestone | Branch | Status |
|---|---|---|
| M1 — HTTP client refactor | `feat/tui-webhooks-m1-httpclient` | ✅ done |
| M2 — TUI skeleton | `feat/tui-webhooks-m2-tui-skeleton` | ✅ done |
| M3 — TUI interaction | `feat/tui-webhooks-m3-tui-interaction` | ✅ done |
| M4 — TUI AI + polish | `feat/tui-webhooks-m4-tui-ai-polish` | ✅ done |
| M5 — Webhook core | `feat/tui-webhooks-m5-webhook-core` | ✅ done |
| M6 — Webhook config + wiring | `feat/tui-webhooks-m6-webhook-config` | ✅ done |
| M7 — Integration & docs | (squashed into M6 merge) | ✅ done |
### Verification
- go build, go vet, go test -race (13 packages), gofmt clean — all green
- Cross-compilation: windows/amd64, darwin/arm64
- `gitflow tui --help` prints usage; model logic tests cover navigation, AI, and views
- Webhook senders round-trip through httptest; Slack Block Kit and Discord embed shapes verified
- Webhook config parsed from YAML with duration fixup; bad configs rejected
- Watch closure fires dispatcher in a goroutine per frame