feat: phase 5 — AI agent integration (OpenAI, Ollama, Anthropic)
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.
This commit is contained in:
parent
6e945fc13b
commit
aee63bf321
@ -31,3 +31,26 @@ func promptDir(def string) (string, error) {
|
||||
}
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// promptYesNo asks a y/N question on stderr and returns the answer.
|
||||
func promptYesNo(def bool, format string, args ...any) (bool, error) {
|
||||
suffix := "[y/N]"
|
||||
if def {
|
||||
suffix = "[Y/n]"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, format+" "+suffix+": ", args...)
|
||||
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "y", "yes":
|
||||
return true, nil
|
||||
case "n", "no":
|
||||
return false, nil
|
||||
case "":
|
||||
return def, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
// newScanCmd runs a single scan pass and renders the result.
|
||||
@ -43,13 +46,52 @@ func newScanCmd() *cobra.Command {
|
||||
}
|
||||
|
||||
opts := presenter.Options{Color: presenter.ParseColorMode(cfg.Color)}
|
||||
return presenter.Present(os.Stdout, cfg.Format, result, opts)
|
||||
if err := presenter.Present(os.Stdout, cfg.Format, result, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// AI suggestions are a human-facing section; they are skipped
|
||||
// in JSON mode so the machine-readable stream stays clean.
|
||||
if cfg.AI.Enabled && cfg.Format != "json" {
|
||||
renderSuggestions(ctx, cfg, opts, result)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
config.RegisterFlags(cmd.Flags())
|
||||
return cmd
|
||||
}
|
||||
|
||||
// renderSuggestions asks the configured AI provider for next actions,
|
||||
// renders them, and — when enabled — runs confirmed commands. Failures are
|
||||
// warnings: a scan result is still useful without AI.
|
||||
func renderSuggestions(ctx context.Context, cfg *config.Config, opts presenter.Options, result status.ScanResult) {
|
||||
provider, err := ai.NewProvider(cfg.AI)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
|
||||
return
|
||||
}
|
||||
suggestions, err := provider.Suggest(ctx, result)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: AI suggestions unavailable: %v\n", err)
|
||||
return
|
||||
}
|
||||
if err := presenter.Suggestions(os.Stdout, opts, suggestions); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
|
||||
return
|
||||
}
|
||||
if cfg.AI.Execute && isTerminal(os.Stdin) {
|
||||
if err := ai.RunConfirmed(ctx, suggestions, confirmSuggestion); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// confirmSuggestion asks the user to approve running a suggested command.
|
||||
func confirmSuggestion(s ai.Suggestion) (bool, error) {
|
||||
return promptYesNo(false, "Run in %s: $ %s", s.RepoPath, s.Command)
|
||||
}
|
||||
|
||||
// newConfigCmd prints the effective configuration.
|
||||
func newConfigCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
|
||||
@ -57,6 +57,11 @@ func newWatchCmd() *cobra.Command {
|
||||
return presenter.Present(os.Stdout, cfg.Format, result, opts)
|
||||
}
|
||||
renderWatchFrame(result, prev, first, cfg, opts)
|
||||
// Ask the AI only on the first frame and when something
|
||||
// changed, so the provider is not hammered every interval.
|
||||
if cfg.AI.Enabled && (first || len(status.Changed(prev, result)) > 0) {
|
||||
renderSuggestions(ctx, cfg, opts, result)
|
||||
}
|
||||
prev = result
|
||||
first = false
|
||||
return nil
|
||||
|
||||
54
internal/ai/ai.go
Normal file
54
internal/ai/ai.go
Normal file
@ -0,0 +1,54 @@
|
||||
// Package ai turns scan results into suggested next actions using an LLM
|
||||
// provider (OpenAI, Ollama, or Anthropic).
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
// Suggestion is one recommended next action for a repository. The JSON tags
|
||||
// match the schema the providers are instructed to emit.
|
||||
type Suggestion struct {
|
||||
RepoPath string `json:"repo_path"` // absolute path of the repository
|
||||
Action string `json:"action"` // commit, push, pull, stash, create_pr, cleanup, inspect, ...
|
||||
Message string `json:"message"` // human-readable explanation
|
||||
Command string `json:"command"` // suggested shell command, if any
|
||||
Priority int `json:"priority"` // 0 = low, 1 = medium, 2 = high
|
||||
}
|
||||
|
||||
// Provider turns a scan result into suggestions.
|
||||
type Provider interface {
|
||||
// Name identifies the provider for logging and errors.
|
||||
Name() string
|
||||
// Suggest asks the provider for next actions on the given result.
|
||||
Suggest(ctx context.Context, result status.ScanResult) ([]Suggestion, error)
|
||||
}
|
||||
|
||||
// NewProvider builds the provider named in the AI configuration. Cloud
|
||||
// providers require their API key to be present in the configured
|
||||
// environment variable; Ollama is local and needs no key.
|
||||
func NewProvider(cfg config.AIConfig) (Provider, error) {
|
||||
switch cfg.Provider {
|
||||
case "openai":
|
||||
key := os.Getenv(cfg.APIKeyEnv)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("ai: %s is not set; export it or set ai.api_key_env", cfg.APIKeyEnv)
|
||||
}
|
||||
return NewOpenAI(cfg, key), nil
|
||||
case "anthropic":
|
||||
key := os.Getenv(cfg.APIKeyEnv)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("ai: %s is not set; export it or set ai.api_key_env", cfg.APIKeyEnv)
|
||||
}
|
||||
return NewAnthropic(cfg, key), nil
|
||||
case "ollama":
|
||||
return NewOllama(cfg), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("ai: unknown provider %q (want openai, ollama, or anthropic)", cfg.Provider)
|
||||
}
|
||||
}
|
||||
266
internal/ai/ai_test.go
Normal file
266
internal/ai/ai_test.go
Normal file
@ -0,0 +1,266 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
func sampleResult() status.ScanResult {
|
||||
return status.ScanResult{
|
||||
ScannedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
ParentDir: "/home/user/projects",
|
||||
Repos: []status.RepoInfo{
|
||||
{Path: "/home/user/projects/api", Name: "api", Branch: "main", Status: status.StatusClean},
|
||||
{Path: "/home/user/projects/web", Name: "web", Branch: "feat/login", Status: status.StatusModified, ModifiedFiles: []string{"a.go"}},
|
||||
{Path: "/home/user/projects/lib", Name: "lib", Branch: "main", Status: status.StatusBehind, BehindBy: 5},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func suggestionsJSON() string {
|
||||
return `[{"repo_path":"/home/user/projects/web","action":"commit","message":"commit your changes","command":"git add -A && git commit -m wip","priority":2},
|
||||
{"repo_path":"/home/user/projects/lib","action":"pull","message":"pull latest","command":"git pull","priority":1}]`
|
||||
}
|
||||
|
||||
func TestNewProviderOpenAIRequiresKey(t *testing.T) {
|
||||
t.Setenv("GITFLOW_TEST_AI_KEY", "")
|
||||
if _, err := NewProvider(config.AIConfig{Provider: "openai", APIKeyEnv: "GITFLOW_TEST_AI_KEY"}); err == nil {
|
||||
t.Error("NewProvider(openai, no key) succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProviderAnthropicRequiresKey(t *testing.T) {
|
||||
t.Setenv("GITFLOW_TEST_AI_KEY", "")
|
||||
if _, err := NewProvider(config.AIConfig{Provider: "anthropic", APIKeyEnv: "GITFLOW_TEST_AI_KEY"}); err == nil {
|
||||
t.Error("NewProvider(anthropic, no key) succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProviderOllamaNeedsNoKey(t *testing.T) {
|
||||
p, err := NewProvider(config.AIConfig{Provider: "ollama"})
|
||||
if err != nil {
|
||||
t.Fatalf("NewProvider(ollama): %v", err)
|
||||
}
|
||||
if p.Name() != "ollama" {
|
||||
t.Errorf("Name = %q, want ollama", p.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewProviderUnknown(t *testing.T) {
|
||||
if _, err := NewProvider(config.AIConfig{Provider: "magic"}); err == nil {
|
||||
t.Error("NewProvider(magic) succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProvider(t *testing.T) {
|
||||
var gotReq chatRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
||||
t.Errorf("Authorization = %q, want Bearer test-key", got)
|
||||
}
|
||||
if got := r.URL.Path; got != "/chat/completions" {
|
||||
t.Errorf("path = %q, want /chat/completions", got)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
content := "```json\n" + suggestionsJSON() + "\n```"
|
||||
resp, _ := json.Marshal(map[string]any{
|
||||
"choices": []any{map[string]any{
|
||||
"message": map[string]any{"role": "assistant", "content": content},
|
||||
}},
|
||||
})
|
||||
_, _ = w.Write(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewOpenAI(config.AIConfig{BaseURL: srv.URL, Model: "gpt-test"}, "test-key")
|
||||
sugs, err := p.Suggest(context.Background(), sampleResult())
|
||||
if err != nil {
|
||||
t.Fatalf("Suggest: %v", err)
|
||||
}
|
||||
if gotReq.Model != "gpt-test" {
|
||||
t.Errorf("model = %q, want gpt-test", gotReq.Model)
|
||||
}
|
||||
if len(gotReq.Messages) != 2 || gotReq.Messages[0].Role != "system" {
|
||||
t.Errorf("messages malformed: %+v", gotReq.Messages)
|
||||
}
|
||||
if len(sugs) != 2 {
|
||||
t.Fatalf("got %d suggestions, want 2", len(sugs))
|
||||
}
|
||||
if sugs[0].Action != "commit" || sugs[0].Priority != 2 || sugs[0].RepoPath != "/home/user/projects/web" {
|
||||
t.Errorf("suggestion[0] = %+v", sugs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProviderErrorBody(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"insufficient quota"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewOpenAI(config.AIConfig{BaseURL: srv.URL, Model: "gpt-test"}, "k")
|
||||
if _, err := p.Suggest(context.Background(), sampleResult()); err == nil {
|
||||
t.Error("Suggest succeeded with API error body, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIProviderHTTPFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewOpenAI(config.AIConfig{BaseURL: srv.URL, Model: "gpt-test"}, "k")
|
||||
if _, err := p.Suggest(context.Background(), sampleResult()); err == nil {
|
||||
t.Error("Suggest succeeded with 500, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaProvider(t *testing.T) {
|
||||
var gotReq ollamaRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Path; got != "/api/chat" {
|
||||
t.Errorf("path = %q, want /api/chat", got)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, _ := json.Marshal(map[string]any{
|
||||
"message": map[string]any{"role": "assistant", "content": suggestionsJSON()},
|
||||
"error": "",
|
||||
})
|
||||
_, _ = w.Write(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewOllama(config.AIConfig{BaseURL: srv.URL, Model: "llama-test"})
|
||||
sugs, err := p.Suggest(context.Background(), sampleResult())
|
||||
if err != nil {
|
||||
t.Fatalf("Suggest: %v", err)
|
||||
}
|
||||
if gotReq.Model != "llama-test" || gotReq.Format != "json" || gotReq.Stream {
|
||||
t.Errorf("request malformed: %+v", gotReq)
|
||||
}
|
||||
if len(sugs) != 2 {
|
||||
t.Errorf("got %d suggestions, want 2", len(sugs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaProviderErrorField(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"error":"model not found"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewOllama(config.AIConfig{BaseURL: srv.URL})
|
||||
if _, err := p.Suggest(context.Background(), sampleResult()); err == nil {
|
||||
t.Error("Suggest succeeded with error field, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicProvider(t *testing.T) {
|
||||
var gotReq anthropicRequest
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("x-api-key"); got != "test-key" {
|
||||
t.Errorf("x-api-key = %q", got)
|
||||
}
|
||||
if got := r.Header.Get("anthropic-version"); got != anthropicVersion {
|
||||
t.Errorf("anthropic-version = %q", got)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, _ := json.Marshal(map[string]any{
|
||||
"content": []any{map[string]any{"type": "text", "text": suggestionsJSON()}},
|
||||
})
|
||||
_, _ = w.Write(resp)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewAnthropic(config.AIConfig{BaseURL: srv.URL, Model: "claude-test"}, "test-key")
|
||||
sugs, err := p.Suggest(context.Background(), sampleResult())
|
||||
if err != nil {
|
||||
t.Fatalf("Suggest: %v", err)
|
||||
}
|
||||
if gotReq.MaxTokens != 1024 || gotReq.System == "" {
|
||||
t.Errorf("request malformed: %+v", gotReq)
|
||||
}
|
||||
if len(sugs) != 2 {
|
||||
t.Errorf("got %d suggestions, want 2", len(sugs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSuggestions(t *testing.T) {
|
||||
plain := `[{"repo_path":"/a","action":"push","message":"m","command":"git push","priority":2}]`
|
||||
if got, err := parseSuggestions(plain); err != nil || len(got) != 1 {
|
||||
t.Errorf("plain: got %v, err %v", got, err)
|
||||
}
|
||||
|
||||
fenced := "Here you go:\n```json\n" + plain + "\n```\nHope that helps!"
|
||||
if got, err := parseSuggestions(fenced); err != nil || len(got) != 1 {
|
||||
t.Errorf("fenced: got %v, err %v", got, err)
|
||||
}
|
||||
|
||||
if got, err := parseSuggestions("[]"); err != nil || len(got) != 0 {
|
||||
t.Errorf("empty: got %v, err %v", got, err)
|
||||
}
|
||||
|
||||
if _, err := parseSuggestions("no json here"); err == nil {
|
||||
t.Error("garbage accepted, want error")
|
||||
}
|
||||
|
||||
bad := `[{"repo_path":` // truncated JSON
|
||||
if _, err := parseSuggestions(bad); err == nil {
|
||||
t.Error("truncated JSON accepted, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSuggestionsClampsPriority(t *testing.T) {
|
||||
out, err := parseSuggestions(`[{"priority":7},{"priority":-3},{"priority":1}]`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []int{2, 0, 1}
|
||||
for i, w := range want {
|
||||
if out[i].Priority != w {
|
||||
t.Errorf("suggestion[%d] priority = %d, want %d", i, out[i].Priority, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrompt(t *testing.T) {
|
||||
prompt := BuildPrompt(sampleResult())
|
||||
for _, want := range []string{
|
||||
`"/home/user/projects"`, "api", "web", "lib", "feat/login",
|
||||
"repo_path", "priority", "Return ONLY a JSON array",
|
||||
} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Errorf("prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(prompt, "\x00") {
|
||||
t.Error("prompt contains NUL bytes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderHonorsCancellation ensures a cancelled context fails fast
|
||||
// instead of waiting for the upstream.
|
||||
func TestProviderHonorsCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
p := NewOpenAI(config.AIConfig{BaseURL: "http://127.0.0.1:1"}, "k")
|
||||
if _, err := p.Suggest(ctx, sampleResult()); err == nil {
|
||||
t.Error("Suggest(cancelled ctx) succeeded, want error")
|
||||
}
|
||||
}
|
||||
82
internal/ai/anthropic.go
Normal file
82
internal/ai/anthropic.go
Normal file
@ -0,0 +1,82 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAnthropicBaseURL = "https://api.anthropic.com/v1"
|
||||
anthropicVersion = "2023-06-01"
|
||||
)
|
||||
|
||||
// AnthropicProvider talks to the Anthropic Messages API.
|
||||
type AnthropicProvider struct {
|
||||
model string
|
||||
baseURL string
|
||||
apiKey string
|
||||
client *client
|
||||
}
|
||||
|
||||
// NewAnthropic builds an Anthropic provider. base_url and model fall back
|
||||
// to sensible defaults when unset in the configuration.
|
||||
func NewAnthropic(cfg config.AIConfig, apiKey string) *AnthropicProvider {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = defaultAnthropicBaseURL
|
||||
}
|
||||
model := cfg.Model
|
||||
if model == "" {
|
||||
model = "claude-3-5-haiku-latest"
|
||||
}
|
||||
return &AnthropicProvider{model: model, baseURL: baseURL, apiKey: apiKey, client: newClient()}
|
||||
}
|
||||
|
||||
// Name implements Provider.
|
||||
func (p *AnthropicProvider) Name() string { return "anthropic" }
|
||||
|
||||
type anthropicRequest struct {
|
||||
Model string `json:"model"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
System string `json:"system"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type anthropicResponse struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Suggest implements Provider.
|
||||
func (p *AnthropicProvider) Suggest(ctx context.Context, result status.ScanResult) ([]Suggestion, error) {
|
||||
req := anthropicRequest{
|
||||
Model: p.model,
|
||||
MaxTokens: 1024,
|
||||
System: "You are gitflow, a concise Git repository health assistant. You reply with JSON only.",
|
||||
Messages: []chatMessage{{Role: "user", Content: BuildPrompt(result)}},
|
||||
}
|
||||
|
||||
var resp anthropicResponse
|
||||
err := p.client.postJSON(ctx, p.baseURL+"/messages",
|
||||
map[string]string{"x-api-key": p.apiKey, "anthropic-version": anthropicVersion}, req, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("ai: anthropic: %s", resp.Error.Message)
|
||||
}
|
||||
for _, c := range resp.Content {
|
||||
if c.Type == "text" && c.Text != "" {
|
||||
return parseSuggestions(c.Text)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("ai: anthropic: empty response")
|
||||
}
|
||||
56
internal/ai/client.go
Normal file
56
internal/ai/client.go
Normal file
@ -0,0 +1,56 @@
|
||||
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
|
||||
}
|
||||
58
internal/ai/execute.go
Normal file
58
internal/ai/execute.go
Normal file
@ -0,0 +1,58 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// safeActions lists the actions whose suggested commands may be executed
|
||||
// with explicit user confirmation. Anything else is treated as advisory
|
||||
// only, because the LLM output must never be able to run arbitrary
|
||||
// commands on the user's machine.
|
||||
var safeActions = map[string]bool{
|
||||
"commit": true,
|
||||
"push": true,
|
||||
"pull": true,
|
||||
"stash": true,
|
||||
"checkout": true,
|
||||
}
|
||||
|
||||
// ConfirmFunc asks the user whether to run a suggestion's command.
|
||||
type ConfirmFunc func(Suggestion) (bool, error)
|
||||
|
||||
// RunConfirmed runs the commands of confirmed suggestions in their
|
||||
// repository working directories. Suggestions whose action is not in the
|
||||
// allowlist are never executed. Cancellation stops the remaining
|
||||
// suggestions.
|
||||
func RunConfirmed(ctx context.Context, suggestions []Suggestion, confirm ConfirmFunc) error {
|
||||
for _, s := range suggestions {
|
||||
if s.Command == "" || !safeActions[s.Action] {
|
||||
continue
|
||||
}
|
||||
ok, err := confirm(s)
|
||||
if err != nil || !ok {
|
||||
continue
|
||||
}
|
||||
if err := runCommand(ctx, s); err != nil {
|
||||
return fmt.Errorf("ai: %s: %w", s.RepoPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCommand executes the suggestion's command inside its repository and
|
||||
// prints its output.
|
||||
func runCommand(ctx context.Context, s Suggestion) error {
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", s.Command)
|
||||
cmd.Dir = s.RepoPath
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("run %q: %w: %s", s.Command, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if text := strings.TrimSpace(string(out)); text != "" {
|
||||
fmt.Println(text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
73
internal/ai/execute_test.go
Normal file
73
internal/ai/execute_test.go
Normal file
@ -0,0 +1,73 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunConfirmedSkipsUnsafeActions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sugs := []Suggestion{
|
||||
{RepoPath: dir, Action: "rm_rf", Command: "echo unsafe"}, // not in allowlist
|
||||
{RepoPath: dir, Action: "push", Command: ""}, // no command
|
||||
}
|
||||
confirmed := 0
|
||||
err := RunConfirmed(context.Background(), sugs, func(s Suggestion) (bool, error) {
|
||||
confirmed++
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunConfirmed: %v", err)
|
||||
}
|
||||
if confirmed != 0 {
|
||||
t.Errorf("confirm called %d times, want 0 (all suggestions skipped)", confirmed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfirmedSkipsUnconfirmed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sugs := []Suggestion{
|
||||
{RepoPath: dir, Action: "pull", Command: "true"},
|
||||
}
|
||||
err := RunConfirmed(context.Background(), sugs, func(s Suggestion) (bool, error) {
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunConfirmed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfirmedExecutesConfirmed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "out.txt")
|
||||
sugs := []Suggestion{
|
||||
{RepoPath: dir, Action: "stash", Command: "printf ok > " + filepath.Base(target)},
|
||||
}
|
||||
err := RunConfirmed(context.Background(), sugs, func(s Suggestion) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunConfirmed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatalf("command did not run: %v", err)
|
||||
}
|
||||
if string(data) != "ok" {
|
||||
t.Errorf("output = %q, want ok", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConfirmedReturnsCommandError(t *testing.T) {
|
||||
sugs := []Suggestion{
|
||||
{RepoPath: t.TempDir(), Action: "pull", Command: "exit 3"},
|
||||
}
|
||||
err := RunConfirmed(context.Background(), sugs, func(s Suggestion) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("RunConfirmed succeeded for failing command, want error")
|
||||
}
|
||||
}
|
||||
69
internal/ai/ollama.go
Normal file
69
internal/ai/ollama.go
Normal file
@ -0,0 +1,69 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
const defaultOllamaBaseURL = "http://localhost:11434"
|
||||
|
||||
// OllamaProvider talks to a local Ollama server. No API key is needed.
|
||||
type OllamaProvider struct {
|
||||
model string
|
||||
baseURL string
|
||||
client *client
|
||||
}
|
||||
|
||||
// NewOllama builds an Ollama provider. base_url and model fall back to
|
||||
// sensible defaults when unset in the configuration.
|
||||
func NewOllama(cfg config.AIConfig) *OllamaProvider {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = defaultOllamaBaseURL
|
||||
}
|
||||
model := cfg.Model
|
||||
if model == "" {
|
||||
model = "llama3.2"
|
||||
}
|
||||
return &OllamaProvider{model: model, baseURL: baseURL, client: newClient()}
|
||||
}
|
||||
|
||||
// Name implements Provider.
|
||||
func (p *OllamaProvider) Name() string { return "ollama" }
|
||||
|
||||
type ollamaRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Format string `json:"format"` // "json" forces structured output
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type ollamaResponse struct {
|
||||
Message chatMessage `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// Suggest implements Provider.
|
||||
func (p *OllamaProvider) Suggest(ctx context.Context, result status.ScanResult) ([]Suggestion, error) {
|
||||
req := ollamaRequest{
|
||||
Model: p.model,
|
||||
Messages: []chatMessage{
|
||||
{Role: "system", Content: "You are gitflow, a concise Git repository health assistant. You reply with JSON only."},
|
||||
{Role: "user", Content: BuildPrompt(result)},
|
||||
},
|
||||
Format: "json",
|
||||
Stream: false,
|
||||
}
|
||||
|
||||
var resp ollamaResponse
|
||||
if err := p.client.postJSON(ctx, p.baseURL+"/api/chat", nil, req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return nil, fmt.Errorf("ai: ollama: %s", resp.Error)
|
||||
}
|
||||
return parseSuggestions(resp.Message.Content)
|
||||
}
|
||||
87
internal/ai/openai.go
Normal file
87
internal/ai/openai.go
Normal file
@ -0,0 +1,87 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
const defaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
|
||||
// OpenAIProvider talks to the OpenAI Chat Completions API.
|
||||
type OpenAIProvider struct {
|
||||
model string
|
||||
baseURL string
|
||||
apiKey string
|
||||
client *client
|
||||
}
|
||||
|
||||
// NewOpenAI builds an OpenAI provider. base_url and model fall back to
|
||||
// sensible defaults when unset in the configuration.
|
||||
func NewOpenAI(cfg config.AIConfig, apiKey string) *OpenAIProvider {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = defaultOpenAIBaseURL
|
||||
}
|
||||
model := cfg.Model
|
||||
if model == "" {
|
||||
model = "gpt-4o"
|
||||
}
|
||||
return &OpenAIProvider{model: model, baseURL: baseURL, apiKey: apiKey, client: newClient()}
|
||||
}
|
||||
|
||||
// Name implements Provider.
|
||||
func (p *OpenAIProvider) Name() string { return "openai" }
|
||||
|
||||
// chatMessage is a single chat turn, shared with the other providers.
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
ResponseFormat *responseFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type responseFormat struct {
|
||||
Type string `json:"type"` // "json_object"
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Choices []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// Suggest implements Provider.
|
||||
func (p *OpenAIProvider) Suggest(ctx context.Context, result status.ScanResult) ([]Suggestion, error) {
|
||||
req := chatRequest{
|
||||
Model: p.model,
|
||||
Messages: []chatMessage{
|
||||
{Role: "system", Content: "You are gitflow, a concise Git repository health assistant. You reply with JSON only."},
|
||||
{Role: "user", Content: BuildPrompt(result)},
|
||||
},
|
||||
ResponseFormat: &responseFormat{Type: "json_object"},
|
||||
}
|
||||
|
||||
var resp chatResponse
|
||||
err := p.client.postJSON(ctx, p.baseURL+"/chat/completions",
|
||||
map[string]string{"Authorization": "Bearer " + p.apiKey}, req, &resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return nil, fmt.Errorf("ai: openai: %s", resp.Error.Message)
|
||||
}
|
||||
if len(resp.Choices) == 0 {
|
||||
return nil, fmt.Errorf("ai: openai: empty response")
|
||||
}
|
||||
return parseSuggestions(resp.Choices[0].Message.Content)
|
||||
}
|
||||
66
internal/ai/parse.go
Normal file
66
internal/ai/parse.go
Normal file
@ -0,0 +1,66 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseSuggestions extracts []Suggestion from an LLM text reply, tolerating
|
||||
// ```json fences and surrounding prose. Priorities are clamped to 0..2 and
|
||||
// the result is capped to a sane number of items.
|
||||
func parseSuggestions(reply string) ([]Suggestion, error) {
|
||||
text := stripFences(strings.TrimSpace(reply))
|
||||
start := strings.IndexByte(text, '[')
|
||||
end := strings.LastIndexByte(text, ']')
|
||||
if start == -1 || end == -1 || end <= start {
|
||||
return nil, fmt.Errorf("ai: no JSON array in reply: %s", truncate(reply, 200))
|
||||
}
|
||||
|
||||
var out []Suggestion
|
||||
if err := json.Unmarshal([]byte(text[start:end+1]), &out); err != nil {
|
||||
return nil, fmt.Errorf("ai: parse suggestions: %w", err)
|
||||
}
|
||||
if len(out) > 50 {
|
||||
out = out[:50]
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].Priority < 0 {
|
||||
out[i].Priority = 0
|
||||
}
|
||||
if out[i].Priority > 2 {
|
||||
out[i].Priority = 2
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stripFences removes ```...``` code fences so fenced JSON parses cleanly.
|
||||
func stripFences(s string) string {
|
||||
if !strings.Contains(s, "```") {
|
||||
return s
|
||||
}
|
||||
lines := strings.Split(s, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
inFence := false
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "```") {
|
||||
inFence = !inFence
|
||||
continue
|
||||
}
|
||||
if inFence {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return s // fences never closed; fall back to raw content
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
69
internal/ai/prompt.go
Normal file
69
internal/ai/prompt.go
Normal file
@ -0,0 +1,69 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
// BuildPrompt renders a scan result as an LLM instruction: a repository
|
||||
// status table plus strict output rules for the suggestion schema.
|
||||
func BuildPrompt(result status.ScanResult) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "You are gitflow, a Git repository health assistant. "+
|
||||
"A scan of %q at %s found %d repositories.\n\n",
|
||||
result.ParentDir, result.ScannedAt.Format(time.RFC3339), len(result.Repos))
|
||||
|
||||
b.WriteString("Repository status table:\n")
|
||||
b.WriteString("| name | status | branch | ahead | behind | changes |\n")
|
||||
b.WriteString("|---|---|---|---|---|---|\n")
|
||||
for _, r := range result.Repos {
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %d | %d | %s |\n",
|
||||
r.Name, r.Status, branchLabel(r), r.AheadBy, r.BehindBy, changeSummary(r))
|
||||
}
|
||||
|
||||
attention := result.NeedsAttention()
|
||||
fmt.Fprintf(&b, "\n%d of %d repositories need attention.\n\n", len(attention), len(result.Repos))
|
||||
|
||||
b.WriteString(`Return ONLY a JSON array of suggestions. Each suggestion has exactly these fields:
|
||||
- "repo_path": absolute path of the repository
|
||||
- "action": one of "commit", "push", "pull", "stash", "create_pr", "cleanup", "inspect"
|
||||
- "message": one short sentence explaining the next step
|
||||
- "command": a concrete git command to run, or "" if none is safe
|
||||
- "priority": 0 (low), 1 (medium), or 2 (high)
|
||||
|
||||
Rules:
|
||||
- Only suggest actions for repositories that need attention.
|
||||
- Prefer the smallest safe step; never suggest destructive commands.
|
||||
- Do not invent repositories that are not in the table.
|
||||
- If nothing needs attention, return [].
|
||||
`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func branchLabel(r status.RepoInfo) string {
|
||||
if r.Branch == "" {
|
||||
return "(none)"
|
||||
}
|
||||
return r.Branch
|
||||
}
|
||||
|
||||
// changeSummary renders staged/modified/untracked counts for the prompt.
|
||||
func changeSummary(r status.RepoInfo) string {
|
||||
parts := make([]string, 0, 3)
|
||||
if n := len(r.StagedFiles); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d staged", n))
|
||||
}
|
||||
if n := len(r.ModifiedFiles); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d modified", n))
|
||||
}
|
||||
if n := len(r.UntrackedFiles); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d untracked", n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "none"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@ -20,10 +20,11 @@ import (
|
||||
// AIConfig holds AI agent settings.
|
||||
type AIConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Provider string `yaml:"provider"` // openai or ollama
|
||||
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.
|
||||
@ -51,6 +52,7 @@ var flagKeys = []struct{ flag, key string }{
|
||||
{"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
|
||||
@ -64,8 +66,9 @@ func RegisterFlags(f *pflag.FlagSet) {
|
||||
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 or ollama")
|
||||
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.
|
||||
@ -104,6 +107,7 @@ func Load(flags *pflag.FlagSet) (*Config, error) {
|
||||
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 {
|
||||
@ -125,6 +129,7 @@ func applyDefaults(v *viper.Viper) {
|
||||
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
|
||||
@ -180,8 +185,12 @@ func (c *Config) Validate() error {
|
||||
if c.Workers < 1 {
|
||||
return errors.New("config: workers must be at least 1")
|
||||
}
|
||||
if c.AI.Enabled && c.AI.Provider == "" {
|
||||
return errors.New("config: ai provider must not be empty")
|
||||
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
|
||||
}
|
||||
@ -204,6 +213,7 @@ func (c *Config) Dump() (string, error) {
|
||||
"model": c.AI.Model,
|
||||
"api_key_env": c.AI.APIKeyEnv,
|
||||
"base_url": c.AI.BaseURL,
|
||||
"execute": c.AI.Execute,
|
||||
},
|
||||
}
|
||||
out, err := yaml.Marshal(v)
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
@ -145,3 +146,33 @@ func TestForRejectsUnknownFormat(t *testing.T) {
|
||||
t.Error("For(xml) succeeded, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestions(t *testing.T) {
|
||||
sugs := []ai.Suggestion{
|
||||
{RepoPath: "/home/user/projects/web", Action: "commit", Message: "commit your work", Command: "git add -A && git commit -m wip", Priority: 2},
|
||||
{RepoPath: "/home/user/projects/lib", Action: "pull", Message: "pull latest", Command: "git pull", Priority: 1},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := Suggestions(&buf, Options{Color: ColorNever}, sugs); err != nil {
|
||||
t.Fatalf("Suggestions: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{"AI SUGGESTIONS", "commit", "pull", "high", "medium", "git pull"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("suggestions output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "\x1b[") {
|
||||
t.Errorf("suggestions contain escape codes with ColorNever")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestionsEmpty(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := Suggestions(&buf, Options{}, nil); err != nil {
|
||||
t.Fatalf("Suggestions: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "no suggestions") {
|
||||
t.Errorf("empty suggestions message missing: %q", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
48
internal/presenter/suggestions.go
Normal file
48
internal/presenter/suggestions.go
Normal file
@ -0,0 +1,48 @@
|
||||
package presenter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
|
||||
)
|
||||
|
||||
// Suggestions renders AI suggestions below a scan result, color-coded by
|
||||
// priority.
|
||||
func Suggestions(w io.Writer, opts Options, suggestions []ai.Suggestion) error {
|
||||
if len(suggestions) == 0 {
|
||||
fmt.Fprintln(w, "\nAI: no suggestions — all repositories are healthy.")
|
||||
return nil
|
||||
}
|
||||
|
||||
r := newRenderer(opts.Color, w)
|
||||
fmt.Fprintln(w, "\nAI SUGGESTIONS")
|
||||
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "REPOSITORY\tACTION\tPRIORITY\tMESSAGE\tCOMMAND")
|
||||
for _, s := range suggestions {
|
||||
prio := priorityLabel(s.Priority)
|
||||
switch s.Priority {
|
||||
case 2:
|
||||
prio = r.red + prio + r.reset
|
||||
case 1:
|
||||
prio = r.yellow + prio + r.reset
|
||||
case 0:
|
||||
prio = r.green + prio + r.reset
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
shortPath(s.RepoPath), s.Action, prio, s.Message, s.Command)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func priorityLabel(p int) string {
|
||||
switch {
|
||||
case p >= 2:
|
||||
return "high"
|
||||
case p == 1:
|
||||
return "medium"
|
||||
default:
|
||||
return "low"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user