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.
267 lines
8.6 KiB
Go
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")
|
|
}
|
|
}
|