From bff9775a92d653d74e575fd6fc1398ca4505ea30 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 28 May 2026 10:43:42 +0300 Subject: refactor(aitool): extract shared AI tool dispatch chain (cq) --- internal/aitool/dispatch.go | 76 ++++++++++++++++++++++++++ internal/aitool/dispatch_test.go | 113 +++++++++++++++++++++++++++++++++++++++ internal/release/release.go | 92 ++++++++++++++----------------- internal/showcase/showcase.go | 43 +-------------- 4 files changed, 230 insertions(+), 94 deletions(-) create mode 100644 internal/aitool/dispatch.go create mode 100644 internal/aitool/dispatch_test.go diff --git a/internal/aitool/dispatch.go b/internal/aitool/dispatch.go new file mode 100644 index 0000000..0924c8b --- /dev/null +++ b/internal/aitool/dispatch.go @@ -0,0 +1,76 @@ +package aitool + +import "os/exec" + +type Tool string + +const ( + ToolOpencode Tool = "opencode" + ToolHexAI Tool = "hexai" + ToolClaude Tool = "claude" + ToolAmp Tool = "amp" +) + +type LookPathFunc func(file string) (string, error) + +func Chain(preferred string) []Tool { + switch preferred { + case "", string(ToolOpencode): + return []Tool{ToolOpencode, ToolHexAI, ToolClaude, ToolAmp} + case string(ToolHexAI): + return []Tool{ToolHexAI, ToolClaude, ToolAmp} + case string(ToolClaude), "claude-code": + return []Tool{ToolClaude, ToolAmp} + case string(ToolAmp): + return []Tool{ToolAmp} + default: + return nil + } +} + +func FirstAvailable(preferred string, lookPath LookPathFunc) Tool { + for _, tool := range Chain(preferred) { + if IsAvailable(tool, lookPath) { + return tool + } + } + + return "" +} + +func AvailableChain(preferred string, lookPath LookPathFunc) []Tool { + chain := Chain(preferred) + available := make([]Tool, 0, len(chain)) + for _, tool := range chain { + if IsAvailable(tool, lookPath) { + available = append(available, tool) + } + } + + return available +} + +func IsAvailable(tool Tool, lookPath LookPathFunc) bool { + if lookPath == nil { + lookPath = exec.LookPath + } + + binary, ok := availabilityBinary(tool) + if !ok { + return false + } + + _, err := lookPath(binary) + return err == nil +} + +func availabilityBinary(tool Tool) (string, bool) { + switch tool { + case ToolOpencode: + return "ollama", true + case ToolHexAI, ToolClaude, ToolAmp: + return string(tool), true + default: + return "", false + } +} diff --git a/internal/aitool/dispatch_test.go b/internal/aitool/dispatch_test.go new file mode 100644 index 0000000..6842668 --- /dev/null +++ b/internal/aitool/dispatch_test.go @@ -0,0 +1,113 @@ +package aitool + +import ( + "fmt" + "reflect" + "testing" +) + +func TestChain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + preferred string + want []Tool + }{ + { + name: "default chain when empty", + want: []Tool{ToolOpencode, ToolHexAI, ToolClaude, ToolAmp}, + }, + { + name: "default chain when opencode", + preferred: "opencode", + want: []Tool{ToolOpencode, ToolHexAI, ToolClaude, ToolAmp}, + }, + { + name: "hexai chain", + preferred: "hexai", + want: []Tool{ToolHexAI, ToolClaude, ToolAmp}, + }, + { + name: "claude alias chain", + preferred: "claude-code", + want: []Tool{ToolClaude, ToolAmp}, + }, + { + name: "amp only", + preferred: "amp", + want: []Tool{ToolAmp}, + }, + { + name: "unknown tool", + preferred: "unknown", + want: nil, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := Chain(tt.preferred) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("Chain(%q) = %#v, want %#v", tt.preferred, got, tt.want) + } + }) + } +} + +func TestFirstAvailable(t *testing.T) { + t.Parallel() + + lookPath := fakeLookPath("claude", "amp") + got := FirstAvailable("", lookPath) + if got != ToolClaude { + t.Fatalf("FirstAvailable() = %q, want %q", got, ToolClaude) + } +} + +func TestFirstAvailable_NoToolsFound(t *testing.T) { + t.Parallel() + + got := FirstAvailable("", fakeLookPath()) + if got != "" { + t.Fatalf("FirstAvailable() = %q, want empty", got) + } +} + +func TestIsAvailable_OpencodeUsesOllamaBinary(t *testing.T) { + t.Parallel() + + if !IsAvailable(ToolOpencode, fakeLookPath("ollama")) { + t.Fatal("expected opencode to be available when ollama exists") + } + + if IsAvailable(ToolOpencode, fakeLookPath("opencode")) { + t.Fatal("expected opencode to be unavailable when only opencode binary exists") + } +} + +func TestAvailableChain_FiltersUnavailableTools(t *testing.T) { + t.Parallel() + + got := AvailableChain("", fakeLookPath("hexai", "amp")) + want := []Tool{ToolHexAI, ToolAmp} + if !reflect.DeepEqual(got, want) { + t.Fatalf("AvailableChain() = %#v, want %#v", got, want) + } +} + +func fakeLookPath(tools ...string) LookPathFunc { + available := make(map[string]struct{}, len(tools)) + for _, tool := range tools { + available[tool] = struct{}{} + } + + return func(file string) (string, error) { + if _, ok := available[file]; ok { + return "/usr/bin/" + file, nil + } + return "", fmt.Errorf("%s not found", file) + } +} diff --git a/internal/release/release.go b/internal/release/release.go index da2c221..d0bc3cf 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -11,6 +11,7 @@ import ( "sort" "strings" + "codeberg.org/snonux/gitsyncer/internal/aitool" "codeberg.org/snonux/gitsyncer/internal/httpclient" "codeberg.org/snonux/gitsyncer/internal/version" ) @@ -411,38 +412,29 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags fmt.Printf(" Prompt includes: %d commits, %.1fKB of code changes\n", len(commits), float64(len(diff))/1024) fmt.Printf(" Total prompt length: %d characters\n", len(instr.String())+len(input.String())) - // Determine which AI tool to use (default to opencode if not set) - aiTool := m.aiTool - if aiTool == "" { - aiTool = "opencode" - } - // Build a full prompt string for tools that read a single argument fullPrompt := instr.String() + "\n\n" + input.String() var releaseNotes string - // 0) Try opencode first (glm-5.1:cloud via ollama launch) - if _, err := exec.LookPath("ollama"); err == nil { - fmt.Println(" Running ollama launch opencode ...") - cmd := exec.Command("ollama", "launch", "opencode", "--model", "glm-5.1:cloud", "-y", "--", "run", fullPrompt) - cmd.Stderr = os.Stderr - out, err := cmd.Output() - if err != nil { - fmt.Printf("opencode ollama failed: %v\n", err) - } else { + for _, tool := range aitool.AvailableChain("opencode", nil) { + switch tool { + case aitool.ToolOpencode: + fmt.Println(" Running ollama launch opencode ...") + cmd := exec.Command("ollama", "launch", "opencode", "--model", "glm-5.1:cloud", "-y", "--", "run", fullPrompt) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + if err != nil { + fmt.Printf("opencode ollama failed: %v\n", err) + continue + } notes := strings.TrimSpace(string(out)) if notes == "" { fmt.Println(" ollama opencode returned empty output; will try fallbacks...") - } else { - releaseNotes = notes + continue } - } - } - - // 1) Try hexai as fallback - if releaseNotes == "" { - if _, err := exec.LookPath("hexai"); err == nil { + releaseNotes = notes + case aitool.ToolHexAI: fmt.Println(" Running hexai CLI command (stdin payload)...") cmd := exec.Command("hexai", instr.String()) cmd.Stdin = strings.NewReader(input.String()) @@ -450,36 +442,26 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags out, err := cmd.Output() if err != nil { fmt.Printf(" hexai CLI failed: %v\n", err) - } else { - notes := strings.TrimSpace(string(out)) - if notes == "" { - fmt.Println(" hexai returned empty output; will try fallbacks...") - } else { - releaseNotes = notes - } + continue } - } - } - - // 2) Try claude as fallback - if releaseNotes == "" { - if _, err := exec.LookPath("claude"); err == nil { + notes := strings.TrimSpace(string(out)) + if notes == "" { + fmt.Println(" hexai returned empty output; will try fallbacks...") + continue + } + releaseNotes = notes + case aitool.ToolClaude: fmt.Println(" Running claude CLI command...") cmd := exec.Command("claude", "--model", "sonnet", fullPrompt) cmd.Env = append(os.Environ(), "CLAUDE_DEBUG=1") - notes, err := m.executeAICommand(cmd, "claude") + notes, err := m.executeAICommand(cmd, string(tool)) if err != nil { fmt.Printf(" Claude CLI failed: %v\n", err) - } else { - releaseNotes = notes + continue } - } - } - - // 3) Try amp as fallback: echo input to stdin and pass instructions as argument - // Note: print stderr to console, but only use stdout for notes - if releaseNotes == "" { - if _, err := exec.LookPath("amp"); err == nil { + releaseNotes = notes + case aitool.ToolAmp: + // Note: print stderr to console, but only use stdout for notes fmt.Println(" Running amp CLI command (stdin payload)...") cmd := exec.Command("amp", "--execute", instr.String()) cmd.Stdin = strings.NewReader(input.String()) @@ -487,14 +469,18 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags out, err := cmd.Output() if err != nil { fmt.Printf(" amp CLI failed: %v\n", err) - } else { - notes := strings.TrimSpace(string(out)) - if notes == "" { - fmt.Println(" amp returned empty output; will try fallbacks...") - } else { - releaseNotes = notes - } + continue + } + notes := strings.TrimSpace(string(out)) + if notes == "" { + fmt.Println(" amp returned empty output; will try fallbacks...") + continue } + releaseNotes = notes + } + + if releaseNotes != "" { + break } } diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go index 4e8d637..b9e1fe1 100644 --- a/internal/showcase/showcase.go +++ b/internal/showcase/showcase.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "codeberg.org/snonux/gitsyncer/internal/aitool" "codeberg.org/snonux/gitsyncer/internal/config" ) @@ -230,47 +231,7 @@ func findReadmeContent(repoPath string) ([]byte, string, bool) { } func selectSummaryTool(aiTool string) string { - switch aiTool { - case "opencode", "": - // Default chain: opencode (via ollama launch) → hexai → claude → amp - if _, err := exec.LookPath("ollama"); err == nil { - return "opencode" - } - if _, err := exec.LookPath("hexai"); err == nil { - return "hexai" - } - if _, err := exec.LookPath("claude"); err == nil { - return "claude" - } - if _, err := exec.LookPath("amp"); err == nil { - return "amp" - } - case "hexai": - // Explicit hexai: hexai → claude → amp - if _, err := exec.LookPath("hexai"); err == nil { - return "hexai" - } - if _, err := exec.LookPath("claude"); err == nil { - return "claude" - } - if _, err := exec.LookPath("amp"); err == nil { - return "amp" - } - case "claude", "claude-code": - // Explicit claude: claude → amp - if _, err := exec.LookPath("claude"); err == nil { - return "claude" - } - if _, err := exec.LookPath("amp"); err == nil { - return "amp" - } - case "amp": - if _, err := exec.LookPath(aiTool); err == nil { - return aiTool - } - } - - return "" + return string(aitool.FirstAvailable(aiTool, nil)) } func runSummaryTool(selectedTool, prompt, repoPath, readmeFile string, readmeContent []byte, readmeFound bool) string { -- cgit v1.2.3