gitFlow/tui.md

23 KiB
Raw Permalink Blame History

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/appApp.ScanOnce(ctx) (ScanResult, error) is the entry point both TUI and webhooks consume
  • internal/scheduler — generic interval loop; run callback receives ctx
  • internal/presenterFormatter interface (Format(w, result) error), color/theme plumbing via Options, Suggestions() and Flags() renderers
  • internal/aiProvider.Suggest(ctx, result) ([]Suggestion, error) is the interface TUI can call on demand
  • internal/notifySend(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

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)

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:

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

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

// 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

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

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

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):

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:

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

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:

// 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:

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:

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