merge: phase 5 AI integration into main

This commit is contained in:
dimitar 2026-08-02 08:48:30 +02:00
commit fe310188ac
16 changed files with 1044 additions and 5 deletions

View File

@ -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
}
}

View File

@ -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{

View File

@ -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
View 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
View 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
View 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
View 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
View 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
}

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

View File

@ -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)

View File

@ -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())
}
}

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