summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-17 09:18:18 +0300
committerPaul Buetow <paul@buetow.org>2025-08-17 09:18:18 +0300
commita8db7af2a094a16393f0060e628310d4161b154f (patch)
treee1240767a26261bed8a0bcbbdb31850ad8b38d4e
parent9603960eeea6b06c5184850c2c2af7d257c77fdd (diff)
feat(release): hexai-first AI release notes; chore(version): bump to 0.8.8v0.8.8
- Prefer hexai stdin pipeline for release notes generation - Fallback to Claude then aichat - Update CLI help and README - Adjust integration tests for current CLI
-rw-r--r--README.md7
-rw-r--r--internal/cmd/release.go13
-rw-r--r--internal/cmd/showcase.go4
-rw-r--r--internal/cmd/sync.go4
-rw-r--r--internal/release/release.go108
-rw-r--r--internal/version/version.go2
-rwxr-xr-xtest/run_integration_tests.sh28
m---------test/work2/test-repo0
m---------test/work3/test-repo0
-rw-r--r--tmp-config.json11
m---------tmp-work/fapi0
11 files changed, 109 insertions, 68 deletions
diff --git a/README.md b/README.md
index f56554b..9feb08d 100644
--- a/README.md
+++ b/README.md
@@ -177,6 +177,13 @@ gitsyncer release create myproject --no-ai-notes
gitsyncer release create --ai-tool aichat
```
+#### AI Release Notes Engines
+
+- Default flow: tries `hexai` first by piping the generated commit/diff payload to stdin and passing an instruction prompt as the sole argument (equivalent to `cat SOMETEXT.txt | hexai PROMPT`).
+- Fallback: if `hexai` is not available or fails, falls back to `claude --model sonnet`, then to `aichat`.
+- Explicit tool: `--ai-tool claude` or `--ai-tool aichat` influences the fallback preference, but `hexai` is still attempted first when available.
+- Requirements: ensure `hexai`, `claude`, or `aichat` are installed and available in `PATH`.
+
### Project Showcase
```bash
diff --git a/internal/cmd/release.go b/internal/cmd/release.go
index f9fa04f..9f4e713 100644
--- a/internal/cmd/release.go
+++ b/internal/cmd/release.go
@@ -16,10 +16,11 @@ var (
)
var releaseCmd = &cobra.Command{
- Use: "release",
- Short: "Manage releases across platforms",
- Long: `Check for version tags without releases and create them across
-GitHub and Codeberg. Supports AI-generated release notes using Claude.`,
+ Use: "release",
+ Short: "Manage releases across platforms",
+ Long: `Check for version tags without releases and create them across
+GitHub and Codeberg. Supports AI-generated release notes via hexai (stdin pipeline),
+with fallback to Claude or aichat.`,
}
var releaseCheckCmd = &cobra.Command{
@@ -108,5 +109,5 @@ func init() {
releaseCreateCmd.Flags().BoolVar(&noAINotes, "no-ai-notes", false, "disable AI-generated release notes (AI notes are enabled by default)")
releaseCreateCmd.Flags().BoolVar(&updateExisting, "update-existing", false, "update existing releases with new AI-generated notes")
releaseCreateCmd.Flags().StringVar(&templatePath, "template", "", "custom template for release notes")
- releaseCreateCmd.Flags().StringVar(&aiTool, "ai-tool", "claude", "AI tool to use for release notes (claude or aichat)")
-} \ No newline at end of file
+ releaseCreateCmd.Flags().StringVar(&aiTool, "ai-tool", "claude", "AI tool to use for release notes (claude or aichat; hexai is tried first if available)")
+}
diff --git a/internal/cmd/showcase.go b/internal/cmd/showcase.go
index 802cf10..7785d6f 100644
--- a/internal/cmd/showcase.go
+++ b/internal/cmd/showcase.go
@@ -59,5 +59,5 @@ func init() {
showcaseCmd.Flags().StringVarP(&outputPath, "output", "o", "", "custom output path (default: ~/git/foo.zone-content/gemtext/about/showcase.gmi.tpl)")
showcaseCmd.Flags().StringVar(&outputFormat, "format", "gemtext", "output format: gemtext, markdown, html")
showcaseCmd.Flags().StringVar(&excludePattern, "exclude", "", "exclude repos matching pattern")
- showcaseCmd.Flags().StringVar(&showcaseAITool, "ai-tool", "claude", "AI tool to use for project summaries (claude or aichat)")
-} \ No newline at end of file
+ showcaseCmd.Flags().StringVar(&showcaseAITool, "ai-tool", "claude", "AI tool to use for project summaries (claude or aichat)")
+}
diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go
index 9fcf0fd..86505d5 100644
--- a/internal/cmd/sync.go
+++ b/internal/cmd/sync.go
@@ -189,7 +189,7 @@ func init() {
syncCmd.PersistentFlags().BoolVar(&noReleases, "no-releases", false, "skip release checking after sync")
syncCmd.PersistentFlags().BoolVar(&autoCreate, "auto-create-releases", false, "automatically create releases without confirmation")
syncCmd.PersistentFlags().BoolVar(&noAIReleaseNotes, "no-ai-release-notes", false, "disable AI-generated release notes (AI notes are enabled by default)")
- syncCmd.PersistentFlags().StringVar(&syncAITool, "ai-tool", "claude", "AI tool to use for release notes when auto-creating (claude or aichat)")
+ syncCmd.PersistentFlags().StringVar(&syncAITool, "ai-tool", "claude", "AI tool to use for release notes when auto-creating (claude or aichat; hexai is tried first if available)")
}
func buildFlags() *cli.Flags {
@@ -205,4 +205,4 @@ func buildFlags() *cli.Flags {
CreateGitHubRepos: createRepos,
CreateCodebergRepos: createRepos,
}
-} \ No newline at end of file
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index 7181152..9e9b2e5 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -330,50 +330,72 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags
return "", fmt.Errorf("failed to get diff: %w", err)
}
- // Prepare the prompt for the AI
- var prompt strings.Builder
- prompt.WriteString(fmt.Sprintf("Generate professional release notes for %s version %s.\n\n", repoName, tag))
-
- if prevTag != "" {
- prompt.WriteString(fmt.Sprintf("Previous version: %s\n", prevTag))
- }
-
- prompt.WriteString("\nCommit messages:\n")
- for _, commit := range commits {
- prompt.WriteString(fmt.Sprintf("- %s\n", commit))
- }
-
- prompt.WriteString("\nCode changes:\n")
- prompt.WriteString(diff)
- prompt.WriteString("\n\nBased on the commits and code changes above, write professional release notes that:\n")
- prompt.WriteString("1. Start with a brief overview of what this release accomplishes\n")
- prompt.WriteString("2. Group changes into logical sections (Features, Improvements, Bug Fixes, etc.)\n")
- prompt.WriteString("3. Explain WHY each change is useful to users, not just what changed\n")
- prompt.WriteString("4. Use clear, non-technical language where possible\n")
- prompt.WriteString("5. Highlight any breaking changes or migration steps\n")
- prompt.WriteString("6. Keep it concise but informative\n")
- prompt.WriteString("7. Format using Markdown\n")
- prompt.WriteString("\nDo not include the version number in the title as it will be added automatically.")
-
- fmt.Printf(" Prompt: Generate release notes for %s %s\n", repoName, tag)
- 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(prompt.String()))
-
- // Determine which AI tool to use (default to claude if not set)
- aiTool := m.aiTool
- if aiTool == "" {
- aiTool = "claude"
- }
-
- var releaseNotes string
-
- if aiTool == "claude" {
- fmt.Println(" Running claude CLI command...")
+ // Prepare prompt/instructions and input payload
+ var instr strings.Builder
+ instr.WriteString(fmt.Sprintf("Generate professional release notes for %s version %s.\n", repoName, tag))
+ if prevTag != "" {
+ instr.WriteString(fmt.Sprintf("Previous version: %s\n", prevTag))
+ }
+ instr.WriteString("\nBased on the provided commits and code changes, write professional release notes that:\n")
+ instr.WriteString("1. Start with a brief overview of what this release accomplishes\n")
+ instr.WriteString("2. Group changes into logical sections (Features, Improvements, Bug Fixes, etc.)\n")
+ instr.WriteString("3. Explain WHY each change is useful to users, not just what changed\n")
+ instr.WriteString("4. Use clear, non-technical language where possible\n")
+ instr.WriteString("5. Highlight any breaking changes or migration steps\n")
+ instr.WriteString("6. Keep it concise but informative\n")
+ instr.WriteString("7. Format using Markdown\n")
+ instr.WriteString("\nDo not include the version number in the title as it will be added automatically.")
+
+ var input strings.Builder
+ input.WriteString("Commit messages:\n")
+ for _, commit := range commits {
+ input.WriteString(fmt.Sprintf("- %s\n", commit))
+ }
+ input.WriteString("\nCode changes:\n")
+ input.WriteString(diff)
+
+ fmt.Printf(" Prompt: Generate release notes for %s %s\n", repoName, tag)
+ 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 claude if not set)
+ aiTool := m.aiTool
+ if aiTool == "" {
+ aiTool = "claude"
+ }
+
+ // Build a full prompt string for tools that read a single argument
+ fullPrompt := instr.String() + "\n\n" + input.String()
+
+ var releaseNotes string
+
+ // 1) Try hexai first: echo input to stdin and pass instructions as argument
+ // Note: print stderr to console, but only use stdout for notes
+ if _, err := exec.LookPath("hexai"); err == nil {
+ fmt.Println(" Running hexai CLI command (stdin payload)...")
+ cmd := exec.Command("hexai", instr.String())
+ cmd.Stdin = strings.NewReader(input.String())
+ cmd.Stderr = os.Stderr
+ 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
+ }
+ }
+ }
+
+ if releaseNotes == "" && aiTool == "claude" {
+ fmt.Println(" Running claude CLI command...")
if _, err := exec.LookPath("claude"); err != nil {
fmt.Println(" claude CLI not found, falling back to aichat...")
aiTool = "aichat"
} else {
- cmd := exec.Command("claude", "--model", "sonnet", prompt.String())
+ cmd := exec.Command("claude", "--model", "sonnet", fullPrompt)
cmd.Env = append(os.Environ(), "CLAUDE_DEBUG=1")
notes, err := m.executeAICommand(cmd, "claude")
@@ -387,13 +409,13 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags
}
}
- if aiTool == "aichat" {
+ if releaseNotes == "" && aiTool == "aichat" {
fmt.Println(" Running aichat CLI command...")
if _, err := exec.LookPath("aichat"); err != nil {
return "", fmt.Errorf("aichat CLI not found in PATH and claude fallback failed")
}
- cmd := exec.Command("aichat", prompt.String())
+ cmd := exec.Command("aichat", fullPrompt)
notes, err := m.executeAICommand(cmd, "aichat")
if err != nil {
return "", fmt.Errorf("aichat CLI failed: %w", err)
@@ -801,4 +823,4 @@ func (m *Manager) UpdateCodebergRelease(owner, repo, tag, releaseNotes string) e
}
return nil
-} \ No newline at end of file
+}
diff --git a/internal/version/version.go b/internal/version/version.go
index 93b9ce8..879c139 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -7,7 +7,7 @@ import (
var (
// Version is the current version of gitsyncer
- Version = "0.8.7"
+ Version = "0.8.8"
// GitCommit is the git commit hash at build time
GitCommit = "unknown"
diff --git a/test/run_integration_tests.sh b/test/run_integration_tests.sh
index b0c18a8..5ebde6c 100755
--- a/test/run_integration_tests.sh
+++ b/test/run_integration_tests.sh
@@ -50,12 +50,12 @@ else
exit 1
fi
-# Test 1: Version flag
-print_test "Test 1: Version flag"
-if ./gitsyncer --version | grep -q "gitsyncer version"; then
- print_success "Version flag works"
+# Test 1: Version command
+print_test "Test 1: Version command"
+if ./gitsyncer version | grep -q "gitsyncer version"; then
+ print_success "Version command works"
else
- print_failure "Version flag failed"
+ print_failure "Version command failed"
exit 1
fi
@@ -74,7 +74,7 @@ fi
# Test 3: List organizations
print_test "Test 3: List organizations"
cd "$PROJECT_ROOT"
-if ./gitsyncer --config test/test-config.json --list-orgs | grep -q "org1"; then
+if ./gitsyncer --config test/test-config.json list orgs | grep -q "org1"; then
print_success "Organizations listed successfully"
else
print_failure "Failed to list organizations"
@@ -84,7 +84,7 @@ fi
# Test 4: Initial sync
print_test "Test 4: Initial repository sync"
rm -rf test/work
-if ./gitsyncer --config test/test-config.json --sync test-repo --work-dir test/work > /dev/null 2>&1; then
+if ./gitsyncer --config test/test-config.json sync repo test-repo --work-dir test/work > /dev/null 2>&1; then
print_success "Initial sync completed"
# Verify all branches are synced
@@ -105,7 +105,7 @@ fi
print_test "Test 5: Idempotent sync (no changes)"
cd "$PROJECT_ROOT"
rm -rf test/work2
-if ./gitsyncer --config test/test-config.json --sync test-repo --work-dir test/work2 > /dev/null 2>&1; then
+if ./gitsyncer --config test/test-config.json sync repo test-repo --work-dir test/work2 > /dev/null 2>&1; then
print_success "Idempotent sync successful"
else
print_failure "Idempotent sync failed"
@@ -130,7 +130,7 @@ git push -q origin main
# Run sync
cd "$PROJECT_ROOT"
rm -rf test/work3
-if ./gitsyncer --config test/test-config.json --sync test-repo --work-dir test/work3 > /dev/null 2>&1; then
+if ./gitsyncer --config test/test-config.json sync repo test-repo --work-dir test/work3 > /dev/null 2>&1; then
print_success "Sync with changes successful"
# Verify change is in org2
@@ -170,7 +170,7 @@ fi
# Test 8: Missing config file
print_test "Test 8: Missing config file handling"
-if ./gitsyncer --config test/nonexistent.json --sync test-repo 2>&1 | grep -q "Failed to load configuration"; then
+if ./gitsyncer --config test/nonexistent.json sync repo test-repo 2>&1 | grep -q "Error loading configuration"; then
print_success "Missing config file handled correctly"
else
print_failure "Missing config file not handled properly"
@@ -182,7 +182,7 @@ print_test "Test 9: Invalid configuration handling"
cd "$TEST_DIR"
echo '{"organizations": []}' > empty-config.json
cd "$PROJECT_ROOT"
-if ./gitsyncer --config test/empty-config.json --sync test-repo 2>&1 | grep -q "no organizations configured"; then
+if ./gitsyncer --config test/empty-config.json sync repo test-repo 2>&1 | grep -q "invalid configuration: no organizations configured"; then
print_success "Empty organization list handled correctly"
else
print_failure "Empty organization list not handled properly"
@@ -196,7 +196,7 @@ cd "$TEST_DIR"
cd "$PROJECT_ROOT"
# Test list-repos
-if ./gitsyncer --config test/multi-repo-config.json --list-repos | grep -q "repo1"; then
+if ./gitsyncer --config test/multi-repo-config.json list repos | grep -q "repo1"; then
print_success "Repository listing works"
else
print_failure "Failed to list repositories"
@@ -204,7 +204,7 @@ else
fi
# Test sync-all
-if ./gitsyncer --config test/multi-repo-config.json --sync-all --work-dir test/work-multi > /dev/null 2>&1; then
+if ./gitsyncer --config test/multi-repo-config.json sync all --work-dir test/work-multi > /dev/null 2>&1; then
print_success "Multiple repository sync completed"
else
print_failure "Multiple repository sync failed"
@@ -226,4 +226,4 @@ echo " ✓ Merge changes from all remotes"
echo " ✓ Detect and report merge conflicts"
echo " ✓ Handle configuration errors gracefully"
echo " ✓ Sync multiple repositories with --sync-all"
-echo " ✓ Handle missing remote repositories gracefully" \ No newline at end of file
+echo " ✓ Handle missing remote repositories gracefully"
diff --git a/test/work2/test-repo b/test/work2/test-repo
new file mode 160000
+Subproject 3404c05717de3426030b65999d8e659494c51bb
diff --git a/test/work3/test-repo b/test/work3/test-repo
new file mode 160000
+Subproject e5f2ab3c76156de1636fd7f766af505825cea00
diff --git a/tmp-config.json b/tmp-config.json
new file mode 100644
index 0000000..8e7b2a7
--- /dev/null
+++ b/tmp-config.json
@@ -0,0 +1,11 @@
+{
+ "organizations": [
+ {"host": "git@codeberg.org", "name": "dummy"},
+ {"host": "git@github.com", "name": "dummy"}
+ ],
+ "repositories": ["fapi"],
+ "work_dir": "./tmp-work",
+ "skip_releases": {
+ "fapi": ["0.0.1"]
+ }
+}
diff --git a/tmp-work/fapi b/tmp-work/fapi
new file mode 160000
+Subproject 95b038701d69c5cfce414bb8fca9ab7a5774a2f