Add the AI layer that turns scan results into suggested next actions. Provider model (internal/ai): - Provider interface with Name() and Suggest(ctx, ScanResult) returning []Suggestion (repo_path, action, message, command, priority 0-2) - NewProvider factory resolves the configured provider and enforces that cloud providers have their API key in the configured env var; Ollama needs no key - OpenAIProvider: Chat Completions with response_format json_object - OllamaProvider: local /api/chat with format:json for structured output - AnthropicProvider: Messages API with system prompt and key/version headers; all three fall back to provider-appropriate defaults for base_url and model - Shared client: 60s timeout, 4 MiB response cap, JSON encode/decode, HTTP error surfaces the upstream status and body Prompt design (BuildPrompt): - Renders the full repository status table (name/status/branch/ahead/ behind/changes) plus strict output rules: exact suggestion schema, allowed actions, smallest-safe-step guidance, no invented repositories, return [] when healthy Parsing (parseSuggestions): - Tolerates ```json fences and surrounding prose, clamps priorities to 0-2, caps the result, and rejects replies without a JSON array Suggestion display (presenter.Suggestions): - "AI SUGGESTIONS" table (repository/action/priority/message/command) rendered below the scan table with priority color-coded (red/yellow/ green); empty results say all repositories are healthy Guarded execution (--ai-execute, experimental): - RunConfirmed executes a suggestion's command inside its repository only after explicit per-command y/N confirmation, and only for actions on an allowlist (commit/push/pull/stash/checkout) so LLM output can never run arbitrary shell commands; cancellation aborts remaining suggestions CLI wiring: - scan: AI block after the table when --ai is set and format is not json (JSON streams stay machine-readable); failures degrade to warnings - watch: AI is queried only on the first frame and when something changed since the previous frame, to avoid hammering the provider every interval - New --ai-execute flag and provider validation in config (openai/ollama/ anthropic), included in the config dump Testing: - httptest-based provider tests verifying request shape (model, auth headers, path), response parsing, API error bodies, HTTP failures, and cancellation - Parse tests: plain/fenced/prose replies, empty arrays, garbage, truncated JSON, priority clamping - Execution tests: unsafe actions and empty commands never run, declined confirmations are skipped, confirmed commands execute in the repo dir, failing commands surface errors - Presenter suggestion table and empty-state tests Verified: go build, go vet, go test -race, gofmt clean; end-to-end smoke test against a local fake Ollama server (request shape confirmed, table + suggestions rendered) and the missing-API-key warning path.
225 lines
6.7 KiB
Go
225 lines
6.7 KiB
Go
// Package config resolves and validates gitflow settings from CLI flags,
|
|
// environment variables, and an optional YAML configuration file.
|
|
//
|
|
// Precedence (highest first): flags, environment variables, config file,
|
|
// built-in defaults.
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/pflag"
|
|
"github.com/spf13/viper"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// AIConfig holds AI agent settings.
|
|
type AIConfig struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
Provider string `yaml:"provider"` // openai, ollama, or anthropic
|
|
Model string `yaml:"model"` // model name; empty lets the provider choose
|
|
APIKeyEnv string `yaml:"api_key_env"` // env var holding the API key
|
|
BaseURL string `yaml:"base_url"` // provider endpoint override
|
|
Execute bool `yaml:"execute"` // run confirmed AI-suggested commands (experimental)
|
|
}
|
|
|
|
// Config is the fully resolved runtime configuration.
|
|
type Config struct {
|
|
Dir string `yaml:"dir"`
|
|
Interval time.Duration `yaml:"interval"`
|
|
Format string `yaml:"format"`
|
|
Color string `yaml:"color"`
|
|
Exclude []string `yaml:"exclude,omitempty"`
|
|
MaxDepth int `yaml:"max_depth"`
|
|
Workers int `yaml:"workers"`
|
|
AI AIConfig `yaml:"ai"`
|
|
ConfigFile string `yaml:"-"` // path of the loaded config file, if any
|
|
}
|
|
|
|
// flagKeys maps CLI flag names to their viper keys.
|
|
var flagKeys = []struct{ flag, key string }{
|
|
{"dir", "dir"},
|
|
{"interval", "interval"},
|
|
{"format", "format"},
|
|
{"color", "color"},
|
|
{"exclude", "exclude"},
|
|
{"max-depth", "max_depth"},
|
|
{"workers", "workers"},
|
|
{"ai", "ai.enabled"},
|
|
{"ai-provider", "ai.provider"},
|
|
{"ai-model", "ai.model"},
|
|
{"ai-execute", "ai.execute"},
|
|
}
|
|
|
|
// RegisterFlags defines every gitflow flag on f. Call Load with the same
|
|
// FlagSet to resolve the effective configuration.
|
|
func RegisterFlags(f *pflag.FlagSet) {
|
|
f.StringP("dir", "d", ".", "parent directory to scan")
|
|
f.DurationP("interval", "i", 0, "rescan interval (e.g. 30s, 5m); 0 runs once")
|
|
f.StringP("format", "f", "table", "output format: table, json, or compact")
|
|
f.String("color", "auto", "color output: auto, always, or never")
|
|
f.StringSlice("exclude", nil, "glob patterns of directories to skip (repeatable)")
|
|
f.Int("max-depth", 0, "maximum directory depth to scan (0 = unlimited)")
|
|
f.Int("workers", 8, "number of concurrent git scans")
|
|
f.Bool("ai", false, "enable AI suggestions")
|
|
f.String("ai-provider", "openai", "AI provider: openai, ollama, or anthropic")
|
|
f.String("ai-model", "gpt-4o", "AI model name")
|
|
f.Bool("ai-execute", false, "run confirmed AI-suggested commands (experimental)")
|
|
}
|
|
|
|
// NewFlagSet returns a FlagSet with every gitflow flag registered.
|
|
func NewFlagSet() *pflag.FlagSet {
|
|
fs := pflag.NewFlagSet("gitflow", pflag.ContinueOnError)
|
|
RegisterFlags(fs)
|
|
return fs
|
|
}
|
|
|
|
// Load resolves the effective configuration from flags, environment, and
|
|
// config file, then validates it.
|
|
func Load(flags *pflag.FlagSet) (*Config, error) {
|
|
v := viper.New()
|
|
applyDefaults(v)
|
|
v.SetEnvPrefix("GITFLOW")
|
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
|
v.AutomaticEnv()
|
|
|
|
if err := readConfigFile(v); err != nil {
|
|
return nil, err
|
|
}
|
|
bindFlags(v, flags)
|
|
|
|
cfg := &Config{
|
|
Dir: v.GetString("dir"),
|
|
Interval: v.GetDuration("interval"),
|
|
Format: v.GetString("format"),
|
|
Color: v.GetString("color"),
|
|
Exclude: v.GetStringSlice("exclude"),
|
|
MaxDepth: v.GetInt("max_depth"),
|
|
Workers: v.GetInt("workers"),
|
|
ConfigFile: v.ConfigFileUsed(),
|
|
AI: AIConfig{
|
|
Enabled: v.GetBool("ai.enabled"),
|
|
Provider: v.GetString("ai.provider"),
|
|
Model: v.GetString("ai.model"),
|
|
APIKeyEnv: v.GetString("ai.api_key_env"),
|
|
BaseURL: v.GetString("ai.base_url"),
|
|
Execute: v.GetBool("ai.execute"),
|
|
},
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func applyDefaults(v *viper.Viper) {
|
|
v.SetDefault("dir", ".")
|
|
v.SetDefault("interval", 0)
|
|
v.SetDefault("format", "table")
|
|
v.SetDefault("color", "auto")
|
|
v.SetDefault("exclude", []string{})
|
|
v.SetDefault("max_depth", 0)
|
|
v.SetDefault("workers", 8)
|
|
v.SetDefault("ai.enabled", false)
|
|
v.SetDefault("ai.provider", "openai")
|
|
v.SetDefault("ai.model", "gpt-4o")
|
|
v.SetDefault("ai.api_key_env", "OPENAI_API_KEY")
|
|
v.SetDefault("ai.base_url", "")
|
|
v.SetDefault("ai.execute", false)
|
|
}
|
|
|
|
// readConfigFile loads ~/.gitflow.yaml (or $GITFLOW_CONFIG when set). A
|
|
// missing config file is not an error; a malformed one is.
|
|
func readConfigFile(v *viper.Viper) error {
|
|
if path := os.Getenv("GITFLOW_CONFIG"); path != "" {
|
|
v.SetConfigFile(path)
|
|
} else {
|
|
v.SetConfigName(".gitflow")
|
|
v.SetConfigType("yaml")
|
|
v.AddConfigPath("$HOME")
|
|
v.AddConfigPath(".")
|
|
}
|
|
if err := v.ReadInConfig(); err != nil {
|
|
var notFound viper.ConfigFileNotFoundError
|
|
if errors.As(err, ¬Found) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("config: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func bindFlags(v *viper.Viper, flags *pflag.FlagSet) {
|
|
for _, fk := range flagKeys {
|
|
if fl := flags.Lookup(fk.flag); fl != nil {
|
|
_ = v.BindPFlag(fk.key, fl)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate rejects configuration that cannot be used.
|
|
func (c *Config) Validate() error {
|
|
if c.Dir == "" {
|
|
return errors.New("config: dir must not be empty")
|
|
}
|
|
switch c.Format {
|
|
case "table", "json", "compact":
|
|
default:
|
|
return fmt.Errorf("config: unsupported format %q (want table, json, or compact)", c.Format)
|
|
}
|
|
switch c.Color {
|
|
case "auto", "always", "never":
|
|
default:
|
|
return fmt.Errorf("config: unsupported color mode %q (want auto, always, or never)", c.Color)
|
|
}
|
|
if c.Interval < 0 {
|
|
return errors.New("config: interval must not be negative")
|
|
}
|
|
if c.MaxDepth < 0 {
|
|
return errors.New("config: max-depth must not be negative")
|
|
}
|
|
if c.Workers < 1 {
|
|
return errors.New("config: workers must be at least 1")
|
|
}
|
|
if c.AI.Enabled {
|
|
switch c.AI.Provider {
|
|
case "openai", "ollama", "anthropic":
|
|
default:
|
|
return fmt.Errorf("config: unsupported ai provider %q (want openai, ollama, or anthropic)", c.AI.Provider)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Dump renders the effective configuration as human-readable YAML, with the
|
|
// interval shown as a duration string.
|
|
func (c *Config) Dump() (string, error) {
|
|
v := map[string]any{
|
|
"config_file": c.ConfigFile,
|
|
"dir": c.Dir,
|
|
"interval": c.Interval.String(),
|
|
"format": c.Format,
|
|
"color": c.Color,
|
|
"exclude": c.Exclude,
|
|
"max_depth": c.MaxDepth,
|
|
"workers": c.Workers,
|
|
"ai": map[string]any{
|
|
"enabled": c.AI.Enabled,
|
|
"provider": c.AI.Provider,
|
|
"model": c.AI.Model,
|
|
"api_key_env": c.AI.APIKeyEnv,
|
|
"base_url": c.AI.BaseURL,
|
|
"execute": c.AI.Execute,
|
|
},
|
|
}
|
|
out, err := yaml.Marshal(v)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
}
|