diff --git a/internal/commands/release/create.go b/internal/commands/release/create.go index e86cddd..8702ce6 100644 --- a/internal/commands/release/create.go +++ b/internal/commands/release/create.go @@ -220,6 +220,20 @@ func createReleaseAction(releaseSvc releaseService, trans *i18n.Translations, re fmt.Println(trans.GetMessage("release.publishing_release", 0, nil)) buildBinaries := cmd.Bool("build-binaries") + mainPath := cmd.String("main-path") + if mainPath == "" && config != nil && config.MainPath != "" { + mainPath = config.MainPath + } + if mainPath == "" { + mainPath = "./cmd/main.go" + } + + if buildBinaries { + if _, err := os.Stat(mainPath); os.IsNotExist(err) { + log.Warn("main entrypoint not found, smartly skipping build binaries during publish", "file", mainPath) + buildBinaries = false + } + } if buildBinaries { progressCh := make(chan models.BuildProgress, 10) diff --git a/internal/commands/release/create_test.go b/internal/commands/release/create_test.go index 5430c6c..e2e3e3e 100644 --- a/internal/commands/release/create_test.go +++ b/internal/commands/release/create_test.go @@ -186,7 +186,7 @@ func TestCreateCommand_WithPublish(t *testing.T) { mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil) - mockService.On("PublishRelease", mock.Anything, release, notes, false, true, mock.Anything).Return(nil) + mockService.On("PublishRelease", mock.Anything, release, notes, false, false, mock.Anything).Return(nil) err := runCreateTest(t, "y\n", []string{"--publish"}, mockService) assert.NoError(t, err) @@ -205,7 +205,7 @@ func TestCreateCommand_WithPublishDraft(t *testing.T) { mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil) - mockService.On("PublishRelease", mock.Anything, release, notes, true, true, mock.Anything).Return(nil) + mockService.On("PublishRelease", mock.Anything, release, notes, true, false, mock.Anything).Return(nil) err := runCreateTest(t, "y\n", []string{"--publish", "--draft"}, mockService) assert.NoError(t, err) @@ -224,7 +224,7 @@ func TestCreateCommand_PublishError(t *testing.T) { mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil) - mockService.On("PublishRelease", mock.Anything, release, notes, false, true, mock.Anything).Return(errors.New("publish error")) + mockService.On("PublishRelease", mock.Anything, release, notes, false, false, mock.Anything).Return(errors.New("publish error")) err := runCreateTest(t, "y\n", []string{"--publish"}, mockService) assert.Error(t, err) diff --git a/internal/commands/stats/stats.go b/internal/commands/stats/stats.go index 59927f0..e5850c4 100644 --- a/internal/commands/stats/stats.go +++ b/internal/commands/stats/stats.go @@ -102,10 +102,13 @@ func (c *StatsCommand) showDailyStats(manager *cost.Manager, t *i18n.Translation for _, record := range todayRecords { cacheIndicator := "" if record.CacheHit { - cacheIndicator = green.Sprint(" [CACHE]") + cacheIndicator = green.Sprint(t.GetMessage("stats.cache_indicator", 0, nil)) } - tokensInfo := dim.Sprintf("(%d→%d tok)", record.TokensInput, record.TokensOutput) + tokensInfo := dim.Sprint(t.GetMessage("stats.tokens_label", 0, struct { + Input int + Output int + }{record.TokensInput, record.TokensOutput})) fmt.Printf("%s - %s: %s %s%s\n", record.Timestamp.Format("15:04"), @@ -119,7 +122,7 @@ func (c *StatsCommand) showDailyStats(manager *cost.Manager, t *i18n.Translation fmt.Println() fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") _, _ = cyan.Printf("%s: ", t.GetMessage("stats.total_today", 0, nil)) - _, _ = yellow.Printf("$%.4f USD\n", total) + _, _ = yellow.Println(t.GetMessage("stats.total_today_value", 0, struct{ Total float64 }{total})) fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println() @@ -154,7 +157,11 @@ func (c *StatsCommand) showMonthlyStats(manager *cost.Manager, t *i18n.Translati dim := color.New(color.FgHiBlack) green := color.New(color.FgGreen) - _, _ = cyan.Printf("\n📅 %s\n", t.GetMessage("stats.monthly_title", 0, struct{ Month string }{time.Now().Format("January 2006")})) + now := time.Now() + monthName := getMonthName(t, now.Month()) + monthYear := fmt.Sprintf("%s %d", monthName, now.Year()) + + _, _ = cyan.Printf("\n📅 %s\n", t.GetMessage("stats.monthly_title", 0, struct{ Month string }{monthYear})) fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println() @@ -201,7 +208,7 @@ func (c *StatsCommand) showMonthlyStats(manager *cost.Manager, t *i18n.Translati fmt.Println() fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") _, _ = cyan.Printf("%s: ", t.GetMessage("stats.total_month", 0, nil)) - _, _ = yellow.Printf("$%.4f USD\n", total) + _, _ = yellow.Println(t.GetMessage("stats.total_month_value", 0, struct{ Total float64 }{total})) daysWithActivity := len(dailyTotals) if daysWithActivity > 0 { @@ -259,7 +266,11 @@ func (c *StatsCommand) showBreakdown(manager *cost.Manager, t *i18n.Translations dim := color.New(color.FgHiBlack) green := color.New(color.FgGreen) - _, _ = cyan.Printf("\n📊 Usage Breakdown - %s\n", time.Now().Format("January 2006")) + now := time.Now() + monthName := getMonthName(t, now.Month()) + monthYear := fmt.Sprintf("%s %d", monthName, now.Year()) + + _, _ = cyan.Printf("\n📊 %s\n", t.GetMessage("stats.usage_breakdown_title", 0, struct{ Month string }{monthYear})) fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") fmt.Println() @@ -331,3 +342,26 @@ func (c *StatsCommand) showBreakdown(manager *cost.Manager, t *i18n.Translations return nil } + +func getMonthName(t *i18n.Translations, month time.Month) string { + months := map[time.Month]string{ + time.January: "months.january", + time.February: "months.february", + time.March: "months.march", + time.April: "months.april", + time.May: "months.may", + time.June: "months.june", + time.July: "months.july", + time.August: "months.august", + time.September: "months.september", + time.October: "months.october", + time.November: "months.november", + time.December: "months.december", + } + + key, ok := months[month] + if !ok { + return month.String() + } + return t.GetMessage(key, 0, nil) +} diff --git a/internal/commands/stats/stats_test.go b/internal/commands/stats/stats_test.go index d628ede..a958cd0 100644 --- a/internal/commands/stats/stats_test.go +++ b/internal/commands/stats/stats_test.go @@ -227,7 +227,7 @@ func TestShowMonthlyStats_WithActivity(t *testing.T) { CostUSD: 0.0010, }, { - Timestamp: now.AddDate(0, -1, 0), + Timestamp: time.Date(now.Year(), now.Month()-1, 15, 0, 0, 0, 0, now.Location()), Command: "suggest", CostUSD: 0.0050, }, diff --git a/internal/commands/suggests_commits/suggests_commits.go b/internal/commands/suggests_commits/suggests_commits.go index 917b93d..67d6806 100644 --- a/internal/commands/suggests_commits/suggests_commits.go +++ b/internal/commands/suggests_commits/suggests_commits.go @@ -19,7 +19,7 @@ import ( // commitService is a minimal interface for testing purposes type commitService interface { - GenerateSuggestions(ctx context.Context, count int, issueNumber int, progress func(models.ProgressEvent)) ([]models.CommitSuggestion, error) + GenerateSuggestions(ctx context.Context, count int, issueNumber int, files []string, progress func(models.ProgressEvent)) ([]models.CommitSuggestion, error) } // commitHandler is a minimal interface for testing purposes @@ -31,6 +31,7 @@ type gitService interface { ValidateGitConfig(ctx context.Context) error GetChangedFiles(ctx context.Context) ([]string, error) GetDiff(ctx context.Context) (string, error) + GetDiffForFiles(ctx context.Context, files []string) (string, error) } type SuggestCommandFactory struct { @@ -81,10 +82,14 @@ func (f *SuggestCommandFactory) createFlags(cfg *config.Config, t *i18n.Translat }, &cli.IntFlag{ Name: "issue", - Aliases: []string{"i"}, Usage: t.GetMessage("suggest_issue_flag_usage", 0, nil), Value: 0, }, + &cli.BoolFlag{ + Name: "interactive", + Aliases: []string{"i"}, + Usage: "Interactively select which files to include in the AI summary", + }, &cli.BoolFlag{ Name: "dry-run", Aliases: []string{"d"}, @@ -102,13 +107,19 @@ func (f *SuggestCommandFactory) createAction(cfg *config.Config, t *i18n.Transla lang := command.String("lang") noEmoji := command.Bool("no-emoji") dryRun := command.Bool("dry-run") + interactive := command.Bool("interactive") + + if interactive && !command.IsSet("count") { + count = 1 + } log.Info("executing suggest command", "count", count, "issue_number", issueNumber, "language", lang, "no_emoji", noEmoji, - "dry_run", dryRun) + "dry_run", dryRun, + "interactive", interactive) if noEmoji { cfg.UseEmoji = false @@ -142,6 +153,31 @@ func (f *SuggestCommandFactory) createAction(cfg *config.Config, t *i18n.Transla return err } + var selectedFiles []string + if interactive { + changedFiles, err := f.gitService.GetChangedFiles(ctx) + if err != nil { + ui.HandleAppError(err, t) + return err + } + + if len(changedFiles) == 0 { + ui.PrintWarning("No changed files to select.") + return nil + } + + selectedFiles, err = ui.PromptMultiSelect(t, t.GetMessage("ui.multi_select_default_msg", 0, nil), changedFiles) + if err != nil { + ui.HandleAppError(err, t) + return err + } + + if len(selectedFiles) == 0 { + ui.PrintWarning("No files selected. Operation cancelled.") + return nil + } + } + spinner := ui.NewSmartSpinner(t.GetMessage("analyzing_changes", 0, nil)) spinner.Start() @@ -150,7 +186,7 @@ func (f *SuggestCommandFactory) createAction(cfg *config.Config, t *i18n.Transla start := time.Now() - suggestions, err = f.commitService.GenerateSuggestions(ctx, count, issueNumber, func(event models.ProgressEvent) { + suggestions, err = f.commitService.GenerateSuggestions(ctx, count, issueNumber, selectedFiles, func(event models.ProgressEvent) { msg := "" switch event.Type { case models.ProgressIssuesDetected: diff --git a/internal/commands/suggests_commits/suggests_commits_test.go b/internal/commands/suggests_commits/suggests_commits_test.go index 595d37d..b4e1a96 100644 --- a/internal/commands/suggests_commits/suggests_commits_test.go +++ b/internal/commands/suggests_commits/suggests_commits_test.go @@ -18,8 +18,8 @@ type MockCommitService struct { mock.Mock } -func (m *MockCommitService) GenerateSuggestions(ctx context.Context, count int, issueNumber int, progress func(models.ProgressEvent)) ([]models.CommitSuggestion, error) { - args := m.Called(ctx, count, issueNumber, progress) +func (m *MockCommitService) GenerateSuggestions(ctx context.Context, count int, issueNumber int, files []string, progress func(models.ProgressEvent)) ([]models.CommitSuggestion, error) { + args := m.Called(ctx, count, issueNumber, files, progress) return args.Get(0).([]models.CommitSuggestion), args.Error(1) } @@ -56,6 +56,11 @@ func (m *MockGitService) GetDiff(ctx context.Context) (string, error) { return args.String(0), args.Error(1) } +func (m *MockGitService) GetDiffForFiles(ctx context.Context, files []string) (string, error) { + args := m.Called(ctx, files) + return args.String(0), args.Error(1) +} + func setupTestEnv(t *testing.T) (*config.Config, *i18n.Translations, func()) { tmpDir, err := os.MkdirTemp("", "matecommit-test-*") if err != nil { @@ -102,7 +107,7 @@ func TestSuggestCommand(t *testing.T) { } mockGit.On("ValidateGitConfig", mock.Anything).Return(nil) - mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return(suggestions, nil) + mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, []string(nil), mock.Anything).Return(suggestions, nil) mockHandler.On("HandleSuggestions", mock.Anything, suggestions).Return(nil) factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit) @@ -162,7 +167,7 @@ func TestSuggestCommand(t *testing.T) { } mockGit.On("ValidateGitConfig", mock.Anything).Return(nil) - mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return(suggestions, nil) + mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, []string(nil), mock.Anything).Return(suggestions, nil) mockHandler.On("HandleSuggestions", mock.Anything, suggestions).Return(nil) factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit) @@ -198,7 +203,7 @@ func TestSuggestCommand(t *testing.T) { } mockGit.On("ValidateGitConfig", mock.Anything).Return(nil) - mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return(suggestions, nil) + mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, []string(nil), mock.Anything).Return(suggestions, nil) mockHandler.On("HandleSuggestions", mock.Anything, suggestions).Return(nil) factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit) @@ -227,7 +232,7 @@ func TestSuggestCommand(t *testing.T) { expectedError := fmt.Errorf("service error") mockGit.On("ValidateGitConfig", mock.Anything).Return(nil) - mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, mock.Anything).Return([]models.CommitSuggestion{}, expectedError) + mockService.On("GenerateSuggestions", mock.Anything, cfg.SuggestionsCount, 0, []string(nil), mock.Anything).Return([]models.CommitSuggestion{}, expectedError) factory := NewSuggestCommandFactory(mockService, mockHandler, mockGit) command := factory.CreateCommand(translations, cfg) diff --git a/internal/git/git_service.go b/internal/git/git_service.go index d5fb504..e8300f0 100644 --- a/internal/git/git_service.go +++ b/internal/git/git_service.go @@ -3,7 +3,6 @@ package git import ( "context" "fmt" - "os" "os/exec" "regexp" "sort" @@ -145,6 +144,66 @@ func (s *GitService) GetDiff(ctx context.Context) (string, error) { return combinedDiff, nil } +func (s *GitService) GetDiffForFiles(ctx context.Context, files []string) (string, error) { + log := logger.FromContext(ctx) + + if len(files) == 0 { + return "", errors.ErrNoDiff + } + + log.Debug("executing git diff for specific files", "count", len(files)) + + argsStaged := append([]string{"diff", "--cached", "--"}, files...) + stagedCmd := exec.CommandContext(ctx, "git", argsStaged...) + stagedOutput, err := stagedCmd.Output() + if err != nil { + log.Error("git diff --cached failed for files", "error", err) + return "", errors.ErrGetDiff.WithError(err).WithContext("diff_type", "staged_files") + } + + argsUnstaged := append([]string{"diff", "--"}, files...) + unstagedCmd := exec.CommandContext(ctx, "git", argsUnstaged...) + unstageOutput, err := unstagedCmd.Output() + if err != nil { + log.Error("git diff failed for files", "error", err) + return "", errors.ErrGetDiff.WithError(err).WithContext("diff_type", "unstaged_files") + } + + combinedDiff := string(stagedOutput) + string(unstageOutput) + + if combinedDiff == "" { + // Fallback check for untracked files in our selection + untrackedCmd := exec.CommandContext(ctx, "git", "ls-files", "--others", "--exclude-standard") + untrackedFiles, err := untrackedCmd.Output() + if err == nil && len(untrackedFiles) > 0 { + untrackedList := strings.Split(string(untrackedFiles), "\n") + + // Quick map for O(1) lookups + selectedMap := make(map[string]bool) + for _, f := range files { + selectedMap[f] = true + } + + for _, file := range untrackedList { + if file != "" && selectedMap[file] { + fileContentCmd := exec.CommandContext(ctx, "git", "show", ":"+file) + content, err := fileContentCmd.Output() + if err != nil { + combinedDiff += "\n=== New file" + " " + file + "===\n" + combinedDiff += string(content) + } + } + } + } + + if combinedDiff == "" { + return "", errors.ErrNoDiff + } + } + + return combinedDiff, nil +} + func (s *GitService) CreateCommit(ctx context.Context, message string) error { log := logger.FromContext(ctx) @@ -205,13 +264,7 @@ func (s *GitService) AddFileToStaging(ctx context.Context, file string) error { "file", file, "repo_root", repoRoot) - fullPath := repoRoot + "/" + file - if _, err := os.Stat(fullPath); os.IsNotExist(err) { - log.Error("file does not exist", - "file", file, - "full_path", fullPath) - return errors.ErrAddFile.WithError(err).WithContext("file", file).WithContext("reason", "file_not_found") - } + cmd := exec.CommandContext(ctx, "git", "add", "--", file) cmd.Dir = repoRoot diff --git a/internal/git/git_service_test.go b/internal/git/git_service_test.go index 0af41b7..e99b171 100644 --- a/internal/git/git_service_test.go +++ b/internal/git/git_service_test.go @@ -400,6 +400,7 @@ func TestAddFileToStaging(t *testing.T) { expectedMessages := []string{ "did not match any files", "no concordó con ningún archivo", + "no such file or directory", } match := false diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index 650428c..476c560 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -268,6 +268,9 @@ config_set_active_vcs_usage = "Set the active VCS provider" config_set_active_vcs_provider_usage = "Name of the VCS provider to set as active" config_active_vcs_updated = "Active VCS provider set to '{{.Provider}}'" test_plan_generated = "✅ Test plan generated successfully" +issues_detected = "🔍 Detected related issues: {{.Issues}}" +issues_closing = "🔗 PR will close {{.Count}} issues" +breaking_changes_detected = "⚠️ {{.Count}} breaking changes detected" [pr_service] error_get_pr = "Error getting the PR: {{.Error}}" @@ -389,6 +392,7 @@ error_pushing_changes = "Error pushing changes: {{.Error}}" changes_pushed = "✅ Changes pushed successfully" version_calculated = "📦 New version calculated: {{.Version}}" error_updating_app_version = "Error updating app version: {{.Error}}" +app_version_update_started = "🔄 Updating app version to {{.Version}}..." app_version_updated = "✅ App version updated to {{.Version}}" commit_no_staged = "No changes to commit. Skipping commit step." error_invalid_branch = "❌ Error: Must be on 'main' or 'master' branch to create releases. {{.Error}}" @@ -478,6 +482,9 @@ output = "Output" total = "Total" cost = "Cost" duration = "Duration" +multi_select_prompt = "Select choices separated by comma (e.g. 1,3) [Enter for all]" +multi_select_invalid = "Invalid selection: '{{.Selection}}'. Ignored." +multi_select_default_msg = "Select files to include in AI commit summary:" # UI - Errors with suggestions [ui_error] @@ -830,7 +837,7 @@ total_month = "Total This Month" average_per_day = "Average per day" error_init = "Error initializing cost manager" activity_log = "Activity Log" -usage_breakdown_title = "Usage Breakdown" +usage_breakdown_title = "Usage Breakdown - {{.Month}}" forecast_title = "📈 Forecast" forecast_days_elapsed = "Days elapsed" forecast_daily_average = "Daily average" @@ -854,7 +861,25 @@ column_percent = "% Total" cache_hits_label = "Cache hits:" total_label = "Total:" calls_text = "calls" -avg_cost_per_commit_label = "💡 Average cost per commit: ${{.Cost}}" +avg_cost_per_commit_label = "💡 Average cost per commit: ${{.Cost}} USD" +tokens_label = "({{.Input}}→{{.Output}} tok)" +cache_indicator = " [CACHE]" +total_today_value = "${{.Total}} USD" +total_month_value = "${{.Total}} USD" + +[months] +january = "January" +february = "February" +march = "March" +april = "April" +may = "May" +june = "June" +july = "July" +august = "August" +september = "September" +october = "October" +november = "November" +december = "December" [cache] usage = "Manage local response cache" diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index 68d6729..9084940 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -279,6 +279,9 @@ config_set_active_vcs_usage = "Establecer el proveedor VCS activo" config_set_active_vcs_provider_usage = "Nombre del proveedor VCS a establecer como activo" config_active_vcs_updated = "Proveedor VCS activo establecido a '{{.Provider}}'" test_plan_generated = "✅ Plan de pruebas generado exitosamente" +issues_detected = "🔍 Issues relacionados detectados: {{.Issues}}" +issues_closing = "🔗 El PR cerrará {{.Count}} issues" +breaking_changes_detected = "⚠️ Se detectaron {{.Count}} cambios que rompen la compatibilidad" [pr_service] error_get_pr = "Error al obtener el PR: {{.Error}}" @@ -410,6 +413,7 @@ changelog_update_started = "📝 Actualizando CHANGELOG.md..." changelog_updated = "✅ CHANGELOG.md Actualizado exitosamente" error_committing_changelog = "Error al enviar el Changelog: {{.Error}}" error_updating_app_version = "Error al actualizar la versión de la aplicación: {{.Error}}" +app_version_update_started = "🔄 Actualizando versión de la aplicación a {{.Version}}..." app_version_updated = "✅ Versión de la aplicación actualizada a {{.Version}}" commit_no_staged = "No hay cambios que confirmar. Se omite el paso de confirmación." changelog_committed = "✅ Changelog confirmado con éxito" @@ -499,6 +503,9 @@ output = "Salida" total = "Total" cost = "Costo" duration = "Duración" +multi_select_prompt = "Elegí los archivos separados por coma (ej: 1,3) [Enter para todos]" +multi_select_invalid = "Selección inválida: '{{.Selection}}'. Ignorada." +multi_select_default_msg = "Seleccioná los archivos para incluir en el resumen de commit de la IA:" # UI - Errors con sugerencias [ui_error] @@ -854,7 +861,7 @@ total_month = "Total Este Mes" average_per_day = "Promedio por día" error_init = "Error inicializando gestor de costos" activity_log = "Registro de Actividad" -usage_breakdown_title = "Desglose de Uso" +usage_breakdown_title = "Desglose de Uso - {{.Month}}" forecast_title = "📈 Pronóstico" forecast_days_elapsed = "Días transcurridos" forecast_daily_average = "Promedio diario" @@ -878,7 +885,25 @@ column_percent = "% Total" cache_hits_label = "Aciertos de caché:" total_label = "Total:" calls_text = "llamadas" -avg_cost_per_commit_label = "💡 Costo promedio por commit: ${{.Cost}}" +avg_cost_per_commit_label = "💡 Costo promedio por commit: ${{.Cost}} USD" +tokens_label = "({{.Input}}→{{.Output}} tok)" +cache_indicator = " [CACHE]" +total_today_value = "${{.Total}} USD" +total_month_value = "${{.Total}} USD" + +[months] +january = "Enero" +february = "Febrero" +march = "Marzo" +april = "Abril" +may = "Mayo" +june = "Junio" +july = "Julio" +august = "Agosto" +september = "Septiembre" +october = "Octubre" +november = "Noviembre" +december = "Diciembre" [cache] usage = "Gestionar caché local de respuestas" diff --git a/internal/services/commit_service.go b/internal/services/commit_service.go index 128fccc..9f56958 100644 --- a/internal/services/commit_service.go +++ b/internal/services/commit_service.go @@ -22,6 +22,7 @@ import ( type commitGitService interface { GetChangedFiles(ctx context.Context) ([]string, error) GetDiff(ctx context.Context) (string, error) + GetDiffForFiles(ctx context.Context, files []string) (string, error) GetRecentCommitMessages(ctx context.Context, limit int) ([]string, error) GetRepoInfo(ctx context.Context) (string, string, string, error) GetCurrentBranch(ctx context.Context) (string, error) @@ -66,14 +67,15 @@ func NewCommitService(gitSvc commitGitService, aiSvc ai.CommitSummarizer, opts . return s } -func (s *CommitService) GenerateSuggestions(ctx context.Context, count int, issueNumber int, progress func(models.ProgressEvent)) ([]models.CommitSuggestion, error) { +func (s *CommitService) GenerateSuggestions(ctx context.Context, count int, issueNumber int, files []string, progress func(models.ProgressEvent)) ([]models.CommitSuggestion, error) { log := logger.FromContext(ctx) log.Info("generating commit suggestions", "count", count, "issue_number", issueNumber, + "specific_files", len(files) > 0, ) - commitInfo, err := s.buildCommitInfo(ctx, issueNumber, progress) + commitInfo, err := s.buildCommitInfo(ctx, issueNumber, files, progress) if err != nil { log.Error("failed to build commit info", "error", err, @@ -101,7 +103,7 @@ func (s *CommitService) GenerateSuggestions(ctx context.Context, count int, issu return suggestions, nil } -func (s *CommitService) buildCommitInfo(ctx context.Context, issueNumber int, progress func(models.ProgressEvent)) (models.CommitInfo, error) { +func (s *CommitService) buildCommitInfo(ctx context.Context, issueNumber int, files []string, progress func(models.ProgressEvent)) (models.CommitInfo, error) { log := logger.FromContext(ctx) log.Debug("building commit info", @@ -114,16 +116,28 @@ func (s *CommitService) buildCommitInfo(ctx context.Context, issueNumber int, pr return commitInfo, domainErrors.ErrAPIKeyMissing } - changes, err := s.git.GetChangedFiles(ctx) - if err != nil { - return commitInfo, err + var changes []string + var err error + if len(files) > 0 { + changes = files + } else { + changes, err = s.git.GetChangedFiles(ctx) + if err != nil { + return commitInfo, err + } } if len(changes) == 0 { return commitInfo, domainErrors.ErrNoChanges } - diff, err := s.git.GetDiff(ctx) + var diff string + if len(files) > 0 { + diff, err = s.git.GetDiffForFiles(ctx, files) + } else { + diff, err = s.git.GetDiff(ctx) + } + if err != nil { return commitInfo, domainErrors.NewAppError(domainErrors.TypeGit, "error getting git diff", err) } diff --git a/internal/services/commit_service_test.go b/internal/services/commit_service_test.go index 8e06c1b..0b479c2 100644 --- a/internal/services/commit_service_test.go +++ b/internal/services/commit_service_test.go @@ -60,7 +60,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { WithVCSClient(mockVCS), WithConfig(cfg), ) - suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.NoError(t, err) assert.Equal(t, expectedResponse, suggestions) @@ -77,7 +77,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { service := NewCommitService(mockGit, mockAI, WithConfig(cfg), ) - suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.Error(t, err) assert.Nil(t, suggestions) @@ -94,7 +94,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { service := NewCommitService(mockGit, mockAI, WithConfig(cfg), ) - suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.Error(t, err) assert.Nil(t, suggestions) @@ -111,7 +111,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { service := NewCommitService(mockGit, mockAI, WithConfig(cfg), ) - suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.Error(t, err) assert.Nil(t, suggestions) @@ -131,7 +131,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { service := NewCommitService(mockGit, mockAI, WithConfig(cfg), ) - suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.Error(t, err) assert.Nil(t, suggestions) @@ -151,7 +151,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { service := NewCommitService(mockGit, mockAI, WithConfig(cfg), ) - suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.Error(t, err) assert.Nil(t, suggestions) @@ -171,7 +171,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { WithTicketManager(mockJira), WithConfig(cfg), ) - suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.Error(t, err) assert.Nil(t, suggestions) @@ -181,7 +181,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { t.Run("AI service nil", func(t *testing.T) { mockGit, _, _, _, _ := setupTest(t) service := NewCommitService(mockGit, nil) - suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + suggestions, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.Error(t, err) assert.Nil(t, suggestions) assert.ErrorIs(t, err, domainErrors.ErrAPIKeyMissing) @@ -202,7 +202,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { }), 3).Return([]models.CommitSuggestion{}, nil) service := NewCommitService(mockGit, mockAI, WithConfig(cfg)) - _, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + _, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.NoError(t, err) }) @@ -226,7 +226,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { }), 3).Return([]models.CommitSuggestion{}, nil) service := NewCommitService(mockGit, mockAI, WithVCSClient(mockVCS), WithConfig(cfg)) - _, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + _, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.NoError(t, err) mockVCS.AssertCalled(t, "GetIssue", mock.Anything, 999) }) @@ -247,7 +247,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { }), 3).Return([]models.CommitSuggestion{}, nil) service := NewCommitService(mockGit, mockAI, WithConfig(cfg)) - _, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + _, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.NoError(t, err) }) @@ -268,7 +268,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { }), 3).Return([]models.CommitSuggestion{}, nil) service := NewCommitService(mockGit, mockAI, WithConfig(cfg)) - _, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + _, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.NoError(t, err) }) @@ -289,7 +289,7 @@ func TestCommitService_GenerateSuggestions(t *testing.T) { }), 3).Return([]models.CommitSuggestion{}, nil) service := NewCommitService(mockGit, mockAI, WithConfig(cfg)) - _, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + _, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.NoError(t, err) }) @@ -311,7 +311,7 @@ func TestCommitService_GenerateSuggestionsWithIssue(t *testing.T) { }), 3).Return([]models.CommitSuggestion{}, nil) service := NewCommitService(mockGit, mockAI, WithVCSClient(mockVCS), WithConfig(cfg)) - suggestions, err := service.GenerateSuggestions(context.Background(), 3, 100, func(e models.ProgressEvent) {}) + suggestions, err := service.GenerateSuggestions(context.Background(), 3, 100, nil, func(e models.ProgressEvent) {}) assert.NoError(t, err) assert.NotNil(t, suggestions) @@ -332,7 +332,7 @@ func TestCommitService_GenerateSuggestionsWithIssue(t *testing.T) { }), 3).Return([]models.CommitSuggestion{}, nil) service := NewCommitService(mockGit, mockAI, WithVCSClient(mockVCS), WithConfig(cfg)) - _, err := service.GenerateSuggestions(context.Background(), 3, 100, func(e models.ProgressEvent) {}) + _, err := service.GenerateSuggestions(context.Background(), 3, 100, nil, func(e models.ProgressEvent) {}) assert.NoError(t, err) }) @@ -354,7 +354,7 @@ func TestCommitService_IssueDetection(t *testing.T) { }), 3).Return([]models.CommitSuggestion{}, nil) service := NewCommitService(mockGit, mockAI, WithVCSClient(mockVCS), WithConfig(cfg)) - _, err := service.GenerateSuggestions(context.Background(), 3, 0, func(e models.ProgressEvent) {}) + _, err := service.GenerateSuggestions(context.Background(), 3, 0, nil, func(e models.ProgressEvent) {}) assert.NoError(t, err) mockVCS.AssertCalled(t, "GetIssue", mock.Anything, 123) }) diff --git a/internal/services/cost/manager_test.go b/internal/services/cost/manager_test.go index 4906802..3dc7eea 100644 --- a/internal/services/cost/manager_test.go +++ b/internal/services/cost/manager_test.go @@ -90,7 +90,7 @@ func TestManager_Totals(t *testing.T) { now := time.Now() todayRecord := ActivityRecord{Timestamp: now, CostUSD: 1.5} yesterdayRecord := ActivityRecord{Timestamp: now.AddDate(0, 0, -1), CostUSD: 2.0} - lastMonthRecord := ActivityRecord{Timestamp: now.AddDate(0, -1, 0), CostUSD: 5.0} + lastMonthRecord := ActivityRecord{Timestamp: time.Date(now.Year(), now.Month()-1, 15, 0, 0, 0, 0, now.Location()), CostUSD: 5.0} records := []ActivityRecord{todayRecord, yesterdayRecord, lastMonthRecord} data, _ := json.Marshal(records) @@ -329,7 +329,7 @@ func TestGetBreakdownByCommand(t *testing.T) { manager.historyPath = tmpDir + "/history.json" now := time.Now() - lastMonth := now.AddDate(0, -1, 0) + lastMonth := time.Date(now.Year(), now.Month()-1, 15, 0, 0, 0, 0, now.Location()) records := []ActivityRecord{ { @@ -475,7 +475,7 @@ func TestGetCacheStats(t *testing.T) { manager.historyPath = tmpDir + "/history.json" now := time.Now() - lastMonth := now.AddDate(0, -1, 0) + lastMonth := time.Date(now.Year(), now.Month()-1, 15, 0, 0, 0, 0, now.Location()) records := []ActivityRecord{ {Timestamp: now, Command: "suggest", CostUSD: 0.002, CacheHit: true}, diff --git a/internal/services/mocks.go b/internal/services/mocks.go index 2ba3763..31bb7a2 100644 --- a/internal/services/mocks.go +++ b/internal/services/mocks.go @@ -65,6 +65,11 @@ func (m *MockGitService) GetDiff(ctx context.Context) (string, error) { return args.String(0), args.Error(1) } +func (m *MockGitService) GetDiffForFiles(ctx context.Context, files []string) (string, error) { + args := m.Called(ctx, files) + return args.String(0), args.Error(1) +} + func (m *MockGitService) StageAllChanges(ctx context.Context) error { args := m.Called(ctx) return args.Error(0) diff --git a/internal/services/release_service.go b/internal/services/release_service.go index 149709f..c6b30e2 100644 --- a/internal/services/release_service.go +++ b/internal/services/release_service.go @@ -504,8 +504,8 @@ func (s *ReleaseService) prependToChangelogLegacy(filename, current, newContent // removeEmptyUnreleasedSections removes empty ## [Unreleased] sections that appear between versions func (s *ReleaseService) removeEmptyUnreleasedSections(content string) string { - emptyUnreleasedPattern := regexp.MustCompile(`(?s)## \[Unreleased]\s*\n(?=## \[)`) - return emptyUnreleasedPattern.ReplaceAllString(content, "") + emptyUnreleasedPattern := regexp.MustCompile(`(?s)## \[Unreleased\]\s*\n(## \[)`) + return emptyUnreleasedPattern.ReplaceAllString(content, "$1") } // consolidateLinkDefinitions removes duplicate link reference definitions @@ -1084,6 +1084,10 @@ func (s *ReleaseService) UpdateAppVersion(ctx context.Context, version string) e content, err := os.ReadFile(versionFile) if err != nil { + if os.IsNotExist(err) && (s.config == nil || s.config.VersionFile == "") { + log.Warn("version file not found, skipping version update", "file", versionFile) + return nil + } return domainErrors.NewAppError(domainErrors.TypeInternal, fmt.Sprintf("failed to read version file: %s", versionFile), err) } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 06a64b9..060f9ba 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,6 +1,7 @@ package ui import ( + "bufio" "errors" "fmt" "io" @@ -589,3 +590,50 @@ func PrintSectionHeader(title string) { separator := strings.Repeat("─", 60) fmt.Printf("\n%s\n%s\n%s\n\n", separator, title, separator) } + +// PromptMultiSelect presents a numbered list of options and asks the user to input +// comma-separated choices (e.g. "1,3"). Hitting enter without choices defaults to all. +func PromptMultiSelect(t *i18n.Translations, message string, options []string) ([]string, error) { + if len(options) == 0 { + return nil, nil + } + + fmt.Printf("\n%s\n", Info.Sprint(message)) + for i, opt := range options { + fmt.Printf(" [%s] %s\n", Accent.Sprintf("%d", i+1), opt) + } + + fmt.Printf("\n%s: ", Dim.Sprint(t.GetMessage("ui.multi_select_prompt", 0, nil))) + + var response string + reader := bufio.NewReader(os.Stdin) + response, _ = reader.ReadString('\n') + response = strings.TrimSpace(response) + + if response == "" { + return options, nil + } + + var selected []string + parts := strings.Split(response, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + var idx int + _, err := fmt.Sscanf(p, "%d", &idx) + if err != nil || idx < 1 || idx > len(options) { + PrintError(os.Stdout, t.GetMessage("ui.multi_select_invalid", 0, struct{ Selection string }{p})) + continue + } + selected = append(selected, options[idx-1]) + } + + if len(selected) == 0 { + return options, nil + } + + return selected, nil +} diff --git a/internal/vcs/github/client.go b/internal/vcs/github/client.go index 73c6662..87f6bea 100644 --- a/internal/vcs/github/client.go +++ b/internal/vcs/github/client.go @@ -114,6 +114,7 @@ var labelDescriptions = map[string]string{ } func NewGitHubClient(owner, repo, token string) *GitHubClient { + token = strings.TrimSpace(token) var httpClient *http.Client if token != "" { ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) @@ -1006,12 +1007,31 @@ func (ghc *GitHubClient) getDiffFromCommits(ctx context.Context, commits []*gith return combinedDiff.String(), nil } +var labelAliases = map[string]string{ + "bug": "fix", + "enhancement": "feature", + "documentation": "docs", + "infrastructure": "infra", + "testing": "test", +} + func (ghc *GitHubClient) validateAndFilterLabels(labels []string) []string { var validLabels []string + seen := make(map[string]bool) + for _, label := range labels { cleaned := strings.ToLower(strings.TrimSpace(label)) - if cleaned != "" && ghc.isAllowedLabel(cleaned) { + if cleaned == "" { + continue + } + + if mapped, ok := labelAliases[cleaned]; ok { + cleaned = mapped + } + + if ghc.isAllowedLabel(cleaned) && !seen[cleaned] { validLabels = append(validLabels, cleaned) + seen[cleaned] = true } } return validLabels diff --git a/internal/vcs/github/client_test.go b/internal/vcs/github/client_test.go index 4a30571..853c63f 100644 --- a/internal/vcs/github/client_test.go +++ b/internal/vcs/github/client_test.go @@ -207,6 +207,30 @@ func TestLabelExists(t *testing.T) { } } +func TestValidateAndFilterLabels(t *testing.T) { + client := newTestClient(nil, nil, nil, nil) + tests := []struct { + name string + input []string + expected []string + }{ + {"Valid labels", []string{"feature", "fix"}, []string{"feature", "fix"}}, + {"Invalid labels ignored", []string{"feature", "invalid"}, []string{"feature"}}, + {"Mapped aliases", []string{"bug", "enhancement", "documentation", "infrastructure", "testing"}, []string{"fix", "feature", "docs", "infra", "test"}}, + {"Case insensitive", []string{"BUG", "FeaTure"}, []string{"fix", "feature"}}, + {"Duplicates ignored", []string{"feature", "fix", "feature"}, []string{"feature", "fix"}}, + {"Duplicate across mapping ignored", []string{"bug", "fix"}, []string{"fix"}}, + {"Empty strings ignored", []string{"", " ", "fix"}, []string{"fix"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := client.validateAndFilterLabels(tt.input) + assert.ElementsMatch(t, tt.expected, result) + }) + } +} + func TestGitHubClient_UpdatePR_ErrorCases(t *testing.T) { t.Run("should return error when Edit fails", func(t *testing.T) { mockPR := &MockPRService{}