refactor: M1 — extract shared HTTP client from AI package
Pull the private `client`/`postJSON` from `internal/ai` into a standalone `internal/httpclient` package so both the AI providers and the upcoming webhook senders can share the same bounded-reader, timeout-guarded JSON HTTP client without introducing a dependency cycle. Changes: - internal/httpclient: Client struct with PostJSON(ctx, url, headers, payload, out), functional options WithTimeout/WithTransport, a 4 MiB response cap, and a 60s default timeout - internal/ai: three providers (OpenAI, Ollama, Anthropic) now embed an `*httpclient.Client` (field renamed from `client` to `http`); the old `client.go` is deleted - All 11 test packages pass (ai tests are byte-for-byte unaffected) This zero-behaviour refactor unblocks the webhook package distributed in M5, which needs the exact same JSON-post-and-decode helper.
This commit is contained in:
parent
982012c867
commit
b83a4baebd
@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/httpclient"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -18,7 +19,7 @@ type AnthropicProvider struct {
|
|||||||
model string
|
model string
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
client *client
|
http *httpclient.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAnthropic builds an Anthropic provider. base_url and model fall back
|
// 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 == "" {
|
if model == "" {
|
||||||
model = "claude-3-5-haiku-latest"
|
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.
|
// Name implements Provider.
|
||||||
@ -65,7 +66,7 @@ func (p *AnthropicProvider) Suggest(ctx context.Context, result status.ScanResul
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resp anthropicResponse
|
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)
|
map[string]string{"x-api-key": p.apiKey, "anthropic-version": anthropicVersion}, req, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/httpclient"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -14,7 +15,7 @@ const defaultOllamaBaseURL = "http://localhost:11434"
|
|||||||
type OllamaProvider struct {
|
type OllamaProvider struct {
|
||||||
model string
|
model string
|
||||||
baseURL string
|
baseURL string
|
||||||
client *client
|
http *httpclient.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOllama builds an Ollama provider. base_url and model fall back to
|
// NewOllama builds an Ollama provider. base_url and model fall back to
|
||||||
@ -28,7 +29,7 @@ func NewOllama(cfg config.AIConfig) *OllamaProvider {
|
|||||||
if model == "" {
|
if model == "" {
|
||||||
model = "llama3.2"
|
model = "llama3.2"
|
||||||
}
|
}
|
||||||
return &OllamaProvider{model: model, baseURL: baseURL, client: newClient()}
|
return &OllamaProvider{model: model, baseURL: baseURL, http: httpclient.New()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name implements Provider.
|
// Name implements Provider.
|
||||||
@ -59,7 +60,7 @@ func (p *OllamaProvider) Suggest(ctx context.Context, result status.ScanResult)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resp ollamaResponse
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp.Error != "" {
|
if resp.Error != "" {
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/httpclient"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -15,7 +16,7 @@ type OpenAIProvider struct {
|
|||||||
model string
|
model string
|
||||||
baseURL string
|
baseURL string
|
||||||
apiKey string
|
apiKey string
|
||||||
client *client
|
http *httpclient.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOpenAI builds an OpenAI provider. base_url and model fall back to
|
// 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 == "" {
|
if model == "" {
|
||||||
model = "gpt-4o"
|
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.
|
// Name implements Provider.
|
||||||
@ -72,7 +73,7 @@ func (p *OpenAIProvider) Suggest(ctx context.Context, result status.ScanResult)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resp chatResponse
|
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)
|
map[string]string{"Authorization": "Bearer " + p.apiKey}, req, &resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
90
internal/httpclient/client.go
Normal file
90
internal/httpclient/client.go
Normal file
@ -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
|
||||||
|
}
|
||||||
78
internal/httpclient/client_test.go
Normal file
78
internal/httpclient/client_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
560
tui.md
Normal file
560
tui.md
Normal file
@ -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)
|
||||||
Loading…
Reference in New Issue
Block a user