From aee63bf32107eb3dd7f84cad528519d4cc421153 Mon Sep 17 00:00:00 2001 From: dimitar Date: Sun, 2 Aug 2026 08:48:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20phase=205=20=E2=80=94=20AI=20agent=20in?= =?UTF-8?q?tegration=20(OpenAI,=20Ollama,=20Anthropic)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/gitflow/prompt.go | 23 +++ cmd/gitflow/scan.go | 44 ++++- cmd/gitflow/watch.go | 5 + internal/ai/ai.go | 54 ++++++ internal/ai/ai_test.go | 266 +++++++++++++++++++++++++++ internal/ai/anthropic.go | 82 +++++++++ internal/ai/client.go | 56 ++++++ internal/ai/execute.go | 58 ++++++ internal/ai/execute_test.go | 73 ++++++++ internal/ai/ollama.go | 69 +++++++ internal/ai/openai.go | 87 +++++++++ internal/ai/parse.go | 66 +++++++ internal/ai/prompt.go | 69 +++++++ internal/config/config.go | 18 +- internal/presenter/presenter_test.go | 31 ++++ internal/presenter/suggestions.go | 48 +++++ 16 files changed, 1044 insertions(+), 5 deletions(-) create mode 100644 internal/ai/ai.go create mode 100644 internal/ai/ai_test.go create mode 100644 internal/ai/anthropic.go create mode 100644 internal/ai/client.go create mode 100644 internal/ai/execute.go create mode 100644 internal/ai/execute_test.go create mode 100644 internal/ai/ollama.go create mode 100644 internal/ai/openai.go create mode 100644 internal/ai/parse.go create mode 100644 internal/ai/prompt.go create mode 100644 internal/presenter/suggestions.go diff --git a/cmd/gitflow/prompt.go b/cmd/gitflow/prompt.go index 97aa89e..ad5531c 100644 --- a/cmd/gitflow/prompt.go +++ b/cmd/gitflow/prompt.go @@ -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 + } +} diff --git a/cmd/gitflow/scan.go b/cmd/gitflow/scan.go index 867c5bf..dc1cedd 100644 --- a/cmd/gitflow/scan.go +++ b/cmd/gitflow/scan.go @@ -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{ diff --git a/cmd/gitflow/watch.go b/cmd/gitflow/watch.go index bf98197..ab770f3 100644 --- a/cmd/gitflow/watch.go +++ b/cmd/gitflow/watch.go @@ -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 diff --git a/internal/ai/ai.go b/internal/ai/ai.go new file mode 100644 index 0000000..de1e74e --- /dev/null +++ b/internal/ai/ai.go @@ -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) + } +} diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go new file mode 100644 index 0000000..8eccc3b --- /dev/null +++ b/internal/ai/ai_test.go @@ -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") + } +} diff --git a/internal/ai/anthropic.go b/internal/ai/anthropic.go new file mode 100644 index 0000000..fc25d39 --- /dev/null +++ b/internal/ai/anthropic.go @@ -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") +} diff --git a/internal/ai/client.go b/internal/ai/client.go new file mode 100644 index 0000000..47635bc --- /dev/null +++ b/internal/ai/client.go @@ -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 +} diff --git a/internal/ai/execute.go b/internal/ai/execute.go new file mode 100644 index 0000000..68ac2ae --- /dev/null +++ b/internal/ai/execute.go @@ -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 +} diff --git a/internal/ai/execute_test.go b/internal/ai/execute_test.go new file mode 100644 index 0000000..6b77569 --- /dev/null +++ b/internal/ai/execute_test.go @@ -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") + } +} diff --git a/internal/ai/ollama.go b/internal/ai/ollama.go new file mode 100644 index 0000000..8362f9a --- /dev/null +++ b/internal/ai/ollama.go @@ -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) +} diff --git a/internal/ai/openai.go b/internal/ai/openai.go new file mode 100644 index 0000000..e1f3175 --- /dev/null +++ b/internal/ai/openai.go @@ -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) +} diff --git a/internal/ai/parse.go b/internal/ai/parse.go new file mode 100644 index 0000000..db55212 --- /dev/null +++ b/internal/ai/parse.go @@ -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] + "..." +} diff --git a/internal/ai/prompt.go b/internal/ai/prompt.go new file mode 100644 index 0000000..5736162 --- /dev/null +++ b/internal/ai/prompt.go @@ -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, ", ") +} diff --git a/internal/config/config.go b/internal/config/config.go index 75b997e..6cffdb5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/presenter/presenter_test.go b/internal/presenter/presenter_test.go index 38f0158..256c38e 100644 --- a/internal/presenter/presenter_test.go +++ b/internal/presenter/presenter_test.go @@ -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()) + } +} diff --git a/internal/presenter/suggestions.go b/internal/presenter/suggestions.go new file mode 100644 index 0000000..d904c21 --- /dev/null +++ b/internal/presenter/suggestions.go @@ -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" + } +}