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
|
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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newScanCmd runs a single scan pass and renders the result.
|
// 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)}
|
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())
|
config.RegisterFlags(cmd.Flags())
|
||||||
return cmd
|
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.
|
// newConfigCmd prints the effective configuration.
|
||||||
func newConfigCmd() *cobra.Command {
|
func newConfigCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|||||||
@ -57,6 +57,11 @@ func newWatchCmd() *cobra.Command {
|
|||||||
return presenter.Present(os.Stdout, cfg.Format, result, opts)
|
return presenter.Present(os.Stdout, cfg.Format, result, opts)
|
||||||
}
|
}
|
||||||
renderWatchFrame(result, prev, first, cfg, 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
|
prev = result
|
||||||
first = false
|
first = false
|
||||||
return nil
|
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.
|
// AIConfig holds AI agent settings.
|
||||||
type AIConfig struct {
|
type AIConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
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
|
Model string `yaml:"model"` // model name; empty lets the provider choose
|
||||||
APIKeyEnv string `yaml:"api_key_env"` // env var holding the API key
|
APIKeyEnv string `yaml:"api_key_env"` // env var holding the API key
|
||||||
BaseURL string `yaml:"base_url"` // provider endpoint override
|
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.
|
// Config is the fully resolved runtime configuration.
|
||||||
@ -51,6 +52,7 @@ var flagKeys = []struct{ flag, key string }{
|
|||||||
{"ai", "ai.enabled"},
|
{"ai", "ai.enabled"},
|
||||||
{"ai-provider", "ai.provider"},
|
{"ai-provider", "ai.provider"},
|
||||||
{"ai-model", "ai.model"},
|
{"ai-model", "ai.model"},
|
||||||
|
{"ai-execute", "ai.execute"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterFlags defines every gitflow flag on f. Call Load with the same
|
// 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("max-depth", 0, "maximum directory depth to scan (0 = unlimited)")
|
||||||
f.Int("workers", 8, "number of concurrent git scans")
|
f.Int("workers", 8, "number of concurrent git scans")
|
||||||
f.Bool("ai", false, "enable AI suggestions")
|
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.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.
|
// 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"),
|
Model: v.GetString("ai.model"),
|
||||||
APIKeyEnv: v.GetString("ai.api_key_env"),
|
APIKeyEnv: v.GetString("ai.api_key_env"),
|
||||||
BaseURL: v.GetString("ai.base_url"),
|
BaseURL: v.GetString("ai.base_url"),
|
||||||
|
Execute: v.GetBool("ai.execute"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
@ -125,6 +129,7 @@ func applyDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("ai.model", "gpt-4o")
|
v.SetDefault("ai.model", "gpt-4o")
|
||||||
v.SetDefault("ai.api_key_env", "OPENAI_API_KEY")
|
v.SetDefault("ai.api_key_env", "OPENAI_API_KEY")
|
||||||
v.SetDefault("ai.base_url", "")
|
v.SetDefault("ai.base_url", "")
|
||||||
|
v.SetDefault("ai.execute", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readConfigFile loads ~/.gitflow.yaml (or $GITFLOW_CONFIG when set). A
|
// readConfigFile loads ~/.gitflow.yaml (or $GITFLOW_CONFIG when set). A
|
||||||
@ -180,8 +185,12 @@ func (c *Config) Validate() error {
|
|||||||
if c.Workers < 1 {
|
if c.Workers < 1 {
|
||||||
return errors.New("config: workers must be at least 1")
|
return errors.New("config: workers must be at least 1")
|
||||||
}
|
}
|
||||||
if c.AI.Enabled && c.AI.Provider == "" {
|
if c.AI.Enabled {
|
||||||
return errors.New("config: ai provider must not be empty")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@ -204,6 +213,7 @@ func (c *Config) Dump() (string, error) {
|
|||||||
"model": c.AI.Model,
|
"model": c.AI.Model,
|
||||||
"api_key_env": c.AI.APIKeyEnv,
|
"api_key_env": c.AI.APIKeyEnv,
|
||||||
"base_url": c.AI.BaseURL,
|
"base_url": c.AI.BaseURL,
|
||||||
|
"execute": c.AI.Execute,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
out, err := yaml.Marshal(v)
|
out, err := yaml.Marshal(v)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -145,3 +146,33 @@ func TestForRejectsUnknownFormat(t *testing.T) {
|
|||||||
t.Error("For(xml) succeeded, want error")
|
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