diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-17 09:18:18 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-17 09:18:18 +0300 |
| commit | a8db7af2a094a16393f0060e628310d4161b154f (patch) | |
| tree | e1240767a26261bed8a0bcbbdb31850ad8b38d4e | |
| parent | 9603960eeea6b06c5184850c2c2af7d257c77fdd (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.md | 7 | ||||
| -rw-r--r-- | internal/cmd/release.go | 13 | ||||
| -rw-r--r-- | internal/cmd/showcase.go | 4 | ||||
| -rw-r--r-- | internal/cmd/sync.go | 4 | ||||
| -rw-r--r-- | internal/release/release.go | 108 | ||||
| -rw-r--r-- | internal/version/version.go | 2 | ||||
| -rwxr-xr-x | test/run_integration_tests.sh | 28 | ||||
| m--------- | test/work2/test-repo | 0 | ||||
| m--------- | test/work3/test-repo | 0 | ||||
| -rw-r--r-- | tmp-config.json | 11 | ||||
| m--------- | tmp-work/fapi | 0 |
11 files changed, 109 insertions, 68 deletions
@@ -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 |
