From 11eea6a82cbfdde40ec1457c6ea080da4da6b7dc Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 31 Oct 2025 20:13:32 +0200 Subject: feat: implement amp AI tool support and replace Taskfile with Mage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- AGENTS.md | 14 +- README.md | 18 +- Taskfile.yaml | 94 -------- cmd/gitsyncer/main.go | 3 +- doc/development.md | 38 ++- gitsyncer.example.json | 24 -- go.mod | 1 + go.sum | 2 + internal/cli/description_cache.go | 51 ++-- internal/cli/description_sync.go | 185 ++++++++------- internal/cli/flags.go | 72 +++--- internal/cli/handlers.go | 46 ++-- internal/cli/release.go | 270 +++++++++++----------- internal/cli/showcase_handler.go | 10 +- internal/cli/showcase_only_handler.go | 152 ++++++------ internal/cli/sync_handlers.go | 256 ++++++++++---------- internal/cmd/list.go | 4 +- internal/cmd/manage.go | 30 +-- internal/cmd/release.go | 26 +-- internal/cmd/root.go | 21 +- internal/cmd/showcase.go | 65 +++--- internal/cmd/sync.go | 58 ++--- internal/cmd/test.go | 12 +- internal/codeberg/codeberg.go | 124 +++++----- internal/config/config.go | 44 ++-- internal/github/github.go | 128 +++++----- internal/release/release.go | 411 ++++++++++++++++++--------------- internal/showcase/ai_context.go | 338 +++++++++++++++------------ internal/showcase/code_extractor.go | 228 +++++++++--------- internal/showcase/images.go | 78 +++---- internal/showcase/language_detector.go | 214 ++++++++--------- internal/showcase/metadata.go | 33 ++- internal/showcase/showcase.go | 311 ++++++++++++++----------- internal/state/state.go | 2 +- internal/sync/branch_analyzer.go | 94 ++++---- internal/sync/branch_filter.go | 8 +- internal/sync/branch_sync.go | 12 +- internal/sync/git_operations.go | 22 +- internal/sync/repository_setup.go | 14 +- internal/sync/sync.go | 40 ++-- internal/version/version.go | 2 +- magefile.go | 139 +++++++++++ 42 files changed, 1904 insertions(+), 1790 deletions(-) delete mode 100644 Taskfile.yaml delete mode 100644 gitsyncer.example.json create mode 100644 magefile.go diff --git a/AGENTS.md b/AGENTS.md index 81f789a..354820a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,13 +7,13 @@ - `doc/`: architecture, API, and development docs. `dist/`: build artifacts. ## Build, Test, and Development Commands -- Build (current platform): `task build` or `go build -o gitsyncer ./cmd/gitsyncer`. -- Cross-compile: `task build-all` (linux/darwin/windows variants also available). -- Run binary: `./gitsyncer --help` or `task run -- --version`. -- Unit tests (when present): `task test` or `go test ./...`. +- Build (current platform): `mage build` or `go build -o gitsyncer ./cmd/gitsyncer`. +- Cross-compile: `mage buildAll` (linux/darwin/windows variants also available). +- Run binary: `./gitsyncer --help` or `mage run`. +- Unit tests (when present): `mage test` or `go test ./...`. - Integration tests: `cd test && ./run_integration_tests.sh`. -- Format/lint: `task fmt`, `task vet`, `task lint` (golangci-lint). -- Tidy modules: `task mod-tidy`. +- Format/lint: `mage fmt`, `mage vet`, `mage lint` (golangci-lint). +- Tidy modules: `mage modTidy`. ## Coding Style & Naming Conventions - Go formatting: enforce `gofmt`/`go fmt`; prefer `goimports` in your editor. @@ -31,7 +31,7 @@ - Commits: Conventional Commits format — `type(scope): summary`. - Examples: `feat(sync): add bidirectional mode`, `fix(config): validate organizations`. - PRs: clear description, linked issues, testing notes (unit + integration), and updated docs (`doc/` or `README.md`) when user-visible. -- Keep changes focused; run `task fmt vet lint` before submitting. +- Keep changes focused; run `mage fmt vet lint` before submitting. ## Security & Configuration Tips - Configuration lives at `~/.config/gitsyncer/config.json` (see `gitsyncer.example.json`). Do not commit secrets. diff --git a/README.md b/README.md index 9feb08d..38c9e85 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ GitSyncer is a tool for synchronizing git repositories between multiple organizations (e.g., GitHub and Codeberg). It automatically keeps all branches in sync across different git hosting platforms. -It has been vibe coded mainly vibe coded using Claude Code CLI. +It has been vibe coded mainly using AI tools (Claude Code CLI and amp). ## Documentation @@ -173,16 +173,16 @@ gitsyncer release create --update-existing # 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 (default) +gitsyncer release create --ai-tool amp ``` #### AI Release Notes Engines -- Default flow: tries `hexai` first by piping the generated commit/diff payload to stdin and passing an instruction prompt as the sole argument (equivalent to `cat SOMETEXT.txt | hexai PROMPT`). -- Fallback: if `hexai` is not available or fails, falls back to `claude --model sonnet`, then to `aichat`. -- Explicit tool: `--ai-tool claude` or `--ai-tool aichat` influences the fallback preference, but `hexai` is still attempted first when available. -- Requirements: ensure `hexai`, `claude`, or `aichat` are installed and available in `PATH`. +- Default flow: tries `amp` first by piping the generated commit/diff payload to stdin and passing an instruction prompt via `--execute` (equivalent to `echo SOMETEXT | amp --execute 'PROMPT'`). +- Fallback: if `amp` is not available or fails, falls back to `hexai`, then `claude --model sonnet`, then to `aichat`. +- Explicit tool: `--ai-tool claude` or `--ai-tool aichat` influences the fallback preference, but `amp` is still attempted first when available. +- Requirements: ensure `amp`, `hexai`, `claude`, or `aichat` are installed and available in `PATH`. ### Project Showcase @@ -369,7 +369,7 @@ The backup location path format is: `user@host:path/REPONAME.git` ## Project Showcase Generation -GitSyncer can generate a comprehensive showcase of all your projects using Claude AI. This feature creates a formatted document with project summaries, statistics, and code snippets. +GitSyncer can generate a comprehensive showcase of all your projects using AI (amp by default). This feature creates a formatted document with project summaries, statistics, and code snippets. ### How it works @@ -381,7 +381,7 @@ GitSyncer can generate a comprehensive showcase of all your projects using Claud - Latest release version and date - AI-assistance detection (looks for CLAUDE.md, GEMINI.md files) -2. **AI-Powered Summaries**: Uses Claude AI to generate concise project descriptions that explain: +2. **AI-Powered Summaries**: Uses AI (amp, hexai, claude, or aichat) to generate concise project descriptions that explain: - What the project does - Why it's useful - How it's implemented diff --git a/Taskfile.yaml b/Taskfile.yaml deleted file mode 100644 index 94a968b..0000000 --- a/Taskfile.yaml +++ /dev/null @@ -1,94 +0,0 @@ -version: '3' - -vars: - BINARY_NAME: gitsyncer - BUILD_DIR: . - CMD_PATH: ./cmd/gitsyncer - LDFLAGS: -s -w - -tasks: - default: - desc: Build the gitsyncer binary - cmds: - - go build -ldflags "{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} {{.CMD_PATH}} - - build: - desc: Build the gitsyncer binary - cmds: - - go build -ldflags "{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} {{.CMD_PATH}} - - build-all: - desc: Build for all supported platforms - cmds: - - task: build-linux - - task: build-darwin - - task: build-windows - - build-linux: - desc: Build for Linux - cmds: - - GOOS=linux GOARCH=amd64 go build -ldflags "{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-linux-amd64 {{.CMD_PATH}} - - build-darwin: - desc: Build for macOS - cmds: - - GOOS=darwin GOARCH=amd64 go build -ldflags "{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-amd64 {{.CMD_PATH}} - - GOOS=darwin GOARCH=arm64 go build -ldflags "{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-darwin-arm64 {{.CMD_PATH}} - - build-windows: - desc: Build for Windows - cmds: - - GOOS=windows GOARCH=amd64 go build -ldflags "{{.LDFLAGS}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}}-windows-amd64.exe {{.CMD_PATH}} - - run: - desc: Build and run the gitsyncer binary - cmds: - - task: build - - ./{{.BINARY_NAME}} - - test: - desc: Run tests - cmds: - - go test ./... - - test-verbose: - desc: Run tests with verbose output - cmds: - - go test -v ./... - - clean: - desc: Clean build artifacts - cmds: - - rm -f {{.BINARY_NAME}} - - rm -f {{.BINARY_NAME}}-* - - mod-tidy: - desc: Tidy go modules - cmds: - - go mod tidy - - fmt: - desc: Format Go code - cmds: - - go fmt ./... - - vet: - desc: Run go vet - cmds: - - go vet ./... - - lint: - desc: Run golangci-lint - cmds: - - golangci-lint run - - install: - desc: Install gitsyncer to $GOPATH/bin - cmds: - - go install {{.CMD_PATH}} - - version: - desc: Show version - deps: [build] - cmds: - - ./{{.BINARY_NAME}} --version \ No newline at end of file diff --git a/cmd/gitsyncer/main.go b/cmd/gitsyncer/main.go index e656e7b..4882b34 100644 --- a/cmd/gitsyncer/main.go +++ b/cmd/gitsyncer/main.go @@ -4,8 +4,7 @@ import ( "codeberg.org/snonux/gitsyncer/internal/cmd" ) - func main() { // Execute the cobra command cmd.Execute() -} \ No newline at end of file +} diff --git a/doc/development.md b/doc/development.md index b22fbd4..75274f0 100644 --- a/doc/development.md +++ b/doc/development.md @@ -18,8 +18,7 @@ This guide is for contributors who want to help develop GitSyncer. - Go 1.21 or later - Git -- Make (optional, for Makefile) -- Task (optional, for Taskfile) +- Mage (optional, for magefile.go) ### Clone the Repository @@ -46,12 +45,8 @@ go mod verify ### Install Development Tools ```bash -# Install Task runner (optional) -# macOS -brew install go-task - -# Linux -sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b ~/.local/bin +# Install Mage (build tool) +go install github.com/magefile/mage@latest # Install golangci-lint for linting go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest @@ -91,26 +86,29 @@ gitsyncer/ ├── go.sum # Go module checksums ├── LICENSE # BSD 2-Clause License ├── README.md # Project README -├── CLAUDE.md # AI assistant hints -└── Taskfile.yaml # Task automation +├── AGENTS.md # AI assistant hints +└── magefile.go # Mage build automation ``` ## Building -### Using Task (Recommended) +### Using Mage (Recommended) ```bash +# List available targets +mage -l + # Build for current platform -task build +mage build # Build for all platforms -task build-all +mage buildAll # Run directly -task run +mage run -# Run with arguments -task run -- --version +# Show version +mage version ``` ### Using Go Directly @@ -202,7 +200,7 @@ Follow standard Go conventions: 1. **Formatting**: Use `gofmt` or `goimports` ```bash # Format all files - task fmt + mage fmt # or gofmt -w . ``` @@ -364,7 +362,7 @@ test: add integration tests for branch filtering 2. **Format code**: ```bash - task fmt + mage fmt ``` 3. **Update documentation**: @@ -372,7 +370,7 @@ test: add integration tests for branch filtering - Update README if adding user-facing features - Add examples to `doc/examples.md` -4. **Update CLAUDE.md** if adding development commands +4. **Update AGENTS.md** if adding development commands ### Pull Request Process @@ -442,6 +440,6 @@ GITSYNCER_DEBUG=1 gitsyncer --sync test-repo ``` 4. Build releases: ```bash - task build-all + mage buildAll ``` 5. Create GitHub/Codeberg release with binaries \ No newline at end of file diff --git a/gitsyncer.example.json b/gitsyncer.example.json deleted file mode 100644 index 39984b4..0000000 --- a/gitsyncer.example.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "organizations": [ - { - "host": "git@codeberg.org", - "name": "snonux" - }, - { - "host": "git@github.com", - "name": "snonux" - }, - { - "host": "paul@t450:git", - "backupLocation": true - } - ], - "repositories": [ - "gitsyncer", - "another-repo", - "yet-another-repo" - ], - "skip_releases": { - "fapi": ["0.0.1"] - } -} diff --git a/go.mod b/go.mod index e53cf63..86f282a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.3 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/magefile/mage v1.15.0 // indirect github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect ) diff --git a/go.sum b/go.sum index ffae55e..e6f25e1 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 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 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.