diff options
| author | Paul Buetow <paul@buetow.org> | 2025-10-31 20:13:32 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-10-31 20:13:32 +0200 |
| commit | 11eea6a82cbfdde40ec1457c6ea080da4da6b7dc (patch) | |
| tree | 8026068f6a3beb3ee02c45f06f4487f4b89caaf1 /internal | |
| parent | 5c3e0b5cf99d028c4f06be7a825388b296e37a22 (diff) | |
feat: implement amp AI tool support and replace Taskfile with Magev0.10.0
- Add amp as default AI tool for release notes and showcase generation
- Fallback chain: amp → hexai → claude → aichat
- Replace Taskfile.yaml with magefile.go for build automation
- Update all documentation (README.md, AGENTS.md, doc/development.md)
- Update version to 0.10.0
Amp-Thread-ID: https://ampcode.com/threads/T-735ba1e2-0255-4b43-8ed1-6c0d2f78301b
Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'internal')
33 files changed, 1727 insertions, 1634 deletions
diff --git a/internal/cli/description_cache.go b/internal/cli/description_cache.go index 1cfc951..a2ce9ab 100644 --- a/internal/cli/description_cache.go +++ b/internal/cli/description_cache.go @@ -1,38 +1,37 @@ package cli import ( - "encoding/json" - "fmt" - "os" - "path/filepath" + "encoding/json" + "fmt" + "os" + "path/filepath" ) // loadDescriptionCache loads the per-repo canonical description cache func loadDescriptionCache(workDir string) map[string]string { - cache := make(map[string]string) - cacheFile := filepath.Join(workDir, ".gitsyncer-descriptions-cache.json") - data, err := os.ReadFile(cacheFile) - if err != nil { - return cache - } - if err := json.Unmarshal(data, &cache); err != nil { - fmt.Printf("Warning: Failed to parse descriptions cache: %v\n", err) - return make(map[string]string) - } - fmt.Printf("Loaded descriptions cache with %d entries\n", len(cache)) - return cache + cache := make(map[string]string) + cacheFile := filepath.Join(workDir, ".gitsyncer-descriptions-cache.json") + data, err := os.ReadFile(cacheFile) + if err != nil { + return cache + } + if err := json.Unmarshal(data, &cache); err != nil { + fmt.Printf("Warning: Failed to parse descriptions cache: %v\n", err) + return make(map[string]string) + } + fmt.Printf("Loaded descriptions cache with %d entries\n", len(cache)) + return cache } // saveDescriptionCache saves the per-repo canonical description cache func saveDescriptionCache(workDir string, cache map[string]string) error { - cacheFile := filepath.Join(workDir, ".gitsyncer-descriptions-cache.json") - data, err := json.MarshalIndent(cache, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal descriptions cache: %w", err) - } - if err := os.WriteFile(cacheFile, data, 0644); err != nil { - return fmt.Errorf("failed to write descriptions cache: %w", err) - } - return nil + cacheFile := filepath.Join(workDir, ".gitsyncer-descriptions-cache.json") + data, err := json.MarshalIndent(cache, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal descriptions cache: %w", err) + } + if err := os.WriteFile(cacheFile, data, 0644); err != nil { + return fmt.Errorf("failed to write descriptions cache: %w", err) + } + return nil } - diff --git a/internal/cli/description_sync.go b/internal/cli/description_sync.go index ae275ce..4904b56 100644 --- a/internal/cli/description_sync.go +++ b/internal/cli/description_sync.go @@ -1,113 +1,112 @@ package cli import ( - "fmt" - "strings" + "fmt" + "strings" - "codeberg.org/snonux/gitsyncer/internal/codeberg" - "codeberg.org/snonux/gitsyncer/internal/config" - "codeberg.org/snonux/gitsyncer/internal/github" + "codeberg.org/snonux/gitsyncer/internal/codeberg" + "codeberg.org/snonux/gitsyncer/internal/config" + "codeberg.org/snonux/gitsyncer/internal/github" ) // syncRepoDescriptions ensures both platforms have the canonical description // Precedence: Codeberg > GitHub; if Codeberg empty and GitHub has one, use GitHub. // knownCBDesc and knownGHDesc can be empty; the function fetches as needed. func syncRepoDescriptions(cfg *config.Config, dryRun bool, repoName, knownCBDesc, knownGHDesc string, cache map[string]string) { - // Load orgs - ghOrg := cfg.FindGitHubOrg() - cbOrg := cfg.FindCodebergOrg() + // Load orgs + ghOrg := cfg.FindGitHubOrg() + cbOrg := cfg.FindCodebergOrg() - var ghClient *github.Client - var cbClient *codeberg.Client - if ghOrg != nil { - c := github.NewClient(ghOrg.GitHubToken, ghOrg.Name) - ghClient = &c - } - if cbOrg != nil { - c := codeberg.NewClient(cbOrg.Name, cbOrg.CodebergToken) - cbClient = &c - } + var ghClient *github.Client + var cbClient *codeberg.Client + if ghOrg != nil { + c := github.NewClient(ghOrg.GitHubToken, ghOrg.Name) + ghClient = &c + } + if cbOrg != nil { + c := codeberg.NewClient(cbOrg.Name, cbOrg.CodebergToken) + cbClient = &c + } - // Get current descriptions (use known if provided) - cbDesc := strings.TrimSpace(knownCBDesc) - ghDesc := strings.TrimSpace(knownGHDesc) - var cbExists, ghExists bool + // Get current descriptions (use known if provided) + cbDesc := strings.TrimSpace(knownCBDesc) + ghDesc := strings.TrimSpace(knownGHDesc) + var cbExists, ghExists bool - if cbDesc == "" && cbClient != nil { - if repo, exists, err := cbClient.GetRepo(repoName); err == nil { - cbExists = exists - if exists { - cbDesc = strings.TrimSpace(repo.Description) - } - } else { - fmt.Printf(" Warning: Codeberg repo lookup failed: %v\n", err) - } - } else if cbClient != nil { - cbExists = true - } + if cbDesc == "" && cbClient != nil { + if repo, exists, err := cbClient.GetRepo(repoName); err == nil { + cbExists = exists + if exists { + cbDesc = strings.TrimSpace(repo.Description) + } + } else { + fmt.Printf(" Warning: Codeberg repo lookup failed: %v\n", err) + } + } else if cbClient != nil { + cbExists = true + } - if ghClient != nil { - if ghDesc == "" || !ghExists { - if repo, exists, err := ghClient.GetRepo(repoName); err == nil { - ghExists = exists - if exists { - ghDesc = strings.TrimSpace(repo.Description) - } - } else { - fmt.Printf(" Warning: GitHub repo lookup failed: %v\n", err) - } - } - } + if ghClient != nil { + if ghDesc == "" || !ghExists { + if repo, exists, err := ghClient.GetRepo(repoName); err == nil { + ghExists = exists + if exists { + ghDesc = strings.TrimSpace(repo.Description) + } + } else { + fmt.Printf(" Warning: GitHub repo lookup failed: %v\n", err) + } + } + } - // Determine canonical description - canonical := cbDesc - if canonical == "" { - canonical = ghDesc - } - canonical = strings.TrimSpace(canonical) + // Determine canonical description + canonical := cbDesc + if canonical == "" { + canonical = ghDesc + } + canonical = strings.TrimSpace(canonical) - // If nothing to sync, bail - if canonical == "" { - return - } + // If nothing to sync, bail + if canonical == "" { + return + } - // Update Codeberg if needed - if cbClient != nil && cbExists { - if cbDesc != canonical { - if dryRun { - fmt.Printf(" [DRY RUN] Would update Codeberg description for %s -> %q\n", repoName, canonical) - } else if cbClient.HasToken() { - if err := cbClient.UpdateRepoDescription(repoName, canonical); err != nil { - fmt.Printf(" Warning: Failed to update Codeberg description: %v\n", err) - } else { - fmt.Printf(" Updated Codeberg description for %s\n", repoName) - } - } else { - fmt.Println(" Warning: No Codeberg token; cannot update description") - } - } - } + // Update Codeberg if needed + if cbClient != nil && cbExists { + if cbDesc != canonical { + if dryRun { + fmt.Printf(" [DRY RUN] Would update Codeberg description for %s -> %q\n", repoName, canonical) + } else if cbClient.HasToken() { + if err := cbClient.UpdateRepoDescription(repoName, canonical); err != nil { + fmt.Printf(" Warning: Failed to update Codeberg description: %v\n", err) + } else { + fmt.Printf(" Updated Codeberg description for %s\n", repoName) + } + } else { + fmt.Println(" Warning: No Codeberg token; cannot update description") + } + } + } - // Update GitHub if needed - if ghClient != nil && ghExists { - if ghDesc != canonical { - if dryRun { - fmt.Printf(" [DRY RUN] Would update GitHub description for %s -> %q\n", repoName, canonical) - } else if ghClient.HasToken() { - if err := ghClient.UpdateRepoDescription(repoName, canonical); err != nil { - fmt.Printf(" Warning: Failed to update GitHub description: %v\n", err) - } else { - fmt.Printf(" Updated GitHub description for %s\n", repoName) - } - } else { - fmt.Println(" Warning: No GitHub token; cannot update description") - } - } - } + // Update GitHub if needed + if ghClient != nil && ghExists { + if ghDesc != canonical { + if dryRun { + fmt.Printf(" [DRY RUN] Would update GitHub description for %s -> %q\n", repoName, canonical) + } else if ghClient.HasToken() { + if err := ghClient.UpdateRepoDescription(repoName, canonical); err != nil { + fmt.Printf(" Warning: Failed to update GitHub description: %v\n", err) + } else { + fmt.Printf(" Updated GitHub description for %s\n", repoName) + } + } else { + fmt.Println(" Warning: No GitHub token; cannot update description") + } + } + } - // Update cache - if cache != nil { - cache[repoName] = canonical - } + // Update cache + if cache != nil { + cache[repoName] = canonical + } } - diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 43d9fa3..5c6914c 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -4,39 +4,39 @@ import ( "flag" "os" "path/filepath" - + "codeberg.org/snonux/gitsyncer/internal/state" ) // Flags holds all command-line flag values type Flags struct { - VersionFlag bool - ConfigPath string - ListOrgs bool - ListRepos bool - SyncRepo string - SyncAll bool - SyncCodebergPublic bool - SyncGitHubPublic bool - FullSync bool - CreateGitHubRepos bool + VersionFlag bool + ConfigPath string + ListOrgs bool + ListRepos bool + SyncRepo string + SyncAll bool + SyncCodebergPublic bool + SyncGitHubPublic bool + FullSync bool + CreateGitHubRepos bool CreateCodebergRepos bool - DryRun bool - WorkDir string - TestGitHubToken bool - Clean bool - DeleteRepo string - Backup bool - Showcase bool - Force bool - BatchRun bool - CheckReleases bool - NoCheckReleases bool - AutoCreateReleases bool - AIReleaseNotes bool - UpdateReleases bool - AITool string - + DryRun bool + WorkDir string + TestGitHubToken bool + Clean bool + DeleteRepo string + Backup bool + Showcase bool + Force bool + BatchRun bool + CheckReleases bool + NoCheckReleases bool + AutoCreateReleases bool + AIReleaseNotes bool + UpdateReleases bool + AITool string + // Internal fields for batch run state management (not set by flags) BatchRunStateManager *state.Manager BatchRunState *state.State @@ -45,7 +45,7 @@ type Flags struct { // ParseFlags parses command-line flags and returns the flags struct func ParseFlags() *Flags { f := &Flags{} - + flag.BoolVar(&f.VersionFlag, "version", false, "print version information") flag.BoolVar(&f.VersionFlag, "v", false, "print version information (short)") flag.StringVar(&f.ConfigPath, "config", "", "path to configuration file") @@ -65,17 +65,17 @@ func ParseFlags() *Flags { flag.BoolVar(&f.Clean, "clean", false, "delete all repositories in work directory (with confirmation)") flag.StringVar(&f.DeleteRepo, "delete-repo", "", "delete specified repository from all configured organizations (with confirmation)") flag.BoolVar(&f.Backup, "backup", false, "enable syncing to backup locations") - flag.BoolVar(&f.Showcase, "showcase", false, "generate project showcase using Claude after syncing") + flag.BoolVar(&f.Showcase, "showcase", false, "generate project showcase using AI (amp by default) after syncing") flag.BoolVar(&f.Force, "force", false, "force regeneration of cached data") flag.BoolVar(&f.BatchRun, "batch-run", false, "enable --full and --showcase (runs only once per week)") flag.BoolVar(&f.CheckReleases, "check-releases", false, "manually check for version tags without releases and create them (with confirmation)") flag.BoolVar(&f.NoCheckReleases, "no-check-releases", false, "disable automatic release checking after sync operations") flag.BoolVar(&f.AutoCreateReleases, "auto-create-releases", false, "automatically create releases without confirmation prompts") - flag.BoolVar(&f.AIReleaseNotes, "ai-release-notes", false, "generate release notes using Claude AI based on git diff") + flag.BoolVar(&f.AIReleaseNotes, "ai-release-notes", false, "generate release notes using AI (amp by default) based on git diff") flag.BoolVar(&f.UpdateReleases, "update-releases", false, "update existing releases with new AI-generated notes") - + flag.Parse() - + // Set default WorkDir if not provided if f.WorkDir == "" { home, err := os.UserHomeDir() @@ -86,7 +86,7 @@ func ParseFlags() *Flags { f.WorkDir = ".gitsyncer-work" } } - + // Handle --full flag by enabling all sync operations if f.FullSync { f.SyncCodebergPublic = true @@ -94,7 +94,7 @@ func ParseFlags() *Flags { f.CreateGitHubRepos = true f.CreateCodebergRepos = true } - + // Handle --batch-run flag by enabling --full and --showcase if f.BatchRun { f.FullSync = true @@ -105,6 +105,6 @@ func ParseFlags() *Flags { f.CreateGitHubRepos = true f.CreateCodebergRepos = true } - + return f -}
\ No newline at end of file +} diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go index 02df7ae..aa43a86 100644 --- a/internal/cli/handlers.go +++ b/internal/cli/handlers.go @@ -28,7 +28,7 @@ func HandleTestGitHubToken() int { fmt.Println("Please set GITHUB_TOKEN environment variable or create ~/.gitsyncer_github_token file") return 1 } - + // Test the token by checking a known repo exists, err := client.RepoExists("gitsyncer") if err != nil { @@ -41,7 +41,7 @@ func HandleTestGitHubToken() int { } return 1 } - + fmt.Printf("SUCCESS: Token is valid! Repository check returned: %v\n", exists) return 0 } @@ -54,7 +54,7 @@ func LoadConfig(configPath string) (*config.Config, error) { return nil, fmt.Errorf("no configuration file found") } } - + fmt.Printf("Loaded configuration from: %s\n", configPath) return config.Load(configPath) } @@ -78,14 +78,14 @@ func findDefaultConfigPath() string { return loc } } - + return "" } // ShowConfigHelp displays help for creating a configuration file func ShowConfigHelp() { home, _ := os.UserHomeDir() - + fmt.Println("No configuration file found. Please create one of:") fmt.Printf(" - ./gitsyncer.json\n") fmt.Printf(" - %s/.config/gitsyncer/config.json\n", home) @@ -135,7 +135,7 @@ func HandleListRepos(cfg *config.Config) int { // ShowUsage displays the usage information func ShowUsage(cfg *config.Config) { fmt.Println("\ngitsyncer - Git repository synchronization tool") - fmt.Printf("Configured with %d organization(s) and %d repository(ies)\n", + fmt.Printf("Configured with %d organization(s) and %d repository(ies)\n", len(cfg.Organizations), len(cfg.Repositories)) fmt.Println("\nUsage:") fmt.Println(" gitsyncer --sync <repo-name> Sync a specific repository") @@ -166,18 +166,18 @@ func HandleDeleteRepo(cfg *config.Config, repoName string) int { } fmt.Printf("\n⚠️ WARNING: This will permanently delete the repository '%s' from all configured organizations!\n\n", repoName) - + // Find organizations where the repo exists var orgsWithRepo []struct { org config.Organization exists bool err error } - + for _, org := range cfg.Organizations { var exists bool var err error - + switch org.Host { case "git@github.com": client := github.NewClient(org.GitHubToken, org.Name) @@ -189,14 +189,14 @@ func HandleDeleteRepo(cfg *config.Config, repoName string) int { fmt.Printf("Skipping unsupported host: %s\n", org.Host) continue } - + orgsWithRepo = append(orgsWithRepo, struct { org config.Organization exists bool err error }{org, exists, err}) } - + // Show summary of where the repo exists fmt.Println("Repository status:") foundAny := false @@ -210,36 +210,36 @@ func HandleDeleteRepo(cfg *config.Config, repoName string) int { fmt.Printf(" ⬜ %s: Not found\n", info.org.GetGitURL()) } } - + if !foundAny { fmt.Printf("\nRepository '%s' not found in any configured organization.\n", repoName) return 0 } - + // Confirm deletion fmt.Printf("\nAre you sure you want to delete '%s' from the above organizations? This action cannot be undone!\n", repoName) fmt.Print("Type 'yes' to confirm: ") - + reader := bufio.NewReader(os.Stdin) confirmation, _ := reader.ReadString('\n') confirmation = strings.TrimSpace(confirmation) - + if confirmation != "yes" { fmt.Println("Deletion cancelled.") return 0 } - + // Perform deletions fmt.Println("\nDeleting repositories...") hasError := false - + for _, info := range orgsWithRepo { if !info.exists || info.err != nil { continue } - + fmt.Printf(" Deleting from %s... ", info.org.GetGitURL()) - + var deleteErr error switch info.org.Host { case "git@github.com": @@ -249,7 +249,7 @@ func HandleDeleteRepo(cfg *config.Config, repoName string) int { client := codeberg.NewClient(info.org.Name, info.org.CodebergToken) deleteErr = client.DeleteRepo(repoName) } - + if deleteErr != nil { fmt.Printf("FAILED: %v\n", deleteErr) hasError = true @@ -257,12 +257,12 @@ func HandleDeleteRepo(cfg *config.Config, repoName string) int { fmt.Println("SUCCESS") } } - + if hasError { fmt.Println("\n⚠️ Some deletions failed. Check the errors above.") return 1 } - + fmt.Printf("\n✅ Repository '%s' has been successfully deleted from all organizations.\n", repoName) return 0 -}
\ No newline at end of file +} diff --git a/internal/cli/release.go b/internal/cli/release.go index 86cc5f9..e6dd057 100644 --- a/internal/cli/release.go +++ b/internal/cli/release.go @@ -29,7 +29,7 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int { fmt.Printf("Error reading work directory %s: %v\n", flags.WorkDir, err) return 1 } - + var repositories []string for _, entry := range entries { if entry.IsDir() { @@ -40,12 +40,12 @@ func HandleCheckReleases(cfg *config.Config, flags *Flags) int { } } } - + if len(repositories) == 0 { fmt.Println("No repositories found in work directory") return 1 } - + fmt.Printf("Found %d repositories in work directory\n", len(repositories)) return HandleCheckReleasesForRepos(cfg, flags, repositories) } @@ -60,23 +60,23 @@ func HandleCheckReleasesForRepo(cfg *config.Config, flags *Flags, repoName strin func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories []string) int { releaseManager := release.NewManager(flags.WorkDir) releaseManager.SetAITool(flags.AITool) - + // Load persistent AI release notes cache cacheFile := filepath.Join(flags.WorkDir, ".gitsyncer-ai-release-notes-cache.json") aiReleaseNotesCache := loadAIReleaseNotesCache(cacheFile) initialCacheSize := len(aiReleaseNotesCache) - + // Track failed AI generations failedAIGenerations := []string{} - + // Print summary at the end defer func() { if len(aiReleaseNotesCache) > initialCacheSize { - fmt.Printf("\nAI release notes cache updated: %d new entries added (total: %d entries)\n", + fmt.Printf("\nAI release notes cache updated: %d new entries added (total: %d entries)\n", len(aiReleaseNotesCache)-initialCacheSize, len(aiReleaseNotesCache)) fmt.Printf("Cache file: %s\n", cacheFile) } - + if len(failedAIGenerations) > 0 { fmt.Printf("\n⚠️ AI release notes generation failed for %d releases:\n", len(failedAIGenerations)) for _, failed := range failedAIGenerations { @@ -86,12 +86,12 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories fmt.Println("Run again to retry generation for these releases.") } }() - + // Set tokens from config with fallback to environment variables and files githubOrg := cfg.FindGitHubOrg() if githubOrg != nil { fmt.Printf("Found GitHub org: %s\n", githubOrg.Name) - + // Try config token first, then fallback to env var and file token := githubOrg.GitHubToken if token == "" { @@ -109,7 +109,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } } } - + if token != "" { releaseManager.SetGitHubToken(token) } else { @@ -118,11 +118,11 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } else { fmt.Println("No GitHub organization found in config") } - + codebergOrg := cfg.FindCodebergOrg() if codebergOrg != nil { fmt.Printf("Found Codeberg org: %s\n", codebergOrg.Name) - + // Try config token first, then fallback to env var and file token := codebergOrg.CodebergToken if token == "" { @@ -140,7 +140,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } } } - + if token != "" { releaseManager.SetCodebergToken(token) fmt.Printf(" Codeberg token loaded (length: %d)\n", len(token)) @@ -150,114 +150,114 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } else { fmt.Println("No Codeberg organization found in config") } - - // Process the specified repositories - for _, repoName := range repositories { - fmt.Printf("\nChecking releases for repository: %s\n", repoName) - + + // Process the specified repositories + for _, repoName := range repositories { + fmt.Printf("\nChecking releases for repository: %s\n", repoName) + // Check if the repository is cloned locally repoPath := filepath.Join(flags.WorkDir, repoName) if _, err := os.Stat(repoPath); os.IsNotExist(err) { fmt.Printf(" Repository not found locally at %s, skipping...\n", repoPath) continue } - + // Get local tags localTags, err := releaseManager.GetLocalTags(repoPath) if err != nil { fmt.Printf(" Error getting local tags: %v\n", err) continue } - + if len(localTags) == 0 { fmt.Println(" No version tags found") continue } - - fmt.Printf(" Found %d version tags: %s\n", len(localTags), strings.Join(localTags, ", ")) - // Log configured skip rules for this repo, if any - if cfg.SkipReleases != nil { - if skipTags, ok := cfg.SkipReleases[repoName]; ok && len(skipTags) > 0 { - fmt.Printf(" Config skip_releases for %s: %s\n", repoName, strings.Join(skipTags, ", ")) - } - } - + + fmt.Printf(" Found %d version tags: %s\n", len(localTags), strings.Join(localTags, ", ")) + // Log configured skip rules for this repo, if any + if cfg.SkipReleases != nil { + if skipTags, ok := cfg.SkipReleases[repoName]; ok && len(skipTags) > 0 { + fmt.Printf(" Config skip_releases for %s: %s\n", repoName, strings.Join(skipTags, ", ")) + } + } + // Check GitHub releases if GitHub is configured var missingGitHub []string githubOrg := cfg.FindGitHubOrg() - if githubOrg != nil && githubOrg.Name != "" { - githubReleases, err := releaseManager.GetGitHubReleases(githubOrg.Name, repoName) - if err != nil { - fmt.Printf(" Error checking GitHub releases: %v\n", err) - } else { - missingGitHub = releaseManager.FindMissingReleases(localTags, githubReleases) - // Filter out tags that should be skipped per config - if len(missingGitHub) > 0 { - var filtered []string - var skipped []string - for _, t := range missingGitHub { - if cfg.ShouldSkipRelease(repoName, t) { - skipped = append(skipped, t) - } else { - filtered = append(filtered, t) - } - } - if len(skipped) > 0 { - fmt.Printf(" Skipping GitHub releases per config for tags: %s\n", strings.Join(skipped, ", ")) - } - missingGitHub = filtered - if len(missingGitHub) > 0 { - fmt.Printf(" Missing GitHub releases: %s\n", strings.Join(missingGitHub, ", ")) - } - } - } - } - + if githubOrg != nil && githubOrg.Name != "" { + githubReleases, err := releaseManager.GetGitHubReleases(githubOrg.Name, repoName) + if err != nil { + fmt.Printf(" Error checking GitHub releases: %v\n", err) + } else { + missingGitHub = releaseManager.FindMissingReleases(localTags, githubReleases) + // Filter out tags that should be skipped per config + if len(missingGitHub) > 0 { + var filtered []string + var skipped []string + for _, t := range missingGitHub { + if cfg.ShouldSkipRelease(repoName, t) { + skipped = append(skipped, t) + } else { + filtered = append(filtered, t) + } + } + if len(skipped) > 0 { + fmt.Printf(" Skipping GitHub releases per config for tags: %s\n", strings.Join(skipped, ", ")) + } + missingGitHub = filtered + if len(missingGitHub) > 0 { + fmt.Printf(" Missing GitHub releases: %s\n", strings.Join(missingGitHub, ", ")) + } + } + } + } + // Check Codeberg releases if Codeberg is configured var missingCodeberg []string codebergOrg := cfg.FindCodebergOrg() - if codebergOrg != nil && codebergOrg.Name != "" { - codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName) - if err != nil { - fmt.Printf(" Error checking Codeberg releases: %v\n", err) - } else { - missingCodeberg = releaseManager.FindMissingReleases(localTags, codebergReleases) - // Filter out tags that should be skipped per config - if len(missingCodeberg) > 0 { - var filtered []string - var skipped []string - for _, t := range missingCodeberg { - if cfg.ShouldSkipRelease(repoName, t) { - skipped = append(skipped, t) - } else { - filtered = append(filtered, t) - } - } - if len(skipped) > 0 { - fmt.Printf(" Skipping Codeberg releases per config for tags: %s\n", strings.Join(skipped, ", ")) - } - missingCodeberg = filtered - if len(missingCodeberg) > 0 { - fmt.Printf(" Missing Codeberg releases: %s\n", strings.Join(missingCodeberg, ", ")) - } - } - } - } - + if codebergOrg != nil && codebergOrg.Name != "" { + codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName) + if err != nil { + fmt.Printf(" Error checking Codeberg releases: %v\n", err) + } else { + missingCodeberg = releaseManager.FindMissingReleases(localTags, codebergReleases) + // Filter out tags that should be skipped per config + if len(missingCodeberg) > 0 { + var filtered []string + var skipped []string + for _, t := range missingCodeberg { + if cfg.ShouldSkipRelease(repoName, t) { + skipped = append(skipped, t) + } else { + filtered = append(filtered, t) + } + } + if len(skipped) > 0 { + fmt.Printf(" Skipping Codeberg releases per config for tags: %s\n", strings.Join(skipped, ", ")) + } + missingCodeberg = filtered + if len(missingCodeberg) > 0 { + fmt.Printf(" Missing Codeberg releases: %s\n", strings.Join(missingCodeberg, ", ")) + } + } + } + } + // Create missing releases with confirmation - if len(missingGitHub) > 0 && githubOrg != nil { - for _, tag := range missingGitHub { - // Skip if configured to skip this repo/tag - if cfg.ShouldSkipRelease(repoName, tag) { - fmt.Printf(" Skipping GitHub release for %s:%s per config skip_releases\n", repoName, tag) - continue - } + if len(missingGitHub) > 0 && githubOrg != nil { + for _, tag := range missingGitHub { + // Skip if configured to skip this repo/tag + if cfg.ShouldSkipRelease(repoName, tag) { + fmt.Printf(" Skipping GitHub release for %s:%s per config skip_releases\n", repoName, tag) + continue + } // Get commits for this tag commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag) if err != nil { commits = []string{} } - + // Generate release notes var releaseNotes string if flags.AIReleaseNotes { @@ -295,16 +295,16 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } else { releaseNotes = releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) } - + // Print release notes to stdout fmt.Printf("\n%s\n", strings.Repeat("=", 70)) fmt.Printf("Release Notes for %s/%s tag %s:\n", githubOrg.Name, repoName, tag) fmt.Printf("%s\n", strings.Repeat("-", 70)) fmt.Println(releaseNotes) fmt.Printf("%s\n\n", strings.Repeat("=", 70)) - + msg := fmt.Sprintf("Create GitHub release for %s/%s tag %s?", githubOrg.Name, repoName, tag) - + // Check if auto-create is enabled createRelease := false if flags.AutoCreateReleases { @@ -313,7 +313,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } else { createRelease = release.PromptConfirmation(msg) } - + if createRelease { if err := releaseManager.CreateGitHubRelease(githubOrg.Name, repoName, tag, releaseNotes); err != nil { fmt.Printf(" Error creating GitHub release: %v\n", err) @@ -323,24 +323,24 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } } } - - if len(missingCodeberg) > 0 && codebergOrg != nil { - // Ensure Releases feature is enabled on Codeberg before creating releases - if err := releaseManager.EnsureCodebergReleasesEnabled(codebergOrg.Name, repoName); err != nil { - fmt.Printf(" Warning: Could not ensure Codeberg releases are enabled: %v\n", err) - } - for _, tag := range missingCodeberg { - // Skip if configured to skip this repo/tag - if cfg.ShouldSkipRelease(repoName, tag) { - fmt.Printf(" Skipping Codeberg release for %s:%s per config skip_releases\n", repoName, tag) - continue - } + + if len(missingCodeberg) > 0 && codebergOrg != nil { + // Ensure Releases feature is enabled on Codeberg before creating releases + if err := releaseManager.EnsureCodebergReleasesEnabled(codebergOrg.Name, repoName); err != nil { + fmt.Printf(" Warning: Could not ensure Codeberg releases are enabled: %v\n", err) + } + for _, tag := range missingCodeberg { + // Skip if configured to skip this repo/tag + if cfg.ShouldSkipRelease(repoName, tag) { + fmt.Printf(" Skipping Codeberg release for %s:%s per config skip_releases\n", repoName, tag) + continue + } // Get commits for this tag commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag) if err != nil { commits = []string{} } - + // Generate release notes var releaseNotes string if flags.AIReleaseNotes { @@ -378,16 +378,16 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } else { releaseNotes = releaseManager.GenerateReleaseNotes(repoPath, tag, localTags) } - + // Print release notes to stdout fmt.Printf("\n%s\n", strings.Repeat("=", 70)) fmt.Printf("Release Notes for %s/%s tag %s:\n", codebergOrg.Name, repoName, tag) fmt.Printf("%s\n", strings.Repeat("-", 70)) fmt.Println(releaseNotes) fmt.Printf("%s\n\n", strings.Repeat("=", 70)) - + msg := fmt.Sprintf("Create Codeberg release for %s/%s tag %s?", codebergOrg.Name, repoName, tag) - + // Check if auto-create is enabled createRelease := false if flags.AutoCreateReleases { @@ -396,7 +396,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } else { createRelease = release.PromptConfirmation(msg) } - + if createRelease { if err := releaseManager.CreateCodebergRelease(codebergOrg.Name, repoName, tag, releaseNotes); err != nil { fmt.Printf(" Error creating Codeberg release: %v\n", err) @@ -406,7 +406,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } } } - + // Update existing releases if requested if flags.UpdateReleases { // Update GitHub releases @@ -419,13 +419,13 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories if !isVersionTag(tag) { continue } - + // Get commits for this tag commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag) if err != nil { commits = []string{} } - + // Generate AI release notes if flags.AIReleaseNotes { // Check cache first (unless --force is used) @@ -464,16 +464,16 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories fmt.Printf(" Warning: Failed to save cache: %v\n", err) } } - + // Print release notes to stdout fmt.Printf("\n%s\n", strings.Repeat("=", 70)) fmt.Printf("Updated Release Notes for %s/%s tag %s:\n", githubOrg.Name, repoName, tag) fmt.Printf("%s\n", strings.Repeat("-", 70)) fmt.Println(aiNotes) fmt.Printf("%s\n\n", strings.Repeat("=", 70)) - + msg := fmt.Sprintf("Update GitHub release for %s/%s tag %s?", githubOrg.Name, repoName, tag) - + updateRelease := false if flags.AutoCreateReleases { fmt.Printf(" Auto-updating GitHub release for %s/%s tag %s\n", githubOrg.Name, repoName, tag) @@ -481,7 +481,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } else { updateRelease = release.PromptConfirmation(msg) } - + if updateRelease { if err := releaseManager.UpdateGitHubRelease(githubOrg.Name, repoName, tag, aiNotes); err != nil { fmt.Printf(" Error updating GitHub release: %v\n", err) @@ -493,7 +493,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } } } - + // Update Codeberg releases if codebergOrg != nil && codebergOrg.Name != "" { codebergReleases, err := releaseManager.GetCodebergReleases(codebergOrg.Name, repoName) @@ -504,13 +504,13 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories if !isVersionTag(tag) { continue } - + // Get commits for this tag commits, err := releaseManager.GetCommitsSinceTag(repoPath, "", tag) if err != nil { commits = []string{} } - + // Generate AI release notes if flags.AIReleaseNotes { // Check cache first (unless --force is used) @@ -549,16 +549,16 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories fmt.Printf(" Warning: Failed to save cache: %v\n", err) } } - + // Print release notes to stdout fmt.Printf("\n%s\n", strings.Repeat("=", 70)) fmt.Printf("Updated Release Notes for %s/%s tag %s:\n", codebergOrg.Name, repoName, tag) fmt.Printf("%s\n", strings.Repeat("-", 70)) fmt.Println(aiNotes) fmt.Printf("%s\n\n", strings.Repeat("=", 70)) - + msg := fmt.Sprintf("Update Codeberg release for %s/%s tag %s?", codebergOrg.Name, repoName, tag) - + updateRelease := false if flags.AutoCreateReleases { fmt.Printf(" Auto-updating Codeberg release for %s/%s tag %s\n", codebergOrg.Name, repoName, tag) @@ -566,7 +566,7 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } else { updateRelease = release.PromptConfirmation(msg) } - + if updateRelease { if err := releaseManager.UpdateCodebergRelease(codebergOrg.Name, repoName, tag, aiNotes); err != nil { fmt.Printf(" Error updating Codeberg release: %v\n", err) @@ -580,25 +580,25 @@ func HandleCheckReleasesForRepos(cfg *config.Config, flags *Flags, repositories } } } - + return 0 } // loadAIReleaseNotesCache loads the AI release notes cache from disk func loadAIReleaseNotesCache(cacheFile string) map[string]string { cache := make(map[string]string) - + data, err := os.ReadFile(cacheFile) if err != nil { // Cache file doesn't exist yet, return empty cache return cache } - + if err := json.Unmarshal(data, &cache); err != nil { fmt.Printf("Warning: Failed to parse AI release notes cache: %v\n", err) return make(map[string]string) } - + fmt.Printf("Loaded AI release notes cache with %d entries\n", len(cache)) return cache } @@ -609,11 +609,11 @@ func saveAIReleaseNotesCache(cacheFile string, cache map[string]string) error { if err != nil { return fmt.Errorf("failed to marshal cache: %w", err) } - + if err := os.WriteFile(cacheFile, data, 0644); err != nil { return fmt.Errorf("failed to write cache file: %w", err) } - + // Don't print on every save since we save after each generation return nil } diff --git a/internal/cli/showcase_handler.go b/internal/cli/showcase_handler.go index 929ce95..642313f 100644 --- a/internal/cli/showcase_handler.go +++ b/internal/cli/showcase_handler.go @@ -20,21 +20,21 @@ func HandleShowcase(cfg *config.Config, flags *Flags) int { // Process all repositories for --sync-all or public sync operations fmt.Println("\nGenerating project showcase for all repositories...") } - + // Create showcase generator generator := showcase.New(cfg, flags.WorkDir) - + // Set AI tool if specified if flags.AITool != "" { generator.SetAITool(flags.AITool) } - + // Generate showcase with optional filter if err := generator.GenerateShowcase(repoFilter, flags.Force); err != nil { log.Printf("ERROR: Failed to generate showcase: %v\n", err) return 1 } - + fmt.Println("Showcase generated successfully!") return 0 -}
\ No newline at end of file +} diff --git a/internal/cli/showcase_only_handler.go b/internal/cli/showcase_only_handler.go index 2055612..1a2a835 100644 --- a/internal/cli/showcase_only_handler.go +++ b/internal/cli/showcase_only_handler.go @@ -14,90 +14,90 @@ import ( // HandleShowcaseOnly handles showcase generation without syncing // It will clone repositories if they don't exist locally, but won't sync changes func HandleShowcaseOnly(cfg *config.Config, flags *Flags) int { - // If a specific repo is requested, only generate for that repo - if flags.SyncRepo != "" { - repo := flags.SyncRepo - - // Ensure the repository is cloned - syncer := sync.New(cfg, flags.WorkDir) - syncer.SetBackupEnabled(false) - if err := syncer.EnsureRepositoryCloned(repo); err != nil { - fmt.Printf("ERROR: Failed to clone %s: %v\n", repo, err) - return 1 - } - - // Generate showcase for just this repository - fmt.Printf("\nGenerating showcase for repository: %s...\n", repo) - generator := showcase.New(cfg, flags.WorkDir) - if flags.AITool != "" { - generator.SetAITool(flags.AITool) - } - if err := generator.GenerateShowcase([]string{repo}, flags.Force); err != nil { - log.Printf("ERROR: Failed to generate showcase for %s: %v\n", repo, err) - return 1 - } - fmt.Println("Showcase generation completed!") - return 0 - } - - // Otherwise, process all repositories - allRepos, err := getAllRepositories(cfg) - if err != nil { - log.Printf("ERROR: Failed to get repositories: %v\n", err) - return 1 - } - if len(allRepos) == 0 { - fmt.Println("No repositories found") - return 1 - } - fmt.Printf("Found %d repositories total\n", len(allRepos)) - - // Create a minimal syncer just for cloning - syncer := sync.New(cfg, flags.WorkDir) - syncer.SetBackupEnabled(false) // Never use backup in showcase-only mode - - // Ensure repositories are cloned (but not synced) - fmt.Println("\nEnsuring repositories are cloned locally...") - for _, repo := range allRepos { - if err := syncer.EnsureRepositoryCloned(repo); err != nil { - fmt.Printf("WARNING: Failed to clone %s: %v\n", repo, err) - // Continue with other repos - } - } - - // Generate showcase for all repositories - fmt.Println("\nGenerating showcase for all repositories...") - generator := showcase.New(cfg, flags.WorkDir) - - // Set AI tool if specified - if flags.AITool != "" { - generator.SetAITool(flags.AITool) - } - - // Pass empty filter to process all repos - if err := generator.GenerateShowcase(nil, flags.Force); err != nil { - log.Printf("ERROR: Failed to generate showcase: %v\n", err) - return 1 - } - - fmt.Println("Showcase generation completed!") - return 0 + // If a specific repo is requested, only generate for that repo + if flags.SyncRepo != "" { + repo := flags.SyncRepo + + // Ensure the repository is cloned + syncer := sync.New(cfg, flags.WorkDir) + syncer.SetBackupEnabled(false) + if err := syncer.EnsureRepositoryCloned(repo); err != nil { + fmt.Printf("ERROR: Failed to clone %s: %v\n", repo, err) + return 1 + } + + // Generate showcase for just this repository + fmt.Printf("\nGenerating showcase for repository: %s...\n", repo) + generator := showcase.New(cfg, flags.WorkDir) + if flags.AITool != "" { + generator.SetAITool(flags.AITool) + } + if err := generator.GenerateShowcase([]string{repo}, flags.Force); err != nil { + log.Printf("ERROR: Failed to generate showcase for %s: %v\n", repo, err) + return 1 + } + fmt.Println("Showcase generation completed!") + return 0 + } + + // Otherwise, process all repositories + allRepos, err := getAllRepositories(cfg) + if err != nil { + log.Printf("ERROR: Failed to get repositories: %v\n", err) + return 1 + } + if len(allRepos) == 0 { + fmt.Println("No repositories found") + return 1 + } + fmt.Printf("Found %d repositories total\n", len(allRepos)) + + // Create a minimal syncer just for cloning + syncer := sync.New(cfg, flags.WorkDir) + syncer.SetBackupEnabled(false) // Never use backup in showcase-only mode + + // Ensure repositories are cloned (but not synced) + fmt.Println("\nEnsuring repositories are cloned locally...") + for _, repo := range allRepos { + if err := syncer.EnsureRepositoryCloned(repo); err != nil { + fmt.Printf("WARNING: Failed to clone %s: %v\n", repo, err) + // Continue with other repos + } + } + + // Generate showcase for all repositories + fmt.Println("\nGenerating showcase for all repositories...") + generator := showcase.New(cfg, flags.WorkDir) + + // Set AI tool if specified + if flags.AITool != "" { + generator.SetAITool(flags.AITool) + } + + // Pass empty filter to process all repos + if err := generator.GenerateShowcase(nil, flags.Force); err != nil { + log.Printf("ERROR: Failed to generate showcase: %v\n", err) + return 1 + } + + fmt.Println("Showcase generation completed!") + return 0 } // getAllRepositories collects all unique repository names from all sources func getAllRepositories(cfg *config.Config) ([]string, error) { repoMap := make(map[string]bool) - + // Add configured repositories for _, repo := range cfg.Repositories { repoMap[repo] = true } - + // Add Codeberg public repos if configured if codebergOrg := cfg.FindCodebergOrg(); codebergOrg != nil { fmt.Printf("Fetching public repositories from Codeberg user/org: %s...\n", codebergOrg.Name) client := codeberg.NewClient(codebergOrg.Name, codebergOrg.CodebergToken) - + repos, err := client.ListPublicRepos() if err != nil { // Try as user @@ -106,17 +106,17 @@ func getAllRepositories(cfg *config.Config) ([]string, error) { fmt.Printf("Warning: Failed to fetch Codeberg repos: %v\n", err) } } - + for _, repo := range repos { repoMap[repo.Name] = true } } - + // Add GitHub public repos if configured if githubOrg := cfg.FindGitHubOrg(); githubOrg != nil { fmt.Printf("Fetching public repositories from GitHub user/org: %s...\n", githubOrg.Name) client := github.NewClient(githubOrg.GitHubToken, githubOrg.Name) - + if client.HasToken() { repos, err := client.ListPublicRepos() if err != nil { @@ -130,12 +130,12 @@ func getAllRepositories(cfg *config.Config) ([]string, error) { fmt.Println("Warning: No GitHub token found, skipping GitHub repos") } } - + // Convert map to slice var allRepos []string for repo := range repoMap { allRepos = append(allRepos, repo) } - + return allRepos, nil } diff --git a/internal/cli/sync_handlers.go b/internal/cli/sync_handlers.go index 09b993a..5c0c9bf 100644 --- a/internal/cli/sync_handlers.go +++ b/internal/cli/sync_handlers.go @@ -20,7 +20,7 @@ func HandleSync(cfg *config.Config, flags *Flags) int { return 1 } } - + // If create-codeberg-repos is enabled, create the repo if needed if flags.CreateCodebergRepos { if err := createCodebergRepoIfNeeded(cfg, flags.SyncRepo); err != nil { @@ -28,20 +28,20 @@ func HandleSync(cfg *config.Config, flags *Flags) int { return 1 } } - + syncer := sync.New(cfg, flags.WorkDir) syncer.SetBackupEnabled(flags.Backup) - if err := syncer.SyncRepository(flags.SyncRepo); err != nil { - log.Fatal("Sync failed:", err) - return 1 - } - // Also sync descriptions for this single repository - descCache := loadDescriptionCache(flags.WorkDir) - syncRepoDescriptions(cfg, flags.DryRun, flags.SyncRepo, "", "", descCache) - if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { - fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) - } - return 0 + if err := syncer.SyncRepository(flags.SyncRepo); err != nil { + log.Fatal("Sync failed:", err) + return 1 + } + // Also sync descriptions for this single repository + descCache := loadDescriptionCache(flags.WorkDir) + syncRepoDescriptions(cfg, flags.DryRun, flags.SyncRepo, "", "", descCache) + if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { + fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) + } + return 0 } // HandleSyncAll handles syncing all configured repositories @@ -71,15 +71,15 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { } } - syncer := sync.New(cfg, flags.WorkDir) - syncer.SetBackupEnabled(flags.Backup) - successCount := 0 - // Load descriptions cache - descCache := loadDescriptionCache(flags.WorkDir) - + syncer := sync.New(cfg, flags.WorkDir) + syncer.SetBackupEnabled(flags.Backup) + successCount := 0 + // Load descriptions cache + descCache := loadDescriptionCache(flags.WorkDir) + for i, repo := range cfg.Repositories { fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(cfg.Repositories), repo) - + // Create GitHub repo if needed if hasGithubClient { if err := createRepoWithClient(&githubClient, repo, fmt.Sprintf("Mirror of %s", repo)); err != nil { @@ -88,7 +88,7 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { return 1 } } - + // Create Codeberg repo if needed if hasCodebergClient { fmt.Printf("Checking/creating Codeberg repository %s...\n", repo) @@ -96,28 +96,28 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { fmt.Printf("Warning: Failed to create Codeberg repo %s: %v\n", repo, err) } } - - if err := syncer.SyncRepository(repo); err != nil { - fmt.Printf("ERROR: Failed to sync %s: %v\n", repo, err) - fmt.Printf("Stopping sync due to error.\n") - return 1 - } - successCount++ - // Sync descriptions after repo sync - syncRepoDescriptions(cfg, flags.DryRun, repo, "", "", descCache) - } - // Save descriptions cache - if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { - fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) - } + + if err := syncer.SyncRepository(repo); err != nil { + fmt.Printf("ERROR: Failed to sync %s: %v\n", repo, err) + fmt.Printf("Stopping sync due to error.\n") + return 1 + } + successCount++ + // Sync descriptions after repo sync + syncRepoDescriptions(cfg, flags.DryRun, repo, "", "", descCache) + } + // Save descriptions cache + if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { + fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) + } fmt.Printf("\nSuccessfully synced all %d repositories!\n", successCount) - + // Print abandoned branches summary if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" { fmt.Print(summary) } - + // Generate script for abandoned branches if scriptPath, err := syncer.GenerateDeleteScript(); err != nil { fmt.Printf("\n⚠️ Failed to generate script: %v\n", err) @@ -144,7 +144,7 @@ func HandleSyncAll(cfg *config.Config, flags *Flags) int { fmt.Printf(strings.Repeat("=", 70)) fmt.Printf("\n") } - + return 0 } @@ -157,9 +157,9 @@ func HandleSyncCodebergPublic(cfg *config.Config, flags *Flags) int { } fmt.Printf("Fetching public repositories from Codeberg user/org: %s...\n", codebergOrg.Name) - + client := codeberg.NewClient(codebergOrg.Name, codebergOrg.CodebergToken) - + // Try fetching as organization first, then as user repos, err := client.ListPublicRepos() if err != nil { @@ -172,15 +172,15 @@ func HandleSyncCodebergPublic(cfg *config.Config, flags *Flags) int { repoNames := codeberg.GetRepoNames(repos) fmt.Printf("Found %d public repositories on Codeberg\n", len(repoNames)) - + if len(repoNames) == 0 { fmt.Println("No public repositories found") return 0 } - // Show the repositories that will be synced - showReposToSync(repoNames) - + // Show the repositories that will be synced + showReposToSync(repoNames) + if flags.DryRun { fmt.Printf("\n[DRY RUN] Would sync %d repositories from Codeberg to GitHub\n", len(repoNames)) if flags.CreateGitHubRepos { @@ -190,11 +190,11 @@ func HandleSyncCodebergPublic(cfg *config.Config, flags *Flags) int { return 0 } } - - if !flags.DryRun { - return syncCodebergRepos(cfg, flags, repos, repoNames) - } - + + if !flags.DryRun { + return syncCodebergRepos(cfg, flags, repos, repoNames) + } + return 0 } @@ -207,14 +207,14 @@ func HandleSyncGitHubPublic(cfg *config.Config, flags *Flags) int { } fmt.Printf("Fetching public repositories from GitHub user/org: %s...\n", githubOrg.Name) - + client := github.NewClient(githubOrg.GitHubToken, githubOrg.Name) if !client.HasToken() { fmt.Println("ERROR: GitHub token required to list repositories") fmt.Println("Set GITHUB_TOKEN env var or create ~/.gitsyncer_github_token file") return 1 } - + repos, err := client.ListPublicRepos() if err != nil { log.Fatal("Failed to fetch repositories:", err) @@ -222,15 +222,15 @@ func HandleSyncGitHubPublic(cfg *config.Config, flags *Flags) int { repoNames := github.GetRepoNames(repos) fmt.Printf("Found %d public repositories on GitHub\n", len(repoNames)) - + if len(repoNames) == 0 { fmt.Println("No public repositories found") return 0 } - // Show the repositories that will be synced - showReposToSync(repoNames) - + // Show the repositories that will be synced + showReposToSync(repoNames) + if flags.DryRun { fmt.Printf("\n[DRY RUN] Would sync %d repositories from GitHub to Codeberg\n", len(repoNames)) if flags.CreateCodebergRepos { @@ -238,11 +238,11 @@ func HandleSyncGitHubPublic(cfg *config.Config, flags *Flags) int { } return 0 } - - if !flags.DryRun { - return syncGitHubRepos(cfg, flags, repos, repoNames) - } - + + if !flags.DryRun { + return syncGitHubRepos(cfg, flags, repos, repoNames) + } + return 0 } @@ -253,14 +253,14 @@ func createGitHubRepoIfNeeded(cfg *config.Config, repoName string) error { if githubOrg == nil { return nil } - + fmt.Printf("Initializing GitHub client for organization: %s\n", githubOrg.Name) githubClient := github.NewClient(githubOrg.GitHubToken, githubOrg.Name) if !githubClient.HasToken() { fmt.Println("Warning: No GitHub token found. Cannot create repository.") return nil } - + fmt.Println("Checking/creating GitHub repository...") return githubClient.CreateRepo(repoName, fmt.Sprintf("Mirror of %s", repoName), false) } @@ -270,14 +270,14 @@ func createCodebergRepoIfNeeded(cfg *config.Config, repoName string) error { if codebergOrg == nil { return nil } - + fmt.Printf("Initializing Codeberg client for organization: %s\n", codebergOrg.Name) codebergClient := codeberg.NewClient(codebergOrg.Name, codebergOrg.CodebergToken) if !codebergClient.HasToken() { fmt.Println("Warning: No Codeberg token found. Cannot create repository.") return nil } - + fmt.Println("Checking/creating Codeberg repository...") return codebergClient.CreateRepo(repoName, fmt.Sprintf("Mirror of %s", repoName), false) } @@ -288,14 +288,14 @@ func initGitHubClient(cfg *config.Config) *github.Client { fmt.Println("Warning: --create-github-repos specified but no GitHub organization found in config") return nil } - + fmt.Printf("Initializing GitHub client for organization: %s\n", githubOrg.Name) githubClient := github.NewClient(githubOrg.GitHubToken, githubOrg.Name) if !githubClient.HasToken() { fmt.Println("Warning: No GitHub token found. Cannot create repositories.") return nil } - + fmt.Println("GitHub client initialized successfully with token") return &githubClient } @@ -346,25 +346,25 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi hasGithubClient = true } } - - fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames)) - // Load descriptions cache - descCache := loadDescriptionCache(flags.WorkDir) - + fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames)) + + // Load descriptions cache + descCache := loadDescriptionCache(flags.WorkDir) + syncer := sync.New(cfg, flags.WorkDir) syncer.SetBackupEnabled(flags.Backup) successCount := 0 - + // Create map for descriptions repoMap := make(map[string]codeberg.Repository) for _, repo := range repos { repoMap[repo.Name] = repo } - - for i, repoName := range repoNames { - fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) - + + for i, repoName := range repoNames { + fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) + // Create GitHub repo if needed if hasGithubClient && flags.CreateGitHubRepos { codebergRepo := repoMap[repoName] @@ -372,42 +372,42 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi if description == "" { description = fmt.Sprintf("Mirror of %s from Codeberg", repoName) } - + fmt.Printf("Checking/creating GitHub repository %s...\n", repoName) err := githubClient.CreateRepo(repoName, description, false) if err != nil { fmt.Printf("Warning: Failed to create GitHub repo %s: %v\n", repoName, err) } } - - if err := syncer.SyncRepository(repoName); err != nil { - fmt.Printf("ERROR: Failed to sync %s: %v\n", repoName, err) - fmt.Printf("Stopping sync due to error.\n") - return 1 - } - successCount++ - - // After syncing, sync descriptions according to precedence - if cbRepo, ok := repoMap[repoName]; ok { - syncRepoDescriptions(cfg, flags.DryRun, repoName, cbRepo.Description, "", descCache) - } else { - syncRepoDescriptions(cfg, flags.DryRun, repoName, "", "", descCache) - } - } - - // Save descriptions cache - if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { - fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) - } - - fmt.Printf("\n=== Summary ===\n") + + if err := syncer.SyncRepository(repoName); err != nil { + fmt.Printf("ERROR: Failed to sync %s: %v\n", repoName, err) + fmt.Printf("Stopping sync due to error.\n") + return 1 + } + successCount++ + + // After syncing, sync descriptions according to precedence + if cbRepo, ok := repoMap[repoName]; ok { + syncRepoDescriptions(cfg, flags.DryRun, repoName, cbRepo.Description, "", descCache) + } else { + syncRepoDescriptions(cfg, flags.DryRun, repoName, "", "", descCache) + } + } + + // Save descriptions cache + if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { + fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) + } + + fmt.Printf("\n=== Summary ===\n") fmt.Printf("Successfully synced: %d repositories\n", successCount) - + // Print abandoned branches summary if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" { fmt.Print(summary) } - + // Generate script for abandoned branches if scriptPath, err := syncer.GenerateDeleteScript(); err != nil { fmt.Printf("\n⚠️ Failed to generate script: %v\n", err) @@ -434,11 +434,11 @@ func syncCodebergRepos(cfg *config.Config, flags *Flags, repos []codeberg.Reposi fmt.Printf(strings.Repeat("=", 70)) fmt.Printf("\n") } - + if !flags.SyncGitHubPublic { return 0 } - + // Print separator for full sync printFullSyncSeparator() return 0 @@ -455,10 +455,10 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository } } - fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames)) + fmt.Printf("\nStarting sync of %d repositories...\n", len(repoNames)) - // Load descriptions cache - descCache := loadDescriptionCache(flags.WorkDir) + // Load descriptions cache + descCache := loadDescriptionCache(flags.WorkDir) syncer := sync.New(cfg, flags.WorkDir) syncer.SetBackupEnabled(flags.Backup) @@ -470,8 +470,8 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository repoMap[repo.Name] = repo } - for i, repoName := range repoNames { - fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) + for i, repoName := range repoNames { + fmt.Printf("\n[%d/%d] Syncing %s...\n", i+1, len(repoNames), repoName) // Create Codeberg repo if needed if hasCodebergClient && flags.CreateCodebergRepos { @@ -488,25 +488,25 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository } } - if err := syncer.SyncRepository(repoName); err != nil { - fmt.Printf("ERROR: Failed to sync %s: %v\n", repoName, err) - fmt.Printf("Stopping sync due to error.\n") - return 1 - } - successCount++ - - // After syncing, sync descriptions according to precedence - if ghRepo, ok := repoMap[repoName]; ok { - syncRepoDescriptions(cfg, flags.DryRun, repoName, "", ghRepo.Description, descCache) - } else { - syncRepoDescriptions(cfg, flags.DryRun, repoName, "", "", descCache) - } - } - - // Save descriptions cache - if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { - fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) - } + if err := syncer.SyncRepository(repoName); err != nil { + fmt.Printf("ERROR: Failed to sync %s: %v\n", repoName, err) + fmt.Printf("Stopping sync due to error.\n") + return 1 + } + successCount++ + + // After syncing, sync descriptions according to precedence + if ghRepo, ok := repoMap[repoName]; ok { + syncRepoDescriptions(cfg, flags.DryRun, repoName, "", ghRepo.Description, descCache) + } else { + syncRepoDescriptions(cfg, flags.DryRun, repoName, "", "", descCache) + } + } + + // Save descriptions cache + if err := saveDescriptionCache(flags.WorkDir, descCache); err != nil { + fmt.Printf("Warning: Failed to save descriptions cache: %v\n", err) + } fmt.Printf("\n=== Summary ===\n") fmt.Printf("Successfully synced: %d repositories\n", successCount) @@ -515,7 +515,7 @@ func syncGitHubRepos(cfg *config.Config, flags *Flags, repos []github.Repository if summary := syncer.GenerateAbandonedBranchSummary(); summary != "" { fmt.Print(summary) } - + // Generate script for abandoned branches if scriptPath, err := syncer.GenerateDeleteScript(); err != nil { fmt.Printf("\n⚠️ Failed to generate script: %v\n", err) diff --git a/internal/cmd/list.go b/internal/cmd/list.go index 90d0eb8..31a39f5 100644 --- a/internal/cmd/list.go +++ b/internal/cmd/list.go @@ -3,8 +3,8 @@ package cmd import ( "os" - "github.com/spf13/cobra" "codeberg.org/snonux/gitsyncer/internal/cli" + "github.com/spf13/cobra" ) var listCmd = &cobra.Command{ @@ -37,4 +37,4 @@ func init() { rootCmd.AddCommand(listCmd) listCmd.AddCommand(listOrgsCmd) listCmd.AddCommand(listReposCmd) -}
\ No newline at end of file +} diff --git a/internal/cmd/manage.go b/internal/cmd/manage.go index 437bd96..6e2ffee 100644 --- a/internal/cmd/manage.go +++ b/internal/cmd/manage.go @@ -5,9 +5,9 @@ import ( "os" "path/filepath" - "github.com/spf13/cobra" "codeberg.org/snonux/gitsyncer/internal/cli" "codeberg.org/snonux/gitsyncer/internal/state" + "github.com/spf13/cobra" ) var force bool @@ -43,7 +43,7 @@ var cleanCmd = &cobra.Command{ flags := buildFlags() flags.Clean = true flags.Force = force - + // TODO: Implement clean handler fmt.Println("Clean command not yet implemented") os.Exit(1) @@ -64,7 +64,7 @@ This is designed for automated weekly synchronization from cron jobs or shell sc flags := buildFlags() flags.BatchRun = true flags.Force = force - + // Check state unless forced if !force { stateManager := state.NewManager(workDir) @@ -72,23 +72,23 @@ This is designed for automated weekly synchronization from cron jobs or shell sc if err != nil { fmt.Fprintf(os.Stderr, "Warning: Failed to load state: %v\n", err) } - + if s.HasRunWithinWeek() { - fmt.Printf("Batch run was already executed within the past week (last run: %s).\n", + fmt.Printf("Batch run was already executed within the past week (last run: %s).\n", s.LastBatchRun.Format("2006-01-02 15:04:05")) stateFile := filepath.Join(workDir, ".gitsyncer-state.json") fmt.Printf("State file location: %s\n", stateFile) fmt.Println("Skipping batch run. Use --force to override.") os.Exit(0) } - + // Store state manager for later flags.BatchRunStateManager = stateManager flags.BatchRunState = s } - + fmt.Println("Starting weekly batch run (full sync + showcase)...") - + // Enable full sync and showcase flags.FullSync = true flags.Showcase = true @@ -96,24 +96,24 @@ This is designed for automated weekly synchronization from cron jobs or shell sc flags.SyncGitHubPublic = true flags.CreateGitHubRepos = true flags.CreateCodebergRepos = true - + // Run sync operations exitCode := cli.HandleSyncCodebergPublic(cfg, flags) if exitCode != 0 { os.Exit(exitCode) } - + exitCode = cli.HandleSyncGitHubPublic(cfg, flags) if exitCode != 0 { os.Exit(exitCode) } - + // Run showcase showcaseCode := cli.HandleShowcase(cfg, flags) if showcaseCode != 0 { os.Exit(showcaseCode) } - + // Save batch run state if flags.BatchRunStateManager != nil && flags.BatchRunState != nil { flags.BatchRunState.UpdateBatchRunTime() @@ -125,7 +125,7 @@ This is designed for automated weekly synchronization from cron jobs or shell sc fmt.Println("Next batch run allowed after one week.") } } - + os.Exit(0) }, } @@ -135,8 +135,8 @@ func init() { manageCmd.AddCommand(deleteRepoCmd) manageCmd.AddCommand(cleanCmd) manageCmd.AddCommand(batchRunCmd) - + // Manage-specific flags cleanCmd.Flags().BoolVarP(&force, "force", "f", false, "force operation without confirmation") batchRunCmd.Flags().BoolVarP(&force, "force", "f", false, "force run even if already run this week") -}
\ No newline at end of file +} diff --git a/internal/cmd/release.go b/internal/cmd/release.go index 9f4e713..656f4a6 100644 --- a/internal/cmd/release.go +++ b/internal/cmd/release.go @@ -3,8 +3,8 @@ package cmd import ( "os" - "github.com/spf13/cobra" "codeberg.org/snonux/gitsyncer/internal/cli" + "github.com/spf13/cobra" ) var ( @@ -16,11 +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 via hexai (stdin pipeline), -with fallback to Claude or aichat.`, + 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 amp (stdin pipeline), +with fallback to hexai, Claude, or aichat.`, } var releaseCheckCmd = &cobra.Command{ @@ -40,7 +40,7 @@ If no repository is specified, checks all configured repositories.`, Run: func(cmd *cobra.Command, args []string) { flags := buildFlags() flags.CheckReleases = true - + if len(args) > 0 { // Check specific repo exitCode := cli.HandleCheckReleasesForRepo(cfg, flags, args[0]) @@ -74,8 +74,8 @@ If no repository is specified, processes all configured repositories.`, # Create for specific repository without AI gitsyncer release create myproject --no-ai-notes - # Use aichat instead of claude for AI release notes - gitsyncer release create --ai-tool aichat`, + # Use amp for AI release notes +gitsyncer release create --ai-tool amp`, Run: func(cmd *cobra.Command, args []string) { flags := buildFlags() flags.CheckReleases = true @@ -83,7 +83,7 @@ If no repository is specified, processes all configured repositories.`, flags.AIReleaseNotes = !noAINotes flags.UpdateReleases = updateExisting flags.AITool = aiTool - + if len(args) > 0 { // Create releases for specific repo exitCode := cli.HandleCheckReleasesForRepo(cfg, flags, args[0]) @@ -100,14 +100,14 @@ func init() { rootCmd.AddCommand(releaseCmd) releaseCmd.AddCommand(releaseCheckCmd) releaseCmd.AddCommand(releaseCreateCmd) - + // Release flags releaseCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "preview what releases would be created") - + // Create-specific flags releaseCreateCmd.Flags().BoolVar(&autoRelease, "auto", false, "skip confirmation prompts") 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; hexai is tried first if available)") + releaseCreateCmd.Flags().StringVar(&aiTool, "ai-tool", "amp", "AI tool to use for release notes (amp, claude, aichat, or hexai; amp is tried first if available)") } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bf4f64a..caf89f1 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -5,15 +5,15 @@ import ( "os" "path/filepath" - "github.com/spf13/cobra" "codeberg.org/snonux/gitsyncer/internal/config" "codeberg.org/snonux/gitsyncer/internal/version" + "github.com/spf13/cobra" ) var ( - cfgFile string - workDir string - cfg *config.Config + cfgFile string + workDir string + cfg *config.Config rootCmd = &cobra.Command{ Use: "gitsyncer", Short: "Synchronize git repositories across multiple platforms", @@ -25,7 +25,7 @@ keeps all branches in sync across different git hosting platforms.`, if cmd.Use == "version" { return } - + // Load configuration var err error cfg, err = config.Load(cfgFile) @@ -35,7 +35,7 @@ keeps all branches in sync across different git hosting platforms.`, fmt.Fprintf(os.Stderr, "See 'gitsyncer help' for more information.\n") os.Exit(1) } - + // Use config WorkDir if no flag was explicitly provided if !cmd.Flags().Changed("work-dir") && cfg.WorkDir != "" { workDir = cfg.WorkDir @@ -54,16 +54,16 @@ func Execute() { func init() { // Global flags rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "configuration file (default: ~/.config/gitsyncer/config.json)") - + // Set default work directory home, err := os.UserHomeDir() defaultWorkDir := ".gitsyncer-work" if err == nil { defaultWorkDir = filepath.Join(home, "git", "gitsyncer-workdir") } - + rootCmd.PersistentFlags().StringVarP(&workDir, "work-dir", "w", defaultWorkDir, "working directory for operations") - + // Version command rootCmd.AddCommand(&cobra.Command{ Use: "version", @@ -72,6 +72,5 @@ func init() { fmt.Println(version.GetVersion()) }, }) - -} +} diff --git a/internal/cmd/showcase.go b/internal/cmd/showcase.go index 6f9f984..a590756 100644 --- a/internal/cmd/showcase.go +++ b/internal/cmd/showcase.go @@ -4,26 +4,25 @@ import ( "fmt" "os" - "github.com/spf13/cobra" "codeberg.org/snonux/gitsyncer/internal/cli" + "github.com/spf13/cobra" ) var ( - forceRegenerate bool - outputPath string - outputFormat string - excludePattern string - showcaseAITool string - showcaseRepo string + forceRegenerate bool + outputPath string + outputFormat string + excludePattern string + showcaseAITool string + showcaseRepo string ) var showcaseCmd = &cobra.Command{ Use: "showcase", Short: "Generate AI-powered project showcase", - Long: `Generate a comprehensive showcase of all your projects using AI. + Long: `Generate a comprehensive showcase of all your projects using AI. This feature creates a formatted document with project summaries, statistics, -and code snippets. By default uses Claude, but will try hexai first if available, -and can also use aichat.`, +and code snippets. By default uses amp, with fallback to hexai, claude, and aichat.`, Example: ` # Generate showcase with cached summaries gitsyncer showcase @@ -40,30 +39,30 @@ and can also use aichat.`, gitsyncer showcase --exclude "test-.*" # Use a specific AI tool - gitsyncer showcase --ai-tool hexai`, - Run: func(cmd *cobra.Command, args []string) { - flags := buildFlags() - flags.Showcase = true - flags.Force = forceRegenerate - flags.AITool = showcaseAITool - if showcaseRepo != "" { - flags.SyncRepo = showcaseRepo - } - - fmt.Println("Running showcase generation for all repositories...") - exitCode := cli.HandleShowcaseOnly(cfg, flags) - os.Exit(exitCode) - }, + gitsyncer showcase --ai-tool amp`, + Run: func(cmd *cobra.Command, args []string) { + flags := buildFlags() + flags.Showcase = true + flags.Force = forceRegenerate + flags.AITool = showcaseAITool + if showcaseRepo != "" { + flags.SyncRepo = showcaseRepo + } + + fmt.Println("Running showcase generation for all repositories...") + exitCode := cli.HandleShowcaseOnly(cfg, flags) + os.Exit(exitCode) + }, } func init() { - rootCmd.AddCommand(showcaseCmd) - - // Showcase flags - showcaseCmd.Flags().BoolVarP(&forceRegenerate, "force", "f", false, "force regeneration of cached summaries") - 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 for summaries: hexai, claude, claude-code, or aichat (default tries hexai→claude→aichat)") - showcaseCmd.Flags().StringVar(&showcaseRepo, "repo", "", "only generate showcase for a single repository") + rootCmd.AddCommand(showcaseCmd) + + // Showcase flags + showcaseCmd.Flags().BoolVarP(&forceRegenerate, "force", "f", false, "force regeneration of cached summaries") + 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", "amp", "AI tool for summaries: amp, hexai, claude, claude-code, or aichat (default tries amp→hexai→claude→aichat)") + showcaseCmd.Flags().StringVar(&showcaseRepo, "repo", "", "only generate showcase for a single repository") } diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go index 86505d5..a28f50d 100644 --- a/internal/cmd/sync.go +++ b/internal/cmd/sync.go @@ -3,18 +3,18 @@ package cmd import ( "os" - "github.com/spf13/cobra" "codeberg.org/snonux/gitsyncer/internal/cli" + "github.com/spf13/cobra" ) var ( - dryRun bool - backup bool - createRepos bool - noReleases bool - autoCreate bool + dryRun bool + backup bool + createRepos bool + noReleases bool + autoCreate bool noAIReleaseNotes bool - syncAITool string + syncAITool string ) var syncCmd = &cobra.Command{ @@ -42,12 +42,12 @@ var syncRepoCmd = &cobra.Command{ # Sync without AI-generated release notes gitsyncer sync repo myproject --no-ai-release-notes - # Auto-create releases using aichat for AI notes - gitsyncer sync repo myproject --auto-create-releases --ai-tool aichat`, + # Auto-create releases using amp for AI notes +gitsyncer sync repo myproject --auto-create-releases --ai-tool amp`, Run: func(cmd *cobra.Command, args []string) { flags := buildFlags() flags.SyncRepo = args[0] - + exitCode := cli.HandleSync(cfg, flags) if exitCode == 0 && !noReleases { cli.HandleCheckReleasesForRepo(cfg, flags, args[0]) @@ -71,7 +71,7 @@ var syncAllCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { flags := buildFlags() flags.SyncAll = true - + exitCode := cli.HandleSyncAll(cfg, flags) if exitCode == 0 && !noReleases { cli.HandleCheckReleases(cfg, flags) @@ -95,11 +95,11 @@ var syncCodebergToGitHubCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { flags := buildFlags() flags.SyncCodebergPublic = true - + if createRepos || autoCreate { flags.CreateGitHubRepos = true } - + exitCode := cli.HandleSyncCodebergPublic(cfg, flags) if exitCode == 0 && !noReleases { cli.HandleCheckReleases(cfg, flags) @@ -123,11 +123,11 @@ var syncGitHubToCodebergCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { flags := buildFlags() flags.SyncGitHubPublic = true - + if createRepos || autoCreate { flags.CreateCodebergRepos = true } - + exitCode := cli.HandleSyncGitHubPublic(cfg, flags) if exitCode == 0 && !noReleases { cli.HandleCheckReleases(cfg, flags) @@ -156,13 +156,13 @@ repositories between GitHub and Codeberg. This is equivalent to the old --full f flags.SyncGitHubPublic = true flags.CreateGitHubRepos = true flags.CreateCodebergRepos = true - + // First sync Codeberg to GitHub exitCode := cli.HandleSyncCodebergPublic(cfg, flags) if exitCode != 0 { os.Exit(exitCode) } - + // Then sync GitHub to Codeberg exitCode = cli.HandleSyncGitHubPublic(cfg, flags) if exitCode == 0 && !noReleases { @@ -174,14 +174,14 @@ repositories between GitHub and Codeberg. This is equivalent to the old --full f func init() { rootCmd.AddCommand(syncCmd) - + // Add subcommands syncCmd.AddCommand(syncRepoCmd) syncCmd.AddCommand(syncAllCmd) syncCmd.AddCommand(syncCodebergToGitHubCmd) syncCmd.AddCommand(syncGitHubToCodebergCmd) syncCmd.AddCommand(syncBidirectionalCmd) - + // Sync flags (available for all sync subcommands) syncCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "preview what would be synced") syncCmd.PersistentFlags().BoolVar(&backup, "backup", false, "include backup locations") @@ -189,20 +189,20 @@ 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; hexai is tried first if available)") + syncCmd.PersistentFlags().StringVar(&syncAITool, "ai-tool", "amp", "AI tool to use for release notes when auto-creating (amp, claude, aichat, or hexai; amp is tried first if available)") } func buildFlags() *cli.Flags { return &cli.Flags{ - ConfigPath: cfgFile, - WorkDir: workDir, - DryRun: dryRun, - Backup: backup, - NoCheckReleases: noReleases, - AutoCreateReleases: autoCreate, - AIReleaseNotes: !noAIReleaseNotes, - AITool: syncAITool, - CreateGitHubRepos: createRepos, + ConfigPath: cfgFile, + WorkDir: workDir, + DryRun: dryRun, + Backup: backup, + NoCheckReleases: noReleases, + AutoCreateReleases: autoCreate, + AIReleaseNotes: !noAIReleaseNotes, + AITool: syncAITool, + CreateGitHubRepos: createRepos, CreateCodebergRepos: createRepos, } } diff --git a/internal/cmd/test.go b/internal/cmd/test.go index 2c50112..ebee3db 100644 --- a/internal/cmd/test.go +++ b/internal/cmd/test.go @@ -4,9 +4,9 @@ import ( "fmt" "os" - "github.com/spf13/cobra" "codeberg.org/snonux/gitsyncer/internal/cli" "codeberg.org/snonux/gitsyncer/internal/config" + "github.com/spf13/cobra" ) var testCmd = &cobra.Command{ @@ -52,11 +52,11 @@ var testConfigCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, "Configuration validation failed: %v\n", err) os.Exit(1) } - + fmt.Println("Configuration validation successful!") fmt.Printf(" Organizations: %d\n", len(cfg.Organizations)) fmt.Printf(" Repositories: %d\n", len(cfg.Repositories)) - + // Check for common issues hasGitHub := false hasCodeberg := false @@ -74,11 +74,11 @@ var testConfigCmd = &cobra.Command{ } } } - + if !hasGitHub && !hasCodeberg { fmt.Println(" ⚠️ Warning: No GitHub or Codeberg organizations configured") } - + os.Exit(0) }, } @@ -88,4 +88,4 @@ func init() { testCmd.AddCommand(testGitHubCmd) testCmd.AddCommand(testCodebergCmd) testCmd.AddCommand(testConfigCmd) -}
\ No newline at end of file +} diff --git a/internal/codeberg/codeberg.go b/internal/codeberg/codeberg.go index 356a14b..7ef583d 100644 --- a/internal/codeberg/codeberg.go +++ b/internal/codeberg/codeberg.go @@ -70,74 +70,74 @@ func (c *Client) loadToken(tokenFromConfig string) { // HasToken returns true if a token is loaded func (c *Client) HasToken() bool { - return c.token != "" + return c.token != "" } // GetRepo fetches a repository by name func (c *Client) GetRepo(repoName string) (Repository, bool, error) { - var repo Repository - url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return repo, false, err - } - if c.HasToken() { - req.Header.Set("Authorization", "token "+c.token) - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return repo, false, err - } - defer resp.Body.Close() - - if resp.StatusCode == 404 { - return repo, false, nil - } - if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return repo, false, fmt.Errorf("failed to get repo: status %d: %s", resp.StatusCode, string(body)) - } - - if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { - return repo, false, fmt.Errorf("failed to parse response: %w", err) - } - return repo, true, nil + var repo Repository + url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return repo, false, err + } + if c.HasToken() { + req.Header.Set("Authorization", "token "+c.token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return repo, false, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return repo, false, nil + } + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return repo, false, fmt.Errorf("failed to get repo: status %d: %s", resp.StatusCode, string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { + return repo, false, fmt.Errorf("failed to parse response: %w", err) + } + return repo, true, nil } // UpdateRepoDescription updates a repository description on Codeberg func (c *Client) UpdateRepoDescription(repoName, description string) error { - if !c.HasToken() { - return fmt.Errorf("Codeberg token required to update repository") - } - - url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName) - payload := map[string]interface{}{ - "description": description, - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - - req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "token "+c.token) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("failed to update Codeberg description: %s - %s", resp.Status, string(b)) - } - return nil + if !c.HasToken() { + return fmt.Errorf("Codeberg token required to update repository") + } + + url := fmt.Sprintf("%s/repos/%s/%s", c.baseURL, c.org, repoName) + payload := map[string]interface{}{ + "description": description, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "token "+c.token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to update Codeberg description: %s - %s", resp.Status, string(b)) + } + return nil } // ListPublicRepos lists all public repositories for an organization @@ -299,7 +299,7 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error { if err != nil { return fmt.Errorf("failed to create repository: status code %d (could not read response)", resp.StatusCode) } - + // Try to parse as JSON error response var errorResp map[string]interface{} if err := json.Unmarshal(body, &errorResp); err == nil { @@ -308,7 +308,7 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error { return fmt.Errorf("failed to create repository: %s (status code %d)", msg, resp.StatusCode) } } - + // If we can't parse JSON, return the raw response return fmt.Errorf("failed to create repository: %s (status code %d)", string(body), resp.StatusCode) } diff --git a/internal/config/config.go b/internal/config/config.go index dce5526..48e6d5f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,14 +19,14 @@ type Organization struct { // Config holds the application configuration type Config struct { - Organizations []Organization `json:"organizations"` - Repositories []string `json:"repositories,omitempty"` - ExcludeBranches []string `json:"exclude_branches,omitempty"` // Regex patterns for branches to exclude - WorkDir string `json:"work_dir,omitempty"` // Working directory for cloning repositories - ExcludeFromShowcase []string `json:"exclude_from_showcase,omitempty"` // Repository names to exclude from showcase - // SkipReleases maps a repository name to a list of tag names for which - // releases should NOT be created on any platform (GitHub/Codeberg) - SkipReleases map[string][]string `json:"skip_releases,omitempty"` + Organizations []Organization `json:"organizations"` + Repositories []string `json:"repositories,omitempty"` + ExcludeBranches []string `json:"exclude_branches,omitempty"` // Regex patterns for branches to exclude + WorkDir string `json:"work_dir,omitempty"` // Working directory for cloning repositories + ExcludeFromShowcase []string `json:"exclude_from_showcase,omitempty"` // Repository names to exclude from showcase + // SkipReleases maps a repository name to a list of tag names for which + // releases should NOT be created on any platform (GitHub/Codeberg) + SkipReleases map[string][]string `json:"skip_releases,omitempty"` } // Load reads and parses the configuration file @@ -102,25 +102,25 @@ func (c *Config) Validate() error { } } - return nil + return nil } // ShouldSkipRelease returns true if the configuration specifies that // the given repo/tag combination should not have a release created. func (c *Config) ShouldSkipRelease(repo, tag string) bool { - if c == nil || c.SkipReleases == nil { - return false - } - tags, ok := c.SkipReleases[repo] - if !ok { - return false - } - for _, t := range tags { - if t == tag { - return true - } - } - return false + if c == nil || c.SkipReleases == nil { + return false + } + tags, ok := c.SkipReleases[repo] + if !ok { + return false + } + for _, t := range tags { + if t == tag { + return true + } + } + return false } // GetGitURL returns the git URL for an organization diff --git a/internal/github/github.go b/internal/github/github.go index 5bcc4f1..238b486 100644 --- a/internal/github/github.go +++ b/internal/github/github.go @@ -196,79 +196,79 @@ func (c *Client) CreateRepo(repoName, description string, private bool) error { // HasToken returns whether a token is configured func (c *Client) HasToken() bool { - return c.token != "" + return c.token != "" } // GetRepo fetches a single repository by name // Returns the repository, a boolean indicating existence, and an error func (c *Client) GetRepo(repoName string) (Repository, bool, error) { - var repo Repository - if c.token == "" { - return repo, false, fmt.Errorf("GitHub token required") - } - - url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return repo, false, err - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Accept", "application/vnd.github.v3+json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return repo, false, err - } - defer resp.Body.Close() - - if resp.StatusCode == 404 { - return repo, false, nil - } - if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return repo, false, fmt.Errorf("failed to get repo: status %d: %s", resp.StatusCode, string(body)) - } - - if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { - return repo, false, fmt.Errorf("failed to decode repo: %w", err) - } - return repo, true, nil + var repo Repository + if c.token == "" { + return repo, false, fmt.Errorf("GitHub token required") + } + + url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return repo, false, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return repo, false, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return repo, false, nil + } + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return repo, false, fmt.Errorf("failed to get repo: status %d: %s", resp.StatusCode, string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil { + return repo, false, fmt.Errorf("failed to decode repo: %w", err) + } + return repo, true, nil } // UpdateRepoDescription updates the repository description func (c *Client) UpdateRepoDescription(repoName, description string) error { - if c.token == "" { - return fmt.Errorf("GitHub token required to update repository") - } - - url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName) - payload := map[string]interface{}{ - "description": description, - } - body, err := json.Marshal(payload) - if err != nil { - return err - } - - req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body)) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.token) - req.Header.Set("Accept", "application/vnd.github.v3+json") - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("failed to update GitHub description: %s - %s", resp.Status, string(b)) - } - return nil + if c.token == "" { + return fmt.Errorf("GitHub token required to update repository") + } + + url := fmt.Sprintf("https://api.github.com/repos/%s/%s", c.org, repoName) + payload := map[string]interface{}{ + "description": description, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("PATCH", url, bytes.NewBuffer(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to update GitHub description: %s - %s", resp.Status, string(b)) + } + return nil } // Repository represents a GitHub repository diff --git a/internal/release/release.go b/internal/release/release.go index 2746389..2acd621 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -52,65 +52,65 @@ func (m *Manager) SetCodebergToken(token string) { // SetAITool sets the AI tool to use for release notes generation func (m *Manager) SetAITool(tool string) { - m.aiTool = tool + m.aiTool = tool } // EnsureCodebergReleasesEnabled ensures that the Codeberg repository has the // Releases feature enabled. If it's disabled, attempts to enable it via API. func (m *Manager) EnsureCodebergReleasesEnabled(owner, repo string) error { - if m.codebergToken == "" { - return fmt.Errorf("Codeberg token is required to manage repository settings") - } - - // Fetch repository metadata - infoURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo) - getReq, err := http.NewRequest("GET", infoURL, nil) - if err != nil { - return err - } - getReq.Header.Set("Authorization", "token "+m.codebergToken) - resp, err := (&http.Client{}).Do(getReq) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("failed to get repo info: %s - %s", resp.Status, string(body)) - } - - var repoInfo struct{ - HasReleases bool `json:"has_releases"` - } - if err := json.NewDecoder(resp.Body).Decode(&repoInfo); err != nil { - return fmt.Errorf("failed to parse repo info: %w", err) - } - if repoInfo.HasReleases { - return nil - } - - // Enable releases via PATCH - payload := map[string]any{"has_releases": true} - body, err := json.Marshal(payload) - if err != nil { - return err - } - patchReq, err := http.NewRequest("PATCH", infoURL, bytes.NewBuffer(body)) - if err != nil { - return err - } - patchReq.Header.Set("Authorization", "token "+m.codebergToken) - patchReq.Header.Set("Content-Type", "application/json") - patchResp, err := (&http.Client{}).Do(patchReq) - if err != nil { - return err - } - defer patchResp.Body.Close() - if patchResp.StatusCode != 200 { - pbody, _ := io.ReadAll(patchResp.Body) - return fmt.Errorf("failed to enable releases: %s - %s", patchResp.Status, string(pbody)) - } - return nil + if m.codebergToken == "" { + return fmt.Errorf("Codeberg token is required to manage repository settings") + } + + // Fetch repository metadata + infoURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo) + getReq, err := http.NewRequest("GET", infoURL, nil) + if err != nil { + return err + } + getReq.Header.Set("Authorization", "token "+m.codebergToken) + resp, err := (&http.Client{}).Do(getReq) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to get repo info: %s - %s", resp.Status, string(body)) + } + + var repoInfo struct { + HasReleases bool `json:"has_releases"` + } + if err := json.NewDecoder(resp.Body).Decode(&repoInfo); err != nil { + return fmt.Errorf("failed to parse repo info: %w", err) + } + if repoInfo.HasReleases { + return nil + } + + // Enable releases via PATCH + payload := map[string]any{"has_releases": true} + body, err := json.Marshal(payload) + if err != nil { + return err + } + patchReq, err := http.NewRequest("PATCH", infoURL, bytes.NewBuffer(body)) + if err != nil { + return err + } + patchReq.Header.Set("Authorization", "token "+m.codebergToken) + patchReq.Header.Set("Content-Type", "application/json") + patchResp, err := (&http.Client{}).Do(patchReq) + if err != nil { + return err + } + defer patchResp.Body.Close() + if patchResp.StatusCode != 200 { + pbody, _ := io.ReadAll(patchResp.Body) + return fmt.Errorf("failed to enable releases: %s - %s", patchResp.Status, string(pbody)) + } + return nil } // isVersionTag checks if a tag name is a version tag @@ -388,67 +388,88 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags return "", fmt.Errorf("failed to get diff: %w", err) } - // 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...") + // 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 amp if not set) + aiTool := m.aiTool + if aiTool == "" { + aiTool = "amp" + } + + // Build a full prompt string for tools that read a single argument + fullPrompt := instr.String() + "\n\n" + input.String() + + var releaseNotes string + + // 1) Try amp 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("amp"); err == nil { + fmt.Println(" Running amp CLI command (stdin payload)...") + cmd := exec.Command("amp", "--execute", instr.String()) + cmd.Stdin = strings.NewReader(input.String()) + cmd.Stderr = os.Stderr + 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 + } + } + } + + // 2) Try hexai as fallback + if releaseNotes == "" { + 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" @@ -467,10 +488,10 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags } } - if releaseNotes == "" && 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") + return "", fmt.Errorf("aichat CLI not found in PATH and fallbacks failed") } cmd := exec.Command("aichat", fullPrompt) @@ -481,6 +502,10 @@ func (m *Manager) GenerateAIReleaseNotes(repoPath, repoName, tag string, allTags releaseNotes = notes } + if releaseNotes == "" && aiTool == "amp" { + return "", fmt.Errorf("amp CLI not found in PATH and fallbacks failed") + } + if releaseNotes == "" { return "", fmt.Errorf("all AI tools failed to generate release notes") } @@ -693,81 +718,83 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e } defer resp.Body.Close() - if resp.StatusCode != 201 { - body, _ := io.ReadAll(resp.Body) - - // Provide a more actionable hint when the repository is missing or owner/repo is wrong - if resp.StatusCode == 404 { - // Probe repository details to distinguish scenarios - probeURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo) - probeReq, perr := http.NewRequest("GET", probeURL, nil) - if perr == nil { - // Prefer probing with the same token - if m.codebergToken != "" { - probeReq.Header.Set("Authorization", "token "+m.codebergToken) - } - if probeResp, perr2 := (&http.Client{}).Do(probeReq); perr2 == nil { - defer probeResp.Body.Close() - if probeResp.StatusCode == 200 { - // Try to detect if releases are disabled - var repoInfo struct{ HasReleases bool `json:"has_releases"` } - if data, rerr := io.ReadAll(probeResp.Body); rerr == nil { - _ = json.Unmarshal(data, &repoInfo) - if !repoInfo.HasReleases { - // Try to enable releases automatically and retry creation - if err := m.EnsureCodebergReleasesEnabled(owner, repo); err != nil { - return fmt.Errorf( - "failed to create Codeberg release: releases are disabled for %s/%s and enabling via API failed: %v. Raw response: %s", - owner, repo, err, string(body), - ) - } - // Retry POST after enabling - retryReq, rerr := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if rerr != nil { - return rerr - } - retryReq.Header.Set("Authorization", "token "+m.codebergToken) - retryReq.Header.Set("Content-Type", "application/json") - retryReq.Header.Set("Accept", "application/json") - retryResp, rerr := (&http.Client{}).Do(retryReq) - if rerr != nil { - return rerr - } - defer retryResp.Body.Close() - if retryResp.StatusCode != 201 { - rbody, _ := io.ReadAll(retryResp.Body) - return fmt.Errorf("failed to create Codeberg release after enabling releases: %s - %s", retryResp.Status, string(rbody)) - } - // Success after enabling - return nil - } - } - // Repo exists and has releases; likely permission/scope issue - return fmt.Errorf( - "failed to create Codeberg release: repo %s/%s exists but returned 404 on release creation. This usually indicates the token lacks write permissions to this repository or owner. Ensure the token belongs to '%s' (or a collaborator/maintainer) and has repository write access. Raw response: %s", - owner, repo, owner, string(body), - ) - } - } - } - return fmt.Errorf( - "failed to create Codeberg release: repository %s/%s not found (404). Verify your Codeberg owner ('organizations[].name') matches the actual owner for this repo and that the repository exists. If needed, create it first, e.g.: gitsyncer sync repo %s --create-codeberg-repos. Raw response: %s", - owner, repo, repo, string(body), - ) - } - - // Special handling for known Gitea issue - if resp.StatusCode == 409 && strings.Contains(string(body), "Release is has no Tag") { - // This is a known Gitea bug - the tag exists but Gitea can't create a release for it - // Check if it's one of the problematic old tags - fmt.Printf("\nWARNING: Codeberg/Gitea returned 'Release is has no Tag' error for tag %s\n", tag) - fmt.Printf("This is a known issue with some old tags. The tag exists but cannot have a release created via API.\n") - fmt.Printf("You may need to create this release manually through the Codeberg web interface.\n\n") - return fmt.Errorf("cannot create release for tag %s due to Gitea API limitation", tag) - } - - return fmt.Errorf("failed to create Codeberg release: %s - %s", resp.Status, string(body)) - } + if resp.StatusCode != 201 { + body, _ := io.ReadAll(resp.Body) + + // Provide a more actionable hint when the repository is missing or owner/repo is wrong + if resp.StatusCode == 404 { + // Probe repository details to distinguish scenarios + probeURL := fmt.Sprintf("https://codeberg.org/api/v1/repos/%s/%s", owner, repo) + probeReq, perr := http.NewRequest("GET", probeURL, nil) + if perr == nil { + // Prefer probing with the same token + if m.codebergToken != "" { + probeReq.Header.Set("Authorization", "token "+m.codebergToken) + } + if probeResp, perr2 := (&http.Client{}).Do(probeReq); perr2 == nil { + defer probeResp.Body.Close() + if probeResp.StatusCode == 200 { + // Try to detect if releases are disabled + var repoInfo struct { + HasReleases bool `json:"has_releases"` + } + if data, rerr := io.ReadAll(probeResp.Body); rerr == nil { + _ = json.Unmarshal(data, &repoInfo) + if !repoInfo.HasReleases { + // Try to enable releases automatically and retry creation + if err := m.EnsureCodebergReleasesEnabled(owner, repo); err != nil { + return fmt.Errorf( + "failed to create Codeberg release: releases are disabled for %s/%s and enabling via API failed: %v. Raw response: %s", + owner, repo, err, string(body), + ) + } + // Retry POST after enabling + retryReq, rerr := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if rerr != nil { + return rerr + } + retryReq.Header.Set("Authorization", "token "+m.codebergToken) + retryReq.Header.Set("Content-Type", "application/json") + retryReq.Header.Set("Accept", "application/json") + retryResp, rerr := (&http.Client{}).Do(retryReq) + if rerr != nil { + return rerr + } + defer retryResp.Body.Close() + if retryResp.StatusCode != 201 { + rbody, _ := io.ReadAll(retryResp.Body) + return fmt.Errorf("failed to create Codeberg release after enabling releases: %s - %s", retryResp.Status, string(rbody)) + } + // Success after enabling + return nil + } + } + // Repo exists and has releases; likely permission/scope issue + return fmt.Errorf( + "failed to create Codeberg release: repo %s/%s exists but returned 404 on release creation. This usually indicates the token lacks write permissions to this repository or owner. Ensure the token belongs to '%s' (or a collaborator/maintainer) and has repository write access. Raw response: %s", + owner, repo, owner, string(body), + ) + } + } + } + return fmt.Errorf( + "failed to create Codeberg release: repository %s/%s not found (404). Verify your Codeberg owner ('organizations[].name') matches the actual owner for this repo and that the repository exists. If needed, create it first, e.g.: gitsyncer sync repo %s --create-codeberg-repos. Raw response: %s", + owner, repo, repo, string(body), + ) + } + + // Special handling for known Gitea issue + if resp.StatusCode == 409 && strings.Contains(string(body), "Release is has no Tag") { + // This is a known Gitea bug - the tag exists but Gitea can't create a release for it + // Check if it's one of the problematic old tags + fmt.Printf("\nWARNING: Codeberg/Gitea returned 'Release is has no Tag' error for tag %s\n", tag) + fmt.Printf("This is a known issue with some old tags. The tag exists but cannot have a release created via API.\n") + fmt.Printf("You may need to create this release manually through the Codeberg web interface.\n\n") + return fmt.Errorf("cannot create release for tag %s due to Gitea API limitation", tag) + } + + return fmt.Errorf("failed to create Codeberg release: %s - %s", resp.Status, string(body)) + } return nil } diff --git a/internal/showcase/ai_context.go b/internal/showcase/ai_context.go index f418894..1c812f8 100644 --- a/internal/showcase/ai_context.go +++ b/internal/showcase/ai_context.go @@ -1,184 +1,214 @@ package showcase import ( - "fmt" - "io/fs" - "os" - "path/filepath" - "sort" - "strings" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" ) // buildAIInputContext prepares a textual context for AI tools when no README exists. // It returns a string to be piped to the AI tool's stdin and a boolean indicating // whether this was sourced from an actual README (true) or synthesized (false). func buildAIInputContext(repoPath string) (string, bool) { - // 1) Try to load a README first - readmeFiles := []string{ - "README.md", "readme.md", "Readme.md", - "README.MD", "README.txt", "readme.txt", - "README", "readme", - } - for _, f := range readmeFiles { - p := filepath.Join(repoPath, f) - if b, err := os.ReadFile(p); err == nil { - return string(b), true - } - } + // 1) Try to load a README first + readmeFiles := []string{ + "README.md", "readme.md", "Readme.md", + "README.MD", "README.txt", "readme.txt", + "README", "readme", + } + for _, f := range readmeFiles { + p := filepath.Join(repoPath, f) + if b, err := os.ReadFile(p); err == nil { + return string(b), true + } + } - // 2) No README: synthesize compact context - var sb strings.Builder + // 2) No README: synthesize compact context + var sb strings.Builder - // File tree (depth-limited) - sb.WriteString("[CONTEXT]\n") - sb.WriteString("Repository does not contain a README.\n") - sb.WriteString("The following is a compact file tree and key manifests/snippets.\n\n") + // File tree (depth-limited) + sb.WriteString("[CONTEXT]\n") + sb.WriteString("Repository does not contain a README.\n") + sb.WriteString("The following is a compact file tree and key manifests/snippets.\n\n") - sb.WriteString("FILE TREE (depth 2):\n") - tree := listFileTree(repoPath, 2, 200) - for _, line := range tree { - sb.WriteString("- ") - sb.WriteString(line) - sb.WriteString("\n") - } - sb.WriteString("\n") + sb.WriteString("FILE TREE (depth 2):\n") + tree := listFileTree(repoPath, 2, 200) + for _, line := range tree { + sb.WriteString("- ") + sb.WriteString(line) + sb.WriteString("\n") + } + sb.WriteString("\n") - // Key manifests we often care about - manifests := []string{ - "go.mod", "go.sum", "package.json", "Cargo.toml", "Cargo.lock", - "pyproject.toml", "requirements.txt", "Makefile", "Dockerfile", - "build.gradle", "pom.xml", "composer.json", - } - wroteHeader := false - for _, m := range manifests { - p := filepath.Join(repoPath, m) - if b, err := os.ReadFile(p); err == nil { - if !wroteHeader { - sb.WriteString("KEY MANIFESTS:\n") - wroteHeader = true - } - sb.WriteString(fmt.Sprintf("--- %s ---\n", m)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } + // Key manifests we often care about + manifests := []string{ + "go.mod", "go.sum", "package.json", "Cargo.toml", "Cargo.lock", + "pyproject.toml", "requirements.txt", "Makefile", "Dockerfile", + "build.gradle", "pom.xml", "composer.json", + } + wroteHeader := false + for _, m := range manifests { + p := filepath.Join(repoPath, m) + if b, err := os.ReadFile(p); err == nil { + if !wroteHeader { + sb.WriteString("KEY MANIFESTS:\n") + wroteHeader = true + } + sb.WriteString(fmt.Sprintf("--- %s ---\n", m)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } - // Source hints: capture first main-ish entry file snippets - // Priority: Go main, Rust main, Node entry, Python main - candidates := []string{ - "cmd", // Go convention - "main.go", - "cmd/main.go", - "src/main.rs", - "index.js", - "src/index.js", - "main.py", - "src/main.py", - } - wroteSrc := false - for _, c := range candidates { - p := filepath.Join(repoPath, c) - info, err := os.Stat(p) - if err != nil { - continue - } - if info.IsDir() { - // collect a few go files under cmd/*/main.go - if c == "cmd" { - _ = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { - if err != nil { return nil } - if d.IsDir() { return nil } - base := filepath.Base(path) - if base == "main.go" { - if b, e := os.ReadFile(path); e == nil { - if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true } - rel, _ := filepath.Rel(repoPath, path) - sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } - return nil - }) - } - continue - } - if b, e := os.ReadFile(p); e == nil { - if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true } - rel, _ := filepath.Rel(repoPath, p) - sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } + // Source hints: capture first main-ish entry file snippets + // Priority: Go main, Rust main, Node entry, Python main + candidates := []string{ + "cmd", // Go convention + "main.go", + "cmd/main.go", + "src/main.rs", + "index.js", + "src/index.js", + "main.py", + "src/main.py", + } + wroteSrc := false + for _, c := range candidates { + p := filepath.Join(repoPath, c) + info, err := os.Stat(p) + if err != nil { + continue + } + if info.IsDir() { + // collect a few go files under cmd/*/main.go + if c == "cmd" { + _ = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + return nil + } + base := filepath.Base(path) + if base == "main.go" { + if b, e := os.ReadFile(path); e == nil { + if !wroteSrc { + sb.WriteString("PRIMARY SOURCE SNIPPETS:\n") + wroteSrc = true + } + rel, _ := filepath.Rel(repoPath, path) + sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } + return nil + }) + } + continue + } + if b, e := os.ReadFile(p); e == nil { + if !wroteSrc { + sb.WriteString("PRIMARY SOURCE SNIPPETS:\n") + wroteSrc = true + } + rel, _ := filepath.Rel(repoPath, p) + sb.WriteString(fmt.Sprintf("--- %s ---\n", rel)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } - // Fallback: include a few top-level .go, .rs, .py, .js files if we still have nothing - if !wroteSrc { - topFiles := listTopFiles(repoPath, []string{".go", ".rs", ".py", ".js", ".ts", ".tsx"}, 5) - for _, f := range topFiles { - if b, e := os.ReadFile(filepath.Join(repoPath, f)); e == nil { - if !wroteSrc { sb.WriteString("PRIMARY SOURCE SNIPPETS:\n"); wroteSrc = true } - sb.WriteString(fmt.Sprintf("--- %s ---\n", f)) - sb.WriteString(trimTo(string(b), 2000)) - sb.WriteString("\n\n") - } - } - } + // Fallback: include a few top-level .go, .rs, .py, .js files if we still have nothing + if !wroteSrc { + topFiles := listTopFiles(repoPath, []string{".go", ".rs", ".py", ".js", ".ts", ".tsx"}, 5) + for _, f := range topFiles { + if b, e := os.ReadFile(filepath.Join(repoPath, f)); e == nil { + if !wroteSrc { + sb.WriteString("PRIMARY SOURCE SNIPPETS:\n") + wroteSrc = true + } + sb.WriteString(fmt.Sprintf("--- %s ---\n", f)) + sb.WriteString(trimTo(string(b), 2000)) + sb.WriteString("\n\n") + } + } + } - // Instruction to the model - sb.WriteString("[TASK]\n") - sb.WriteString("Summarize this project in 1–2 paragraphs: what it does, why it's useful, and how it's implemented. Mention notable tech choices. Be concise and informative.\n") + // Instruction to the model + sb.WriteString("[TASK]\n") + sb.WriteString("Summarize this project in 1–2 paragraphs: what it does, why it's useful, and how it's implemented. Mention notable tech choices. Be concise and informative.\n") - return sb.String(), false + return sb.String(), false } // listFileTree returns a sorted list of relative paths up to a given depth and limit. func listFileTree(root string, maxDepth int, maxEntries int) []string { - var entries []string - var count int - _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { return nil } - if path == root { return nil } - rel, e := filepath.Rel(root, path) - if e != nil { return nil } - // depth check - depth := 1 + strings.Count(rel, string(os.PathSeparator)) - if depth > maxDepth { return fs.SkipDir } - entries = append(entries, rel) - count++ - if count >= maxEntries { return fs.SkipDir } - return nil - }) - sort.Strings(entries) - if len(entries) > maxEntries { - entries = entries[:maxEntries] - } - return entries + var entries []string + var count int + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if path == root { + return nil + } + rel, e := filepath.Rel(root, path) + if e != nil { + return nil + } + // depth check + depth := 1 + strings.Count(rel, string(os.PathSeparator)) + if depth > maxDepth { + return fs.SkipDir + } + entries = append(entries, rel) + count++ + if count >= maxEntries { + return fs.SkipDir + } + return nil + }) + sort.Strings(entries) + if len(entries) > maxEntries { + entries = entries[:maxEntries] + } + return entries } // listTopFiles lists top-level files with certain extensions up to a limit. func listTopFiles(root string, exts []string, limit int) []string { - dir, err := os.ReadDir(root) - if err != nil { return nil } - var out []string - for _, e := range dir { - if e.IsDir() { continue } - name := e.Name() - for _, x := range exts { - if strings.HasSuffix(strings.ToLower(name), strings.ToLower(x)) { - out = append(out, name) - break - } - } - if len(out) >= limit { break } - } - sort.Strings(out) - return out + dir, err := os.ReadDir(root) + if err != nil { + return nil + } + var out []string + for _, e := range dir { + if e.IsDir() { + continue + } + name := e.Name() + for _, x := range exts { + if strings.HasSuffix(strings.ToLower(name), strings.ToLower(x)) { + out = append(out, name) + break + } + } + if len(out) >= limit { + break + } + } + sort.Strings(out) + return out } // trimTo soft-limits content length for inclusion in AI context. func trimTo(s string, max int) string { - if len(s) <= max { return s } - return s[:max] + "\n... [truncated]" + if len(s) <= max { + return s + } + return s[:max] + "\n... [truncated]" } - diff --git a/internal/showcase/code_extractor.go b/internal/showcase/code_extractor.go index 91a0a78..fbf17f6 100644 --- a/internal/showcase/code_extractor.go +++ b/internal/showcase/code_extractor.go @@ -22,34 +22,34 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str // Get the primary language (highest percentage) primaryLang := languages[0].Name - + // Define file extensions for each language langExtensions := map[string][]string{ - "Go": {".go"}, - "Python": {".py"}, - "JavaScript": {".js"}, - "TypeScript": {".ts"}, - "Java": {".java"}, - "C": {".c", ".h"}, - "C++": {".cpp", ".cc", ".cxx", ".hpp"}, - "C/C++": {".h"}, - "C#": {".cs"}, - "Ruby": {".rb"}, - "PHP": {".php"}, - "Swift": {".swift"}, - "Kotlin": {".kt"}, - "Rust": {".rs"}, - "Shell": {".sh", ".bash"}, - "Perl": {".pl", ".pm"}, - "Raku": {".raku", ".rakumod", ".p6", ".pm6"}, - "Haskell": {".hs"}, - "Lua": {".lua"}, - "HTML": {".html", ".htm"}, - "CSS": {".css"}, - "SQL": {".sql"}, - "Make": {"Makefile", "makefile", "GNUmakefile"}, - "HCL": {".tf", ".tfvars", ".hcl"}, - "AWK": {".awk", ".cgi"}, // .cgi files can be AWK scripts + "Go": {".go"}, + "Python": {".py"}, + "JavaScript": {".js"}, + "TypeScript": {".ts"}, + "Java": {".java"}, + "C": {".c", ".h"}, + "C++": {".cpp", ".cc", ".cxx", ".hpp"}, + "C/C++": {".h"}, + "C#": {".cs"}, + "Ruby": {".rb"}, + "PHP": {".php"}, + "Swift": {".swift"}, + "Kotlin": {".kt"}, + "Rust": {".rs"}, + "Shell": {".sh", ".bash"}, + "Perl": {".pl", ".pm"}, + "Raku": {".raku", ".rakumod", ".p6", ".pm6"}, + "Haskell": {".hs"}, + "Lua": {".lua"}, + "HTML": {".html", ".htm"}, + "CSS": {".css"}, + "SQL": {".sql"}, + "Make": {"Makefile", "makefile", "GNUmakefile"}, + "HCL": {".tf", ".tfvars", ".hcl"}, + "AWK": {".awk", ".cgi"}, // .cgi files can be AWK scripts } // Get file extensions for the primary language @@ -79,13 +79,13 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str if info.IsDir() { name := info.Name() // Skip hidden directories and common non-code directories - if strings.HasPrefix(name, ".") && name != "." || - name == "node_modules" || - name == "vendor" || - name == "target" || - name == "dist" || - name == "build" || - name == "__pycache__" { + if strings.HasPrefix(name, ".") && name != "." || + name == "node_modules" || + name == "vendor" || + name == "target" || + name == "dist" || + name == "build" || + name == "__pycache__" { return filepath.SkipDir } return nil @@ -99,7 +99,7 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str // Check if file matches extensions basename := filepath.Base(path) ext := filepath.Ext(path) - + matched := false for _, validExt := range extensions { if validExt == basename || (strings.HasPrefix(validExt, ".") && ext == validExt) { @@ -107,7 +107,7 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str break } } - + // For executable files, also check shebang if primary language is AWK and file has .cgi extension if !matched && primaryLang == "AWK" && ext == ".cgi" && info.Mode()&0111 != 0 { if file, err := os.Open(path); err == nil { @@ -121,14 +121,14 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str file.Close() } } - + if matched { // Skip test files and generated files - if !strings.Contains(basename, "_test") && - !strings.Contains(basename, ".test.") && - !strings.Contains(basename, ".min.") && - !strings.Contains(path, "/test/") && - !strings.Contains(path, "/tests/") { + if !strings.Contains(basename, "_test") && + !strings.Contains(basename, ".test.") && + !strings.Contains(basename, ".min.") && + !strings.Contains(path, "/test/") && + !strings.Contains(path, "/tests/") { codeFiles = append(codeFiles, path) } } @@ -148,10 +148,10 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str rand.Shuffle(len(codeFiles), func(i, j int) { codeFiles[i], codeFiles[j] = codeFiles[j], codeFiles[i] }) - + var snippet string var selectedFile string - + // Try up to 5 files to find a good snippet for i := 0; i < len(codeFiles) && i < 5; i++ { candidateFile := codeFiles[i] @@ -159,28 +159,28 @@ func extractCodeSnippet(repoPath string, languages []LanguageStats) (string, str if err != nil { continue } - + // Check if this snippet has acceptable line lengths if hasAcceptableLineLength(candidateSnippet, 80) { snippet = candidateSnippet selectedFile = candidateFile break } - + // Keep the first valid snippet as fallback if snippet == "" { snippet = candidateSnippet selectedFile = candidateFile } } - + if snippet == "" { return "", "", fmt.Errorf("no valid code snippets found") } // Get relative path for display relPath, _ := filepath.Rel(repoPath, selectedFile) - + return snippet, fmt.Sprintf("%s from `%s`", primaryLang, relPath), nil } @@ -236,9 +236,9 @@ func extractSnippetFromFile(filePath string, minLines, maxLines int) (string, er skipLines := 0 for i, line := range lines { trimmed := strings.TrimSpace(line) - if trimmed != "" && !strings.HasPrefix(trimmed, "import") && - !strings.HasPrefix(trimmed, "package") && !strings.HasPrefix(trimmed, "using") && - !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "from") { + if trimmed != "" && !strings.HasPrefix(trimmed, "import") && + !strings.HasPrefix(trimmed, "package") && !strings.HasPrefix(trimmed, "using") && + !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "from") { skipLines = i break } @@ -260,19 +260,19 @@ func findSmallestCompleteFunction(lines []string) string { end int size int } - + var functions []functionInfo - + // Keywords that typically start functions/methods functionKeywords := []string{ "func ", "function ", "def ", "public ", "private ", "protected ", "static ", "async ", "procedure ", "sub ", "method ", } - + // Find all complete functions for i := 0; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) - + // Check if this line starts a function isFunction := false for _, keyword := range functionKeywords { @@ -281,11 +281,11 @@ func findSmallestCompleteFunction(lines []string) string { break } } - + if !isFunction { continue } - + // Try to find the end of this function functionEnd := findFunctionEnd(lines, i) if functionEnd > i { @@ -300,7 +300,7 @@ func findSmallestCompleteFunction(lines []string) string { } } } - + // Find the smallest function with acceptable line lengths if len(functions) > 0 { // First try to find a function with all lines <= 80 chars @@ -310,7 +310,7 @@ func findSmallestCompleteFunction(lines []string) string { return snippet } } - + // If none found, return the smallest function (will be broken later) smallest := functions[0] for _, f := range functions[1:] { @@ -320,7 +320,7 @@ func findSmallestCompleteFunction(lines []string) string { } return strings.Join(lines[smallest.start:smallest.end+1], "\n") } - + return "" } @@ -329,11 +329,11 @@ func findFunctionEnd(lines []string, start int) int { if start >= len(lines) { return -1 } - + // For brace-based languages braceCount := 0 inFunction := false - + // For Python - track initial indentation isPython := strings.Contains(lines[start], "def ") || strings.Contains(lines[start], "class ") var initialIndent int @@ -346,11 +346,11 @@ func findFunctionEnd(lines []string, start int) int { } } } - + for i := start; i < len(lines); i++ { line := lines[i] trimmed := strings.TrimSpace(line) - + // Handle Python indentation if isPython && i > start { if trimmed == "" { @@ -361,7 +361,7 @@ func findFunctionEnd(lines []string, start int) int { return i - 1 } } - + // Handle brace-based languages for _, ch := range line { if ch == '{' { @@ -375,12 +375,12 @@ func findFunctionEnd(lines []string, start int) int { } } } - + // If we're in Python and reached the end, return the last line if isPython { return len(lines) - 1 } - + return -1 } @@ -391,11 +391,11 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int, "func ", "function ", "def ", "public ", "private ", "protected ", "static ", "async ", "procedure ", "sub ", "method ", } - + // Try to find a function that fits within our size constraints for i := 0; i < len(lines); i++ { line := strings.TrimSpace(lines[i]) - + // Check if this line starts a function isFunction := false for _, keyword := range functionKeywords { @@ -404,11 +404,11 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int, break } } - + if !isFunction { continue } - + // Try to find the end of this function functionEnd := findFunctionEnd(lines, i) if functionEnd > i { @@ -418,7 +418,7 @@ func findCompleteFunctionOrMethod(lines []string, minLines, maxLines int) (int, } } } - + return -1, -1 } @@ -435,7 +435,7 @@ func findInterestingStart(lines []string, snippetSize int) int { line := strings.TrimSpace(lines[i]) // Skip empty lines and comments if line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "#") || - strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") { + strings.HasPrefix(line, "/*") || strings.HasPrefix(line, "*") { continue } @@ -457,10 +457,10 @@ func stripComments(code string) string { lines := strings.Split(code, "\n") var result []string inMultilineComment := false - + for _, line := range lines { trimmed := strings.TrimSpace(line) - + // Handle multi-line comments for C-style languages if strings.Contains(line, "/*") { inMultilineComment = true @@ -475,19 +475,19 @@ func stripComments(code string) string { continue } } - + if inMultilineComment { if strings.Contains(line, "*/") { inMultilineComment = false } continue } - + // Skip single-line comments if trimmed == "" { // Keep empty lines for readability result = append(result, line) - } else if strings.HasPrefix(trimmed, "//") || + } else if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#include") && !strings.HasPrefix(trimmed, "#define") || strings.HasPrefix(trimmed, "<!--") || strings.HasPrefix(trimmed, "*") && len(trimmed) > 1 && trimmed[1] == ' ' { @@ -509,7 +509,7 @@ func stripComments(code string) string { result = append(result, line) } } - + // Remove leading and trailing empty lines for len(result) > 0 && strings.TrimSpace(result[0]) == "" { result = result[1:] @@ -517,13 +517,13 @@ func stripComments(code string) string { for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" { result = result[:len(result)-1] } - + // Remove unnecessary indentation result = removeCommonIndentation(result) - + // Break long lines result = breakLongLines(result, 80) - + return strings.Join(result, "\n") } @@ -532,11 +532,11 @@ func removeCommonIndentation(lines []string) []string { if len(lines) == 0 { return lines } - + // Find the common prefix of whitespace var commonPrefix string firstNonEmpty := -1 - + // Find first non-empty line to use as reference for i, line := range lines { if strings.TrimSpace(line) != "" { @@ -544,11 +544,11 @@ func removeCommonIndentation(lines []string) []string { break } } - + if firstNonEmpty == -1 { return lines } - + // Get the whitespace prefix of the first non-empty line firstLine := lines[firstNonEmpty] for i, ch := range firstLine { @@ -557,18 +557,18 @@ func removeCommonIndentation(lines []string) []string { break } } - + // If the first line has no indentation, return as-is if commonPrefix == "" { return lines } - + // Find the actual common prefix among all non-empty lines for _, line := range lines { if strings.TrimSpace(line) == "" { continue } - + // Reduce commonPrefix to what this line shares for i := 0; i < len(commonPrefix); i++ { if i >= len(line) || line[i] != commonPrefix[i] { @@ -576,17 +576,17 @@ func removeCommonIndentation(lines []string) []string { break } } - + if commonPrefix == "" { break } } - + // If no common prefix found, return as-is if commonPrefix == "" { return lines } - + // Remove common prefix from all lines result := make([]string, len(lines)) prefixLen := len(commonPrefix) @@ -599,7 +599,7 @@ func removeCommonIndentation(lines []string) []string { result[i] = line } } - + return result } @@ -617,18 +617,18 @@ func hasAcceptableLineLength(snippet string, maxLength int) bool { // breakLongLines breaks lines that exceed maxLength at appropriate points func breakLongLines(lines []string, maxLength int) []string { var result []string - + for _, line := range lines { if len(line) <= maxLength { result = append(result, line) continue } - + // Try to break the line intelligently broken := breakLine(line, maxLength) result = append(result, broken...) } - + return result } @@ -638,7 +638,7 @@ func breakLine(line string, maxLength int) []string { if len(line) <= maxLength { return []string{line} } - + // Get the indentation of the original line indent := "" for _, ch := range line { @@ -648,43 +648,43 @@ func breakLine(line string, maxLength int) []string { break } } - + // Common break points in order of preference breakPoints := []string{ - ", ", // After comma - " && ", // Before logical operators + ", ", // After comma + " && ", // Before logical operators " || ", - " + ", // Before arithmetic operators + " + ", // Before arithmetic operators " - ", " * ", " / ", - " = ", // Before assignment + " = ", // Before assignment " := ", - " == ", // Before comparison + " == ", // Before comparison " != ", " < ", " > ", " <= ", " >= ", - "(", // After opening parenthesis - " ", // Any space + "(", // After opening parenthesis + " ", // Any space } - + var result []string remaining := line isFirstLine := true - + for len(remaining) > maxLength { // Find the best break point bestBreak := -1 - + for _, breakPoint := range breakPoints { // Look for break point before maxLength searchIn := remaining if len(searchIn) > maxLength { searchIn = remaining[:maxLength] } - + idx := strings.LastIndex(searchIn, breakPoint) if idx > 0 && idx < maxLength { // For some break points, we want to break after them @@ -696,12 +696,12 @@ func breakLine(line string, maxLength int) []string { } } } - + // If no good break point found, break at maxLength if bestBreak == -1 { bestBreak = maxLength } - + // Add the line lineToAdd := remaining[:bestBreak] if !isFirstLine && !strings.HasPrefix(strings.TrimSpace(lineToAdd), "//") { @@ -709,7 +709,7 @@ func breakLine(line string, maxLength int) []string { lineToAdd = indent + " " + strings.TrimLeft(lineToAdd, " \t") } result = append(result, strings.TrimRight(lineToAdd, " ")) - + // Update remaining remaining = remaining[bestBreak:] if !isFirstLine && !strings.HasPrefix(strings.TrimSpace(remaining), "//") { @@ -717,7 +717,7 @@ func breakLine(line string, maxLength int) []string { } isFirstLine = false } - + // Add the last part if len(remaining) > 0 { if !isFirstLine && !strings.HasPrefix(strings.TrimSpace(remaining), "//") { @@ -725,6 +725,6 @@ func breakLine(line string, maxLength int) []string { } result = append(result, remaining) } - + return result -}
\ No newline at end of file +} diff --git a/internal/showcase/images.go b/internal/showcase/images.go index 66372cb..b6fe6d7 100644 --- a/internal/showcase/images.go +++ b/internal/showcase/images.go @@ -15,7 +15,7 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er // Look for README files readmeFiles := []string{"README.md", "readme.md", "Readme.md", "README.MD"} var readmePath string - + for _, filename := range readmeFiles { path := filepath.Join(repoPath, filename) if _, err := os.Stat(path); err == nil { @@ -23,30 +23,30 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er break } } - + if readmePath == "" { return nil, nil // No README found, not an error } - + // Read README content content, err := os.ReadFile(readmePath) if err != nil { return nil, fmt.Errorf("failed to read README: %w", err) } - + fmt.Printf("Found README at: %s\n", readmePath) - + // Extract image references images := extractImageReferences(string(content)) fmt.Printf("Found %d images in README\n", len(images)) for i, img := range images { fmt.Printf(" Image %d: %s\n", i+1, img) } - + if len(images) == 0 { return nil, nil } - + // Limit to first and last image (max 2) var selectedImages []string if len(images) == 1 { @@ -54,19 +54,19 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er } else { selectedImages = []string{images[0], images[len(images)-1]} } - + // Create showcase subdirectory for this repo repoShowcaseDir := filepath.Join(showcaseDir, "showcase", repoName) if err := os.MkdirAll(repoShowcaseDir, 0755); err != nil { return nil, fmt.Errorf("failed to create showcase directory: %w", err) } - + // Copy images and collect relative paths var copiedImages []string for i, imgPath := range selectedImages { var destFilename string var err error - + if strings.HasPrefix(imgPath, "http://") || strings.HasPrefix(imgPath, "https://") { // Handle URL - download the image // Extract extension from URL, handling query parameters @@ -78,7 +78,7 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er } destFilename = fmt.Sprintf("image-%d%s", i+1, ext) destPath := filepath.Join(repoShowcaseDir, destFilename) - + if err = downloadImage(imgPath, destPath); err != nil { fmt.Printf("Warning: Failed to download image %s: %v\n", imgPath, err) continue @@ -89,31 +89,31 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er if !filepath.IsAbs(imgPath) { srcPath = filepath.Join(repoPath, imgPath) } - + // Check if image exists if _, err := os.Stat(srcPath); err != nil { fmt.Printf("Warning: Image not found: %s\n", srcPath) continue } - + // Generate destination filename ext := filepath.Ext(srcPath) destFilename = fmt.Sprintf("image-%d%s", i+1, ext) destPath := filepath.Join(repoShowcaseDir, destFilename) - + // Copy image if err := copyFile(srcPath, destPath); err != nil { fmt.Printf("Warning: Failed to copy image %s: %v\n", srcPath, err) continue } } - + // Store relative path from showcase directory relativePath := filepath.Join("showcase", repoName, destFilename) copiedImages = append(copiedImages, relativePath) fmt.Printf("Copied/Downloaded image: %s -> %s\n", imgPath, relativePath) } - + return copiedImages, nil } @@ -121,24 +121,24 @@ func extractImagesFromRepo(repoPath, repoName, showcaseDir string) ([]string, er func extractImageReferences(content string) []string { var images []string seen := make(map[string]bool) - + // Regex patterns for markdown images patterns := []string{ - `!\[([^\]]*)\]\(([^)]+)\)`, //  - `<img[^>]+src=["']([^"']+)["'][^>]*>`, // <img src="url"> with quotes - `<img[^>]+src=([^\s>]+)[^>]*>`, // <img src=url> without quotes - `!\[([^\]]*)\]\[([^\]]+)\]`, // ![alt][ref] - `\[([^\]]+)\]:\s*(.+?)(?:\s+"[^"]+")?\s*$`, // [ref]: url "title" + `!\[([^\]]*)\]\(([^)]+)\)`, //  + `<img[^>]+src=["']([^"']+)["'][^>]*>`, // <img src="url"> with quotes + `<img[^>]+src=([^\s>]+)[^>]*>`, // <img src=url> without quotes + `!\[([^\]]*)\]\[([^\]]+)\]`, // ![alt][ref] + `\[([^\]]+)\]:\s*(.+?)(?:\s+"[^"]+")?\s*$`, // [ref]: url "title" } - + fmt.Printf("DEBUG: Content length: %d bytes\n", len(content)) - + // Extract from markdown image syntax for i, pattern := range patterns[:3] { // First three patterns have URLs in different positions re := regexp.MustCompile(pattern) matches := re.FindAllStringSubmatch(content, -1) fmt.Printf("DEBUG: Pattern %d (%s) found %d matches\n", i, pattern, len(matches)) - + for _, match := range matches { var url string if pattern == patterns[0] { @@ -146,18 +146,18 @@ func extractImageReferences(content string) []string { } else { url = match[1] // For <img src="url"> (both with and without quotes) } - + // Clean and validate URL url = strings.TrimSpace(url) - + // Handle markdown image titles - remove anything after a space or quote if idx := strings.IndexAny(url, " \"'"); idx != -1 { url = url[:idx] } url = strings.TrimSpace(url) - + fmt.Printf("DEBUG: Found potential image URL: %s\n", url) - + if isImageFile(url) { fmt.Printf("DEBUG: URL is image file\n") if !seen[url] { @@ -181,7 +181,7 @@ func extractImageReferences(content string) []string { } } } - + // Handle reference-style images refPattern := regexp.MustCompile(patterns[4]) refMatches := refPattern.FindAllStringSubmatch(content, -1) @@ -189,7 +189,7 @@ func extractImageReferences(content string) []string { for _, match := range refMatches { refs[match[1]] = strings.TrimSpace(match[2]) } - + // Find reference-style image uses refUsePattern := regexp.MustCompile(patterns[3]) refUseMatches := refUsePattern.FindAllStringSubmatch(content, -1) @@ -202,7 +202,7 @@ func extractImageReferences(content string) []string { } } } - + return images } @@ -220,7 +220,7 @@ func isImageFile(url string) bool { // isGitHostedImage checks if URL is from GitHub/Codeberg func isGitHostedImage(url string) bool { - return strings.Contains(url, "github.com") || + return strings.Contains(url, "github.com") || strings.Contains(url, "githubusercontent.com") || strings.Contains(url, "codeberg.org") || strings.Contains(url, "codeberg.page") @@ -233,18 +233,18 @@ func copyFile(src, dst string) error { return err } defer sourceFile.Close() - + destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() - + _, err = io.Copy(destFile, sourceFile) if err != nil { return err } - + return destFile.Sync() } @@ -256,11 +256,11 @@ func downloadImage(url, dst string) error { if err != nil { return fmt.Errorf("curl failed: %v, output: %s", err, string(output)) } - + // Verify the file was created if _, err := os.Stat(dst); err != nil { return fmt.Errorf("downloaded file not found: %v", err) } - + return nil -}
\ No newline at end of file +} diff --git a/internal/showcase/language_detector.go b/internal/showcase/language_detector.go index 0f356b8..692f048 100644 --- a/internal/showcase/language_detector.go +++ b/internal/showcase/language_detector.go @@ -14,111 +14,111 @@ import ( func detectLanguages(repoPath string) (languages []LanguageStats, documentation []LanguageStats, err error) { languageLines := make(map[string]int) documentationLines := make(map[string]int) - + // Define common language extensions langExtensions := map[string]string{ - ".go": "Go", - ".py": "Python", - ".js": "JavaScript", - ".ts": "TypeScript", - ".java": "Java", - ".c": "C", - ".cpp": "C++", - ".cc": "C++", - ".cxx": "C++", - ".h": "C/C++", - ".hpp": "C++", - ".hxx": "C++", - ".cs": "C#", - ".rb": "Ruby", - ".php": "PHP", - ".swift": "Swift", - ".kt": "Kotlin", - ".rs": "Rust", - ".scala": "Scala", - ".r": "R", - ".m": "Objective-C", - ".mm": "Objective-C++", - ".sh": "Shell", - ".bash": "Shell", - ".zsh": "Shell", - ".fish": "Shell", - ".pl": "Perl", - ".pm": "Perl", - ".raku": "Raku", - ".rakumod": "Raku", - ".rakudoc": "Raku", + ".go": "Go", + ".py": "Python", + ".js": "JavaScript", + ".ts": "TypeScript", + ".java": "Java", + ".c": "C", + ".cpp": "C++", + ".cc": "C++", + ".cxx": "C++", + ".h": "C/C++", + ".hpp": "C++", + ".hxx": "C++", + ".cs": "C#", + ".rb": "Ruby", + ".php": "PHP", + ".swift": "Swift", + ".kt": "Kotlin", + ".rs": "Rust", + ".scala": "Scala", + ".r": "R", + ".m": "Objective-C", + ".mm": "Objective-C++", + ".sh": "Shell", + ".bash": "Shell", + ".zsh": "Shell", + ".fish": "Shell", + ".pl": "Perl", + ".pm": "Perl", + ".raku": "Raku", + ".rakumod": "Raku", + ".rakudoc": "Raku", ".rakutest": "Raku", - ".p6": "Raku", - ".pm6": "Raku", - ".lua": "Lua", - ".vim": "Vim Script", - ".el": "Emacs Lisp", - ".clj": "Clojure", - ".hs": "Haskell", - ".ml": "OCaml", - ".ex": "Elixir", - ".exs": "Elixir", - ".dart": "Dart", - ".jl": "Julia", - ".nim": "Nim", - ".v": "V", - ".zig": "Zig", - ".html": "HTML", - ".htm": "HTML", - ".css": "CSS", - ".scss": "SCSS", - ".sass": "Sass", - ".less": "Less", - ".xml": "XML", - ".json": "JSON", - ".yaml": "YAML", - ".yml": "YAML", - ".toml": "TOML", - ".ini": "INI", - ".cfg": "Config", - ".conf": "Config", - ".sql": "SQL", - ".tf": "HCL", - ".tfvars": "HCL", - ".hcl": "HCL", - ".awk": "AWK", + ".p6": "Raku", + ".pm6": "Raku", + ".lua": "Lua", + ".vim": "Vim Script", + ".el": "Emacs Lisp", + ".clj": "Clojure", + ".hs": "Haskell", + ".ml": "OCaml", + ".ex": "Elixir", + ".exs": "Elixir", + ".dart": "Dart", + ".jl": "Julia", + ".nim": "Nim", + ".v": "V", + ".zig": "Zig", + ".html": "HTML", + ".htm": "HTML", + ".css": "CSS", + ".scss": "SCSS", + ".sass": "Sass", + ".less": "Less", + ".xml": "XML", + ".json": "JSON", + ".yaml": "YAML", + ".yml": "YAML", + ".toml": "TOML", + ".ini": "INI", + ".cfg": "Config", + ".conf": "Config", + ".sql": "SQL", + ".tf": "HCL", + ".tfvars": "HCL", + ".hcl": "HCL", + ".awk": "AWK", } - + // Define documentation/text extensions docExtensions := map[string]string{ - ".md": "Markdown", - ".rst": "reStructuredText", - ".tex": "LaTeX", - ".txt": "Text", - ".adoc": "AsciiDoc", - ".org": "Org", + ".md": "Markdown", + ".rst": "reStructuredText", + ".tex": "LaTeX", + ".txt": "Text", + ".adoc": "AsciiDoc", + ".org": "Org", } // Special files that indicate specific languages specialFiles := map[string]string{ - "makefile": "Make", - "gnumakefile": "Make", - "dockerfile": "Docker", - "dockerfile.*": "Docker", - "cmakelists.txt": "CMake", - "rakefile": "Ruby", - "gemfile": "Ruby", - "package.json": "JavaScript", - "cargo.toml": "Rust", - "go.mod": "Go", - "go.sum": "Go", - "pom.xml": "Java", - "build.gradle": "Gradle", - "build.gradle.kts": "Kotlin", - "requirements.txt": "Python", - "setup.py": "Python", - "pyproject.toml": "Python", - "composer.json": "PHP", - "*.dockerfile": "Docker", - "containerfile": "Docker", - "jenkinsfile": "Groovy", - "vagrantfile": "Ruby", + "makefile": "Make", + "gnumakefile": "Make", + "dockerfile": "Docker", + "dockerfile.*": "Docker", + "cmakelists.txt": "CMake", + "rakefile": "Ruby", + "gemfile": "Ruby", + "package.json": "JavaScript", + "cargo.toml": "Rust", + "go.mod": "Go", + "go.sum": "Go", + "pom.xml": "Java", + "build.gradle": "Gradle", + "build.gradle.kts": "Kotlin", + "requirements.txt": "Python", + "setup.py": "Python", + "pyproject.toml": "Python", + "composer.json": "PHP", + "*.dockerfile": "Docker", + "containerfile": "Docker", + "jenkinsfile": "Groovy", + "vagrantfile": "Ruby", } // Count lines for each language @@ -131,15 +131,15 @@ func detectLanguages(repoPath string) (languages []LanguageStats, documentation if info.IsDir() { name := info.Name() // Skip hidden directories and common non-code directories - if strings.HasPrefix(name, ".") && name != "." || - name == "node_modules" || - name == "vendor" || - name == "target" || - name == "dist" || - name == "build" || - name == "out" || - name == "__pycache__" || - name == "coverage" { + if strings.HasPrefix(name, ".") && name != "." || + name == "node_modules" || + name == "vendor" || + name == "target" || + name == "dist" || + name == "build" || + name == "out" || + name == "__pycache__" || + name == "coverage" { return filepath.SkipDir } return nil @@ -157,7 +157,7 @@ func detectLanguages(repoPath string) (languages []LanguageStats, documentation // Determine the language or documentation type var language string var isDoc bool - + // Check special files first if lang, ok := specialFiles[basename]; ok { language = lang @@ -204,7 +204,7 @@ func detectLanguages(repoPath string) (languages []LanguageStats, documentation file.Close() } } - + // If we identified a language, count its lines if language != "" { lines, err := countFileLines(path) @@ -317,4 +317,4 @@ func FormatLanguagesWithPercentages(languages []LanguageStats) string { } return strings.Join(parts, ", ") -}
\ No newline at end of file +} diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go index ca8af05..147e714 100644 --- a/internal/showcase/metadata.go +++ b/internal/showcase/metadata.go @@ -22,8 +22,8 @@ type RepoMetadata struct { Languages []LanguageStats // Programming languages with usage statistics Documentation []LanguageStats // Documentation/text files with usage statistics CommitCount int - LinesOfCode int // Lines of code (excluding documentation) - LinesOfDocs int // Lines of documentation + LinesOfCode int // Lines of code (excluding documentation) + LinesOfDocs int // Lines of documentation FirstCommitDate string LastCommitDate string License string @@ -58,7 +58,7 @@ func extractRepoMetadata(repoPath string) (*RepoMetadata, error) { loc += lang.Lines } metadata.LinesOfCode = loc - + locDocs := 0 for _, doc := range metadata.Documentation { locDocs += doc.Lines @@ -101,7 +101,6 @@ func extractRepoMetadata(repoPath string) (*RepoMetadata, error) { return metadata, nil } - // getCommitCount returns the total number of commits func getCommitCount(repoPath string) (int, error) { cmd := exec.Command("git", "-C", repoPath, "rev-list", "--all", "--count") @@ -126,7 +125,7 @@ func countLinesOfCode(repoPath string) (int, error) { `cd "%s" && git ls-files | grep -E '\.(go|py|js|ts|java|c|cpp|h|hpp|cs|rb|php|swift|kt|rs|scala|r|sh|bash|zsh|pl|lua|vim|el|clj|hs|ml|ex|exs|dart|jl|nim|v|zig|html|css|scss|sass|json|xml|yaml|yml|toml|ini|conf|cfg)$' | xargs wc -l 2>/dev/null | tail -n 1 | awk '{print $1}'`, repoPath, )) - + output, err := cmd.Output() if err != nil { // Fallback: try a simpler approach @@ -264,12 +263,12 @@ func getAverageCommitAge(repoPath string, commitCount int) (float64, error) { if line == "" { continue } - + timestamp, err := strconv.ParseInt(line, 10, 64) if err != nil { continue } - + age := (now - float64(timestamp)) / 86400 // Convert to days totalAge += age validCommits++ @@ -310,12 +309,12 @@ func getLatestTag(repoPath string) (string, string, bool, error) { break } } - + if latestTag == "" { // No version-like tags found return "", "", false, nil } - + // Get the date of the latest tag cmd = exec.Command("git", "-C", repoPath, "log", "-1", "--format=%ai", latestTag) dateOutput, err := cmd.Output() @@ -323,7 +322,7 @@ func getLatestTag(repoPath string) (string, string, bool, error) { // Tag exists but couldn't get date return latestTag, "", true, nil } - + // Extract just the date part (YYYY-MM-DD) parts := strings.Fields(string(dateOutput)) tagDate := "" @@ -339,24 +338,24 @@ func getLatestTag(repoPath string) (string, string, bool, error) { func isVersionTag(tag string) bool { // Remove 'v' prefix if present versionStr := strings.TrimPrefix(tag, "v") - + // Check if the remaining string contains at least one digit and one dot hasDigit := false hasDot := false - + for _, ch := range versionStr { if ch >= '0' && ch <= '9' { hasDigit = true } else if ch == '.' { hasDot = true - } else if ch != '-' && ch != '+' && ch != '_' && - (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') { + } else if ch != '-' && ch != '+' && ch != '_' && + (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') { // Allow alphanumeric characters and common separators // but anything else makes it not a version return false } } - + // Must have at least one digit, and either: // - have a dot (e.g., 1.0, 0.1.2) // - be just digits (e.g., 2, 2024) @@ -367,6 +366,6 @@ func isVersionTag(tag string) bool { return true } } - + return hasDigit && hasDot -}
\ No newline at end of file +} diff --git a/internal/showcase/showcase.go b/internal/showcase/showcase.go index 01e0e2b..1c057f3 100644 --- a/internal/showcase/showcase.go +++ b/internal/showcase/showcase.go @@ -1,8 +1,8 @@ package showcase import ( - "context" - "encoding/json" + "context" + "encoding/json" "fmt" "os" "os/exec" @@ -51,7 +51,7 @@ func New(cfg *config.Config, workDir string) *Generator { return &Generator{ config: cfg, workDir: workDir, - aiTool: "claude", // default to claude + aiTool: "amp", // default to amp } } @@ -157,25 +157,25 @@ func (g *Generator) GenerateShowcase(repoFilter []string, forceRegenerate bool) // runCommandWithTimeout runs a command with a short timeout and returns trimmed stdout. // Stderr is included in the error message for easier debugging when GITSYNCER_DEBUG=1. func runCommandWithTimeout(name string, args ...string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, name, args...) - out, err := cmd.CombinedOutput() - if ctx.Err() == context.DeadlineExceeded { - return "", fmt.Errorf("command timed out") - } - if err != nil { - // include a snippet of output for debugging - msg := strings.TrimSpace(string(out)) - if len(msg) > 300 { - msg = msg[:300] + "..." - } - if msg != "" { - return "", fmt.Errorf("%v: %s", err, msg) - } - return "", err - } - return string(out), nil + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("command timed out") + } + if err != nil { + // include a snippet of output for debugging + msg := strings.TrimSpace(string(out)) + if len(msg) > 300 { + msg = msg[:300] + "..." + } + if msg != "" { + return "", fmt.Errorf("%v: %s", err, msg) + } + return "", err + } + return string(out), nil } // getRepositories returns a list of repository directories in the work directory @@ -222,35 +222,48 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool } } - // Determine which AI tool to use (only if we need to run it) - // Prefer hexai if available when default tool is "claude" (aligns with release flow) - selectedTool := g.aiTool - if !haveCachedSummary { - switch g.aiTool { - case "claude", "claude-code", "": - // Try hexai -> claude -> aichat - if _, err := exec.LookPath("hexai"); err == nil { - selectedTool = "hexai" - } else if _, err := exec.LookPath("claude"); err == nil { - selectedTool = "claude" - } else if _, err := exec.LookPath("aichat"); err == nil { - selectedTool = "aichat" - } else { - // No AI tool available; fall back to README-based summary later - selectedTool = "" - } - case "hexai", "aichat": - if _, err := exec.LookPath(g.aiTool); err != nil { - // Requested tool missing; fall back to README-based summary later - selectedTool = "" - } else { - selectedTool = g.aiTool - } - default: - // Unsupported tool configured; fall back to README-based summary later - selectedTool = "" - } - } + // Determine which AI tool to use (only if we need to run it) + // Prefer amp if available when default tool is "" (aligns with release flow) + selectedTool := g.aiTool + if !haveCachedSummary { + switch g.aiTool { + case "amp", "": + // Try amp -> hexai -> claude -> aichat + if _, err := exec.LookPath("amp"); err == nil { + selectedTool = "amp" + } else if _, err := exec.LookPath("hexai"); err == nil { + selectedTool = "hexai" + } else if _, err := exec.LookPath("claude"); err == nil { + selectedTool = "claude" + } else if _, err := exec.LookPath("aichat"); err == nil { + selectedTool = "aichat" + } else { + // No AI tool available; fall back to README-based summary later + selectedTool = "" + } + case "claude", "claude-code": + // Try claude -> hexai -> aichat + if _, err := exec.LookPath("claude"); err == nil { + selectedTool = "claude" + } else if _, err := exec.LookPath("hexai"); err == nil { + selectedTool = "hexai" + } else if _, err := exec.LookPath("aichat"); err == nil { + selectedTool = "aichat" + } else { + selectedTool = "" + } + case "hexai", "aichat": + if _, err := exec.LookPath(g.aiTool); err != nil { + // Requested tool missing; fall back to README-based summary later + selectedTool = "" + } else { + selectedTool = g.aiTool + } + default: + // Unsupported tool configured; fall back to README-based summary later + selectedTool = "" + } + } // Change to repository directory originalDir, err := os.Getwd() @@ -273,50 +286,78 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool // Get the summary - either from cache or by running AI tool var summary string - if haveCachedSummary { - summary = cachedSummary - fmt.Printf("Using cached AI summary\n") - } else { - prompt := "Please provide a 1-2 paragraph summary of this project, explaining what it does, why it's useful, and how it's implemented. Focus on the key features and architecture. Be concise but informative." - - var cmd *exec.Cmd - - switch selectedTool { - case "claude": - fmt.Printf("Running Claude command:\n") - fmt.Printf(" claude --model sonnet \"%s\"\n", prompt) - cmd = exec.Command("claude", "--model", "sonnet", prompt) - case "hexai": - // Use README content as stdin and pass the prompt as argument - fmt.Printf("Running hexai command (stdin payload)\n") - // Find README file - readmeFiles := []string{ - "README.md", "readme.md", "Readme.md", - "README.MD", "README.txt", "readme.txt", - "README", "readme", - } - var readmeContent []byte - var readmeFound bool - for _, readmeFile := range readmeFiles { - content, err := os.ReadFile(readmeFile) - if err == nil { - readmeContent = content - readmeFound = true - fmt.Printf(" Using %s as input\n", readmeFile) - break - } - } - if readmeFound { - fmt.Printf(" echo <README content> | hexai \"%s\"\n", prompt) - cmd = exec.Command("hexai", prompt) - cmd.Stdin = strings.NewReader(string(readmeContent)) - } else { - // Will fall back below - cmd = nil - } - case "aichat": - // For aichat, we need to read README.md and pipe it to aichat - fmt.Printf("Running aichat command:\n") + if haveCachedSummary { + summary = cachedSummary + fmt.Printf("Using cached AI summary\n") + } else { + prompt := "Please provide a 1-2 paragraph summary of this project, explaining what it does, why it's useful, and how it's implemented. Focus on the key features and architecture. Be concise but informative." + + var cmd *exec.Cmd + + switch selectedTool { + case "amp": + // Use README content as stdin and pass the prompt as --execute argument + fmt.Printf("Running amp command (stdin payload)\n") + // Find README file + readmeFiles := []string{ + "README.md", "readme.md", "Readme.md", + "README.MD", "README.txt", "readme.txt", + "README", "readme", + } + var readmeContent []byte + var readmeFound bool + for _, readmeFile := range readmeFiles { + content, err := os.ReadFile(readmeFile) + if err == nil { + readmeContent = content + readmeFound = true + fmt.Printf(" Using %s as input\n", readmeFile) + break + } + } + if readmeFound { + fmt.Printf(" echo <README content> | amp --execute \"%s\"\n", prompt) + cmd = exec.Command("amp", "--execute", prompt) + cmd.Stdin = strings.NewReader(string(readmeContent)) + } else { + // Will fall back below + cmd = nil + } + case "claude": + fmt.Printf("Running Claude command:\n") + fmt.Printf(" claude --model sonnet \"%s\"\n", prompt) + cmd = exec.Command("claude", "--model", "sonnet", prompt) + case "hexai": + // Use README content as stdin and pass the prompt as argument + fmt.Printf("Running hexai command (stdin payload)\n") + // Find README file + readmeFiles := []string{ + "README.md", "readme.md", "Readme.md", + "README.MD", "README.txt", "readme.txt", + "README", "readme", + } + var readmeContent []byte + var readmeFound bool + for _, readmeFile := range readmeFiles { + content, err := os.ReadFile(readmeFile) + if err == nil { + readmeContent = content + readmeFound = true + fmt.Printf(" Using %s as input\n", readmeFile) + break + } + } + if readmeFound { + fmt.Printf(" echo <README content> | hexai \"%s\"\n", prompt) + cmd = exec.Command("hexai", prompt) + cmd.Stdin = strings.NewReader(string(readmeContent)) + } else { + // Will fall back below + cmd = nil + } + case "aichat": + // For aichat, we need to read README.md and pipe it to aichat + fmt.Printf("Running aichat command:\n") // Find README file readmeFiles := []string{ @@ -337,46 +378,46 @@ func (g *Generator) generateProjectSummary(repoName string, forceRegenerate bool } } - if readmeFound { - fmt.Printf(" echo <README content> | aichat \"%s\"\n", prompt) - cmd = exec.Command("aichat", prompt) - cmd.Stdin = strings.NewReader(string(readmeContent)) - } else { - // Will fall back below - cmd = nil - } - default: - // No/unsupported tool; will fall back below - cmd = nil - } - - if cmd != nil { - if output, err := cmd.Output(); err == nil { - summary = strings.TrimSpace(string(output)) - } - } - - // Fallback: create a minimal summary from README if AI unavailable/failed - if summary == "" { - readmeFiles := []string{ - "README.md", "readme.md", "Readme.md", - "README.MD", "README.txt", "readme.txt", - "README", "readme", - } - for _, readmeFile := range readmeFiles { - if content, err := os.ReadFile(readmeFile); err == nil { - parts := strings.Split(strings.TrimSpace(string(content)), "\n\n") - if len(parts) > 0 { - summary = strings.TrimSpace(parts[0]) - break - } - } - } - if summary == "" { - summary = fmt.Sprintf("%s: source code repository.", repoName) - } - } - } + if readmeFound { + fmt.Printf(" echo <README content> | aichat \"%s\"\n", prompt) + cmd = exec.Command("aichat", prompt) + cmd.Stdin = strings.NewReader(string(readmeContent)) + } else { + // Will fall back below + cmd = nil + } + default: + // No/unsupported tool; will fall back below + cmd = nil + } + + if cmd != nil { + if output, err := cmd.Output(); err == nil { + summary = strings.TrimSpace(string(output)) + } + } + + // Fallback: create a minimal summary from README if AI unavailable/failed + if summary == "" { + readmeFiles := []string{ + "README.md", "readme.md", "Readme.md", + "README.MD", "README.txt", "readme.txt", + "README", "readme", + } + for _, readmeFile := range readmeFiles { + if content, err := os.ReadFile(readmeFile); err == nil { + parts := strings.Split(strings.TrimSpace(string(content)), "\n\n") + if len(parts) > 0 { + summary = strings.TrimSpace(parts[0]) + break + } + } + } + if summary == "" { + summary = fmt.Sprintf("%s: source code repository.", repoName) + } + } + } // Build URLs codebergURL := "" diff --git a/internal/state/state.go b/internal/state/state.go index af90879..c2a62ed 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -76,4 +76,4 @@ func (s *State) HasRunWithinWeek() bool { // UpdateBatchRunTime updates the last batch run timestamp to now func (s *State) UpdateBatchRunTime() { s.LastBatchRun = time.Now() -}
\ No newline at end of file +} diff --git a/internal/sync/branch_analyzer.go b/internal/sync/branch_analyzer.go index 3038fcc..7499996 100644 --- a/internal/sync/branch_analyzer.go +++ b/internal/sync/branch_analyzer.go @@ -12,28 +12,28 @@ import ( // BranchInfo holds information about a branch type BranchInfo struct { - Name string - LastCommit time.Time - Remote string - IsAbandoned bool - AbandonReason string + Name string + LastCommit time.Time + Remote string + IsAbandoned bool + AbandonReason string RemotesWithBranch []string // List of remotes that have this branch } // AbandonedBranchReport holds the analysis results type AbandonedBranchReport struct { - MainBranchUpdated bool - MainBranchLastCommit time.Time - AbandonedBranches []BranchInfo + MainBranchUpdated bool + MainBranchLastCommit time.Time + AbandonedBranches []BranchInfo AbandonedIgnoredBranches []BranchInfo // Abandoned branches that match exclusion patterns - TotalBranches int - TotalIgnoredBranches int + TotalBranches int + TotalIgnoredBranches int } // analyzeAbandonedBranches analyzes branches to find abandoned ones func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { report := &AbandonedBranchReport{ - AbandonedBranches: []BranchInfo{}, + AbandonedBranches: []BranchInfo{}, AbandonedIgnoredBranches: []BranchInfo{}, } @@ -42,11 +42,11 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { if err != nil { return nil, fmt.Errorf("failed to get branches: %w", err) } - + // Filter branches based on exclusion patterns branches := s.branchFilter.FilterBranches(allBranches) report.TotalBranches = len(branches) - + // Get excluded branches for separate analysis excludedBranches := s.branchFilter.GetExcludedBranches(allBranches) report.TotalIgnoredBranches = len(excludedBranches) @@ -69,7 +69,7 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { // Analyze each branch sixMonthsAgo := time.Now().AddDate(0, -6, 0) - + for _, branch := range branches { // Skip main/master branches if branch == "main" || branch == "master" { @@ -89,7 +89,7 @@ func (s *Syncer) analyzeAbandonedBranches() (*AbandonedBranchReport, error) { report.AbandonedBranches = append(report.AbandonedBranches, *branchInfo) } } - + // Also analyze ignored branches for abandonment for _, branch := range excludedBranches { // Skip main/master branches even if they match exclusion patterns @@ -127,7 +127,7 @@ func (s *Syncer) findMainBranch(branches []string) string { // getBranchInfo gets information about a specific branch func (s *Syncer) getBranchInfo(branch string) (*BranchInfo, error) { info := &BranchInfo{ - Name: branch, + Name: branch, RemotesWithBranch: []string{}, } @@ -137,18 +137,18 @@ func (s *Syncer) getBranchInfo(branch string) (*BranchInfo, error) { for i := range s.config.Organizations { org := &s.config.Organizations[i] - + // Skip backup locations if backup is not enabled if org.BackupLocation && !s.backupEnabled { continue } - + remoteName := s.getRemoteName(org) if s.remoteBranchExists(remoteName, branch) { // Add this remote to the list info.RemotesWithBranch = append(info.RemotesWithBranch, remoteName) - + // Get last commit date for this branch on this remote commitTime, err := s.getLastCommitTime(remoteName, branch) if err == nil && (latestCommit.IsZero() || commitTime.After(latestCommit)) { @@ -211,22 +211,22 @@ func formatAbandonedBranchReport(report *AbandonedBranchReport, repoName string) var sb strings.Builder sb.WriteString(fmt.Sprintf("\n🔍 Abandoned branches in %s:\n", repoName)) sb.WriteString(fmt.Sprintf(" Main branch last updated: %s\n", report.MainBranchLastCommit.Format("2006-01-02"))) - + if len(report.AbandonedBranches) > 0 { sb.WriteString(fmt.Sprintf(" Found %d abandoned branches (no commits for 6+ months):\n\n", len(report.AbandonedBranches))) for _, branch := range report.AbandonedBranches { - sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n", - branch.Name, + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n", + branch.Name, branch.LastCommit.Format("2006-01-02"), branch.AbandonReason)) } } - + if len(report.AbandonedIgnoredBranches) > 0 { sb.WriteString(fmt.Sprintf("\n Found %d abandoned IGNORED branches (no commits for 6+ months):\n\n", len(report.AbandonedIgnoredBranches))) for _, branch := range report.AbandonedIgnoredBranches { - sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n", - branch.Name, + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s, %s)\n", + branch.Name, branch.LastCommit.Format("2006-01-02"), branch.AbandonReason)) } @@ -244,7 +244,7 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string { totalAbandoned := 0 totalAbandonedIgnored := 0 reposWithAbandoned := 0 - + for _, report := range s.abandonedReports { if len(report.AbandonedBranches) > 0 || len(report.AbandonedIgnoredBranches) > 0 { totalAbandoned += len(report.AbandonedBranches) @@ -274,30 +274,30 @@ func (s *Syncer) GenerateAbandonedBranchSummary() string { if len(report.AbandonedBranches) == 0 && len(report.AbandonedIgnoredBranches) == 0 { continue } - + totalBranches := len(report.AbandonedBranches) + len(report.AbandonedIgnoredBranches) sb.WriteString(fmt.Sprintf("📁 %s (%d branches):\n", repoName, totalBranches)) - + // Regular abandoned branches if len(report.AbandonedBranches) > 0 { sb.WriteString(" Regular branches:\n") for _, branch := range report.AbandonedBranches { - sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n", - branch.Name, + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n", + branch.Name, branch.LastCommit.Format("2006-01-02"))) } } - + // Ignored abandoned branches if len(report.AbandonedIgnoredBranches) > 0 { sb.WriteString(" Ignored branches:\n") for _, branch := range report.AbandonedIgnoredBranches { - sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n", - branch.Name, + sb.WriteString(fmt.Sprintf(" - %s (last commit: %s)\n", + branch.Name, branch.LastCommit.Format("2006-01-02"))) } } - + sb.WriteString("\n") } @@ -324,7 +324,7 @@ func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName sb.WriteString("# === REGULAR BRANCHES ===\n") for _, branch := range report.AbandonedBranches { sb.WriteString(fmt.Sprintf("# Branch: %s (last commit: %s)\n", branch.Name, branch.LastCommit.Format("2006-01-02"))) - + // Delete from all remotes that have this branch if len(branch.RemotesWithBranch) > 0 { sb.WriteString("# Delete from remotes:\n") @@ -332,19 +332,19 @@ func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName sb.WriteString(fmt.Sprintf("git push %s --delete %s\n", remote, branch.Name)) } } - + // Delete local branch sb.WriteString("# Delete local branch:\n") sb.WriteString(fmt.Sprintf("git branch -D %s\n\n", branch.Name)) } } - + // Process ignored abandoned branches if len(report.AbandonedIgnoredBranches) > 0 { sb.WriteString("# === IGNORED BRANCHES ===\n") for _, branch := range report.AbandonedIgnoredBranches { sb.WriteString(fmt.Sprintf("# Branch: %s (last commit: %s) [IGNORED]\n", branch.Name, branch.LastCommit.Format("2006-01-02"))) - + // Delete from all remotes that have this branch if len(branch.RemotesWithBranch) > 0 { sb.WriteString("# Delete from remotes:\n") @@ -352,7 +352,7 @@ func (s *Syncer) GenerateDeleteCommands(report *AbandonedBranchReport, repoName sb.WriteString(fmt.Sprintf("git push %s --delete %s\n", remote, branch.Name)) } } - + // Delete local branch sb.WriteString("# Delete local branch:\n") sb.WriteString(fmt.Sprintf("git branch -D %s\n\n", branch.Name)) @@ -375,7 +375,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { totalAbandoned += len(report.AbandonedBranches) totalIgnored += len(report.AbandonedIgnoredBranches) } - + if totalAbandoned == 0 && totalIgnored == 0 { return "", nil } @@ -540,7 +540,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { fmt.Fprintf(file, " fi\n") fmt.Fprintf(file, "else\n") fmt.Fprintf(file, " echo \" 🔸 Deleting branch: %s (last commit: %s)\"\n", branch.Name, branch.LastCommit.Format("2006-01-02")) - + // Check if we're on the branch to be deleted, and switch to main/master if so fmt.Fprintf(file, " # Check if we're on the branch to be deleted\n") fmt.Fprintf(file, " current_branch=$(git branch --show-current)\n") @@ -558,12 +558,12 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { fmt.Fprintf(file, " if [[ \"$current_branch\" == \"%s\" ]] && [[ -z \"$main_branch\" ]]; then\n", branch.Name) fmt.Fprintf(file, " continue\n") fmt.Fprintf(file, " fi\n") - + // Delete from remotes for _, remote := range branch.RemotesWithBranch { fmt.Fprintf(file, " execute_cmd git push %s --delete \"%s\"\n", remote, branch.Name) } - + // Delete local branch fmt.Fprintf(file, " execute_cmd git branch -D \"%s\"\n", branch.Name) fmt.Fprintf(file, "fi\n\n") @@ -580,7 +580,7 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { fmt.Fprintf(file, " fi\n") fmt.Fprintf(file, "else\n") fmt.Fprintf(file, " echo \" 🔹 Deleting ignored branch: %s (last commit: %s)\"\n", branch.Name, branch.LastCommit.Format("2006-01-02")) - + // Check if we're on the branch to be deleted, and switch to main/master if so fmt.Fprintf(file, " # Check if we're on the branch to be deleted\n") fmt.Fprintf(file, " current_branch=$(git branch --show-current)\n") @@ -598,12 +598,12 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { fmt.Fprintf(file, " if [[ \"$current_branch\" == \"%s\" ]] && [[ -z \"$main_branch\" ]]; then\n", branch.Name) fmt.Fprintf(file, " continue\n") fmt.Fprintf(file, " fi\n") - + // Delete from remotes for _, remote := range branch.RemotesWithBranch { fmt.Fprintf(file, " execute_cmd git push %s --delete \"%s\"\n", remote, branch.Name) } - + // Delete local branch fmt.Fprintf(file, " execute_cmd git branch -D \"%s\"\n", branch.Name) fmt.Fprintf(file, "fi\n\n") @@ -634,4 +634,4 @@ func (s *Syncer) GenerateDeleteScript() (string, error) { } return scriptPath, nil -}
\ No newline at end of file +} diff --git a/internal/sync/branch_filter.go b/internal/sync/branch_filter.go index 3a4fd40..33f7895 100644 --- a/internal/sync/branch_filter.go +++ b/internal/sync/branch_filter.go @@ -77,7 +77,7 @@ func FormatExclusionReport(excludedBranches []string, patterns []string) string var sb strings.Builder sb.WriteString(fmt.Sprintf("\n🚫 Excluded %d branches based on patterns:\n", len(excludedBranches))) - + // Show patterns sb.WriteString(" Patterns: ") for i, pattern := range patterns { @@ -87,12 +87,12 @@ func FormatExclusionReport(excludedBranches []string, patterns []string) string sb.WriteString(fmt.Sprintf("'%s'", pattern)) } sb.WriteString("\n") - + // Show excluded branches sb.WriteString(" Excluded branches:\n") for _, branch := range excludedBranches { sb.WriteString(fmt.Sprintf(" - %s\n", branch)) } - + return sb.String() -}
\ No newline at end of file +} diff --git a/internal/sync/branch_sync.go b/internal/sync/branch_sync.go index 02f8964..eafb551 100644 --- a/internal/sync/branch_sync.go +++ b/internal/sync/branch_sync.go @@ -9,7 +9,7 @@ import ( // trackRemotesWithBranch finds which remotes have a specific branch func (s *Syncer) trackRemotesWithBranch(branch string, remotes map[string]*config.Organization) map[string]bool { remotesWithBranch := make(map[string]bool) - + for remoteName, org := range remotes { // Skip checking backup locations as we don't sync from them if org.BackupLocation { @@ -19,7 +19,7 @@ func (s *Syncer) trackRemotesWithBranch(branch string, remotes map[string]*confi remotesWithBranch[remoteName] = true } } - + return remotesWithBranch } @@ -29,14 +29,14 @@ func mergeFromRemotes(branch string, remotesWithBranch map[string]bool) error { fmt.Printf(" Branch %s is local only, will push to all remotes\n", branch) return nil } - + // Merge changes from all remotes that have this branch for remoteName := range remotesWithBranch { if err := mergeBranch(remoteName, branch); err != nil { return err } } - + return nil } @@ -56,7 +56,7 @@ func pushToAllRemotes(branch string, remotes map[string]*config.Organization, re return err } } - + return nil } @@ -69,4 +69,4 @@ func (s *Syncer) syncAllBranches(branches []string, remotes map[string]*config.O } } return nil -}
\ No newline at end of file +} diff --git a/internal/sync/git_operations.go b/internal/sync/git_operations.go index efa27f5..0bd698e 100644 --- a/internal/sync/git_operations.go +++ b/internal/sync/git_operations.go @@ -242,24 +242,24 @@ func createSSHBareRepository(sshHost, repoPath string) error { if len(parts) != 2 { return fmt.Errorf("invalid SSH host format: %s", sshHost) } - + userHost := parts[0] basePath := parts[1] - + // Full path to the repository fullRepoPath := fmt.Sprintf("%s/%s.git", basePath, repoPath) - + fmt.Printf("Creating bare repository at %s:%s\n", userHost, fullRepoPath) - + // Create the repository directory and initialize as bare commands := fmt.Sprintf("mkdir -p %s && cd %s && git init --bare", fullRepoPath, fullRepoPath) cmd := exec.Command("ssh", userHost, commands) output, err := cmd.CombinedOutput() - + if err != nil { return fmt.Errorf("failed to create bare repository: %w\n%s", err, string(output)) } - + fmt.Printf("Successfully created bare repository at %s:%s\n", userHost, fullRepoPath) return nil } @@ -280,18 +280,18 @@ func pushBranchWithBackupSupport(remoteName, branch string, remoteHasBranch bool if err != nil { return fmt.Errorf("failed to get remote URL: %w", err) } - + // Extract repo name from URL repoName := extractRepoName(remoteURL) if repoName == "" { return fmt.Errorf("failed to extract repository name from URL: %s", remoteURL) } - + // Create the bare repository if err := createSSHBareRepository(org.Host, repoName); err != nil { return fmt.Errorf("failed to create SSH repository: %w", err) } - + // Try pushing again cmd = exec.Command("git", "push", remoteName, branch, "--tags") if err := cmd.Run(); err != nil { @@ -300,7 +300,7 @@ func pushBranchWithBackupSupport(remoteName, branch string, remoteHasBranch bool fmt.Printf(" Successfully pushed to newly created backup repository\n") return nil } - + fmt.Printf(" Note: Remote repository %s does not exist - must be created manually\n", remoteName) fmt.Printf(" Skipping push to %s\n", remoteName) return nil // Not an error, just skip @@ -341,7 +341,7 @@ func getRemoteURL(remoteName string) (string, error) { func extractRepoName(url string) string { // Remove .git suffix if present url = strings.TrimSuffix(url, ".git") - + // Extract the last component of the path parts := strings.Split(url, "/") if len(parts) > 0 { diff --git a/internal/sync/repository_setup.go b/internal/sync/repository_setup.go index 3ebafbd..7e2c40e 100644 --- a/internal/sync/repository_setup.go +++ b/internal/sync/repository_setup.go @@ -54,12 +54,12 @@ func (s *Syncer) setupNewRepository(repoPath string) error { continue // Skip the first org we already cloned from } org := &s.config.Organizations[i] - + // Skip backup locations if backup is not enabled if org.BackupLocation && !s.backupEnabled { continue } - + if err := s.addRemote(repoPath, org); err != nil { return fmt.Errorf("failed to add remote %s: %w", s.getRemoteName(org), err) } @@ -75,12 +75,12 @@ func (s *Syncer) setupExistingRepository(repoPath string) error { // Check and add any missing remotes for i := range s.config.Organizations { org := &s.config.Organizations[i] - + // Skip backup locations if backup is not enabled if org.BackupLocation && !s.backupEnabled { continue } - + remoteName := s.getRemoteName(org) // Check if remote exists @@ -115,14 +115,14 @@ func (s *Syncer) getRemotesMap() map[string]*config.Organization { remotes := make(map[string]*config.Organization) for i := range s.config.Organizations { org := &s.config.Organizations[i] - + // Skip backup locations if backup is not enabled if org.BackupLocation && !s.backupEnabled { continue } - + remoteName := s.getRemoteName(org) remotes[remoteName] = org } return remotes -}
\ No newline at end of file +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 0f51689..0f2f479 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -12,9 +12,9 @@ import ( // Syncer handles repository synchronization between organizations type Syncer struct { - config *config.Config - workDir string - repoName string + config *config.Config + workDir string + repoName string abandonedReports map[string]*AbandonedBranchReport // Collects reports across repos branchFilter *BranchFilter // Filter for excluding branches backupEnabled bool // Whether to sync to backup locations @@ -32,8 +32,8 @@ func New(cfg *config.Config, workDir string) *Syncer { } return &Syncer{ - config: cfg, - workDir: workDir, + config: cfg, + workDir: workDir, abandonedReports: make(map[string]*AbandonedBranchReport), branchFilter: branchFilter, backupEnabled: false, // Default to false, will be set via SetBackupEnabled @@ -82,7 +82,7 @@ func (s *Syncer) SyncRepository(repoName string) error { // Filter branches based on exclusion patterns branches := s.branchFilter.FilterBranches(allBranches) excludedBranches := s.branchFilter.GetExcludedBranches(allBranches) - + // Report excluded branches if any if exclusionReport := FormatExclusionReport(excludedBranches, s.config.ExcludeBranches); exclusionReport != "" { fmt.Print(exclusionReport) @@ -118,12 +118,12 @@ func (s *Syncer) SyncRepository(repoName string) error { // This is used for showcase-only mode func (s *Syncer) EnsureRepositoryCloned(repoName string) error { s.repoName = repoName - + // Create work directory if it doesn't exist if err := os.MkdirAll(s.workDir, 0755); err != nil { return fmt.Errorf("failed to create work directory: %w", err) } - + // Check if repository already exists repoPath := filepath.Join(s.workDir, repoName) if _, err := os.Stat(repoPath); err == nil { @@ -131,10 +131,10 @@ func (s *Syncer) EnsureRepositoryCloned(repoName string) error { fmt.Printf(" Repository %s already exists locally\n", repoName) return nil } - + // Repository doesn't exist, clone it fmt.Printf(" Cloning %s...\n", repoName) - + // Find first non-backup organization to clone from var sourceOrg *config.Organization for i := range s.config.Organizations { @@ -143,16 +143,16 @@ func (s *Syncer) EnsureRepositoryCloned(repoName string) error { break } } - + if sourceOrg == nil { return fmt.Errorf("no non-backup organizations configured to clone from") } - + // Clone the repository if err := s.cloneRepository(sourceOrg, repoPath); err != nil { return fmt.Errorf("failed to clone repository: %w", err) } - + fmt.Printf(" Successfully cloned %s\n", repoName) return nil } @@ -282,7 +282,7 @@ func (s *Syncer) syncBranch(branch string, remotes map[string]*config.Organizati if stashed { defer popStash() } - + // Create or checkout the branch if err := s.checkoutBranch(branch); err != nil { return fmt.Errorf("failed to checkout branch %s: %w", branch, err) @@ -307,7 +307,7 @@ func (s *Syncer) handleWorkingDirectoryState() (bool, error) { if err != nil || statusStr == "" { return false, nil } - + if hasConflicts { // Get absolute path for clarity absPath, err := filepath.Abs(s.workDir) @@ -316,7 +316,7 @@ func (s *Syncer) handleWorkingDirectoryState() (bool, error) { } return false, fmt.Errorf("repository has unresolved merge conflicts\nPlease resolve conflicts in: %s\nOr delete the directory to start fresh: rm -rf %s", absPath, absPath) } - + // If we have uncommitted changes but no conflicts, try to stash them if err := stashChanges(); err != nil { return false, fmt.Errorf("failed to stash changes: %w", err) @@ -380,13 +380,13 @@ func (s *Syncer) getRemoteName(org *config.Organization) string { func (s *Syncer) filterBackupBranches(output []byte) []byte { lines := strings.Split(string(output), "\n") var filtered []string - + for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } - + // Check if this branch is from a backup remote isBackup := false for i := range s.config.Organizations { @@ -399,11 +399,11 @@ func (s *Syncer) filterBackupBranches(output []byte) []byte { } } } - + if !isBackup { filtered = append(filtered, line) } } - + return []byte(strings.Join(filtered, "\n")) } diff --git a/internal/version/version.go b/internal/version/version.go index 7872b56..dda0d9a 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.9.2" + Version = "0.10.0" // GitCommit is the git commit hash at build time GitCommit = "unknown" |
