summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-28 10:43:42 +0300
committerPaul Buetow <paul@buetow.org>2026-05-28 10:43:42 +0300
commitbff9775a92d653d74e575fd6fc1398ca4505ea30 (patch)
tree8985d735aa98e0c86b4fe0a8bab9b8fbe99de550
parentcda151ec9654fd8b4d043668da21f909477f5e9c (diff)
refactor(aitool): extract shared AI tool dispatch chain (cq)
-rw-r--r--internal/aitool/dispatch.go76
-rw-r--r--internal/aitool/dispatch_test.go113
-rw-r--r--internal/release/release.go92
-rw-r--r--internal/showcase/showcase.go43
4 files changed, 230 insertions, 94 deletions
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 {