gitFlow/internal/ai/ai_test.go
dimitar aee63bf321 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.
2026-08-02 08:48:27 +02:00

267 lines
8.6 KiB
Go

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")
}
}