diff --git a/cmd/main.go b/cmd/main.go index f7fd613..3d6efa1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,9 +9,9 @@ import ( "github.com/Tomas-vilte/MateCommit/internal/cli/command/config" "github.com/Tomas-vilte/MateCommit/internal/cli/command/handler" - "github.com/Tomas-vilte/MateCommit/internal/cli/command/pr" + "github.com/Tomas-vilte/MateCommit/internal/cli/command/pull_requests" "github.com/Tomas-vilte/MateCommit/internal/cli/command/release" - "github.com/Tomas-vilte/MateCommit/internal/cli/command/suggest" + "github.com/Tomas-vilte/MateCommit/internal/cli/command/suggests_commits" "github.com/Tomas-vilte/MateCommit/internal/cli/registry" cfg "github.com/Tomas-vilte/MateCommit/internal/config" "github.com/Tomas-vilte/MateCommit/internal/i18n" @@ -72,7 +72,7 @@ func initializeApp() (*cli.Command, error) { ticketService := jira.NewJiraService(cfgApp, &http.Client{}) - commitService := services.NewCommitService(gitService, aiProvider, ticketService, cfgApp, translations) + commitService := services.NewCommitService(gitService, aiProvider, ticketService, nil, cfgApp, translations) commitHandler := handler.NewSuggestionHandler(gitService, translations) @@ -80,9 +80,9 @@ func initializeApp() (*cli.Command, error) { prServiceFactory := factory.NewPrServiceFactory(cfgApp, translations, aiSummarizer, gitService) - prCommand := pr.NewSummarizeCommand(prServiceFactory) + prCommand := pull_requests.NewSummarizeCommand(prServiceFactory) - if err := registerCommand.Register("suggest", suggest.NewSuggestCommandFactory(commitService, commitHandler)); err != nil { + if err := registerCommand.Register("suggest", suggests_commits.NewSuggestCommandFactory(commitService, commitHandler)); err != nil { log.Fatalf("Error al registrar el comando 'suggest': %v", err) } diff --git a/internal/cli/command/handler/suggestions_test.go b/internal/cli/command/handler/suggestions_test.go index bf54739..7ba55f3 100644 --- a/internal/cli/command/handler/suggestions_test.go +++ b/internal/cli/command/handler/suggestions_test.go @@ -81,6 +81,11 @@ func (m *mockGitService) PushTag(ctx context.Context, version string) error { return args.Error(0) } +func (m *mockGitService) GetRecentCommitMessages(ctx context.Context, count int) (string, error) { + args := m.Called(ctx, count) + return args.String(0), args.Error(1) +} + func captureOutput(f func()) string { old := os.Stdout r, w, _ := os.Pipe() diff --git a/internal/cli/command/pr/summarize.go b/internal/cli/command/pull_requests/summarize.go similarity index 98% rename from internal/cli/command/pr/summarize.go rename to internal/cli/command/pull_requests/summarize.go index cd1e91e..6a887a6 100644 --- a/internal/cli/command/pr/summarize.go +++ b/internal/cli/command/pull_requests/summarize.go @@ -1,4 +1,4 @@ -package pr +package pull_requests import ( "context" diff --git a/internal/cli/command/pr/summarize_test.go b/internal/cli/command/pull_requests/summarize_test.go similarity index 99% rename from internal/cli/command/pr/summarize_test.go rename to internal/cli/command/pull_requests/summarize_test.go index 031df9d..f2c89a5 100644 --- a/internal/cli/command/pr/summarize_test.go +++ b/internal/cli/command/pull_requests/summarize_test.go @@ -1,4 +1,4 @@ -package pr +package pull_requests import ( "context" diff --git a/internal/cli/command/release/mocks.go b/internal/cli/command/release/mocks.go index 26823a6..6a900d9 100644 --- a/internal/cli/command/release/mocks.go +++ b/internal/cli/command/release/mocks.go @@ -126,3 +126,8 @@ func (m *MockGitService) PushTag(ctx context.Context, version string) error { args := m.Called(ctx, version) return args.Error(0) } + +func (m *MockGitService) GetRecentCommitMessages(ctx context.Context, count int) (string, error) { + args := m.Called(ctx, count) + return args.String(0), args.Error(1) +} diff --git a/internal/cli/command/suggest/suggest.go b/internal/cli/command/suggests_commits/suggests_commits.go similarity index 77% rename from internal/cli/command/suggest/suggest.go rename to internal/cli/command/suggests_commits/suggests_commits.go index 291d6ad..2d2396f 100644 --- a/internal/cli/command/suggest/suggest.go +++ b/internal/cli/command/suggests_commits/suggests_commits.go @@ -1,10 +1,11 @@ -package suggest +package suggests_commits import ( "context" "fmt" "github.com/Tomas-vilte/MateCommit/internal/config" + "github.com/Tomas-vilte/MateCommit/internal/domain/models" "github.com/Tomas-vilte/MateCommit/internal/domain/ports" "github.com/Tomas-vilte/MateCommit/internal/i18n" "github.com/urfave/cli/v3" @@ -53,6 +54,12 @@ func (f *SuggestCommandFactory) createFlags(cfg *config.Config, t *i18n.Translat Value: cfg.UseEmoji, Usage: t.GetMessage("suggest_no_emoji_flag_usage", 0, nil), }, + &cli.IntFlag{ + Name: "issue", + Aliases: []string{"i"}, + Usage: t.GetMessage("suggest_issue_flag_usage", 0, nil), + Value: 0, + }, } } @@ -60,9 +67,9 @@ func (f *SuggestCommandFactory) createAction(cfg *config.Config, t *i18n.Transla return func(ctx context.Context, command *cli.Command) error { emojiFlag := command.Bool("no-emoji") if emojiFlag { - cfg.UseEmoji = false // Deshabilitar emojis si --no-emoji está presente + cfg.UseEmoji = false } else { - cfg.UseEmoji = true // Habilitar emojis si --no-emoji no está presente + cfg.UseEmoji = true } count := command.Int("count") if count < 1 || count > 10 { @@ -80,7 +87,21 @@ func (f *SuggestCommandFactory) createAction(cfg *config.Config, t *i18n.Transla } fmt.Println(t.GetMessage("analyzing_changes", 0, nil)) - suggestions, err := f.commitService.GenerateSuggestions(ctx, int(count)) + + issueNumber := command.Int("issue") + var suggestions []models.CommitSuggestion + var err error + + if issueNumber > 0 { + msg := t.GetMessage("issue_including_context", 0, map[string]interface{}{ + "Number": issueNumber, + }) + fmt.Println(msg) + suggestions, err = f.commitService.GenerateSuggestionsWithIssue(ctx, count, issueNumber) + } else { + suggestions, err = f.commitService.GenerateSuggestions(ctx, count) + } + if err != nil { msg := t.GetMessage("suggestion_generation_error", 0, map[string]interface{}{"Error": err}) return fmt.Errorf("%s", msg) diff --git a/internal/cli/command/suggest/suggest_test.go b/internal/cli/command/suggests_commits/suggests_commits_test.go similarity index 94% rename from internal/cli/command/suggest/suggest_test.go rename to internal/cli/command/suggests_commits/suggests_commits_test.go index 54b48b4..24a6d76 100644 --- a/internal/cli/command/suggest/suggest_test.go +++ b/internal/cli/command/suggests_commits/suggests_commits_test.go @@ -1,4 +1,4 @@ -package suggest +package suggests_commits import ( "context" @@ -14,7 +14,6 @@ import ( "github.com/stretchr/testify/mock" ) -// Mock para CommitService type MockCommitService struct { mock.Mock } @@ -24,6 +23,11 @@ func (m *MockCommitService) GenerateSuggestions(ctx context.Context, count int) return args.Get(0).([]models.CommitSuggestion), args.Error(1) } +func (m *MockCommitService) GenerateSuggestionsWithIssue(ctx context.Context, count int, issueNumber int) ([]models.CommitSuggestion, error) { + args := m.Called(ctx, count, issueNumber) + return args.Get(0).([]models.CommitSuggestion), args.Error(1) +} + // Mock para CommitHandler type MockCommitHandler struct { mock.Mock @@ -45,7 +49,7 @@ func setupTestEnv(t *testing.T) (*config.Config, *i18n.Translations, func()) { PathFile: tmpConfigPath, Language: "es", UseEmoji: true, - SuggestionsCount: 3, // Añadido el valor por defecto + SuggestionsCount: 3, } translations, err := i18n.NewTranslations("es", "../../../i18n/locales") @@ -85,7 +89,7 @@ func TestSuggestCommand(t *testing.T) { cmd := factory.CreateCommand(translations, cfg) // Act - err := cmd.Run(ctx, []string{"suggest"}) // Sin especificar count, usa el valor por defecto + err := cmd.Run(ctx, []string{"suggest"}) // Assert assert.NoError(t, err) diff --git a/internal/domain/models/commit.go b/internal/domain/models/commit.go index 6e50ec6..2fbd084 100644 --- a/internal/domain/models/commit.go +++ b/internal/domain/models/commit.go @@ -13,6 +13,7 @@ type ( Files []string Diff string TicketInfo *TicketInfo + IssueInfo *Issue } GitChange struct { diff --git a/internal/domain/models/issue.go b/internal/domain/models/issue.go new file mode 100644 index 0000000..9e67cc5 --- /dev/null +++ b/internal/domain/models/issue.go @@ -0,0 +1,12 @@ +package models + +type Issue struct { + ID int + Number int + Title string + Description string + State string + Labels []string + Author string + URL string +} diff --git a/internal/domain/models/release.go b/internal/domain/models/release.go index 6e4c23b..e431d5b 100644 --- a/internal/domain/models/release.go +++ b/internal/domain/models/release.go @@ -26,14 +26,6 @@ type ( FileStats FileStatistics } - Issue struct { - Number int - Title string - Labels []string - Author string - URL string - } - PullRequest struct { Number int Title string diff --git a/internal/domain/ports/commit_service.go b/internal/domain/ports/commit_service.go index dd752c3..71f45e8 100644 --- a/internal/domain/ports/commit_service.go +++ b/internal/domain/ports/commit_service.go @@ -2,9 +2,13 @@ package ports import ( "context" + "github.com/Tomas-vilte/MateCommit/internal/domain/models" ) type CommitService interface { + // GenerateSuggestions genera sugerencias de commit basadas en los cambios detectados GenerateSuggestions(ctx context.Context, count int) ([]models.CommitSuggestion, error) + // GenerateSuggestionsWithIssue genera sugerencias considerando un issue específico + GenerateSuggestionsWithIssue(ctx context.Context, count int, issueNumber int) ([]models.CommitSuggestion, error) } diff --git a/internal/domain/ports/git_service.go b/internal/domain/ports/git_service.go index 58d5289..3b9c50a 100644 --- a/internal/domain/ports/git_service.go +++ b/internal/domain/ports/git_service.go @@ -33,6 +33,7 @@ type GitService interface { GetLastTag(ctx context.Context) (string, error) GetCommitCount(ctx context.Context) (int, error) GetCommitsSinceTag(ctx context.Context, tag string) ([]models.Commit, error) + GetRecentCommitMessages(ctx context.Context, count int) (string, error) CreateTag(ctx context.Context, version, message string) error PushTag(ctx context.Context, version string) error } diff --git a/internal/domain/ports/vcs_client.go b/internal/domain/ports/vcs_client.go index 46c7e19..5b197e7 100644 --- a/internal/domain/ports/vcs_client.go +++ b/internal/domain/ports/vcs_client.go @@ -32,6 +32,6 @@ type VCSClient interface { GetContributorsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]string, error) // GetFileStatsBetweenTags obtiene estadísticas de archivos entre dos tags GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) - // GetFileAtTag obtiene el contenido de un archivo en un tag específico - GetFileAtTag(ctx context.Context, tag, filepath string) (string, error) + // GetIssue obtiene información de un issue/ticket por su número + GetIssue(ctx context.Context, issueNumber int) (*models.Issue, error) } diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index 307d1a8..031155a 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -25,6 +25,24 @@ other = "Language (en, es)" [suggest_no_emoji_flag_usage] other = "Disable emojis" +[suggest_issue_flag_usage] +other = "Issue/ticket number to include context in commit" + +[issue_detected_auto] +other = "🔍 Detected issue #{{.Number}}: {{.Title}}" + +[issue_using_manual] +other = "📋 Using issue #{{.Number}}: {{.Title}}" + +[issue_including_context] +other = "📋 Including context from issue #{{.Number}}" + +[issue_vcs_init_error] +other = "⚠️ Could not initialize VCS client: {{.Error}}" + +[issue_fetch_error] +other = "⚠️ Could not fetch issue #{{.Number}} information: {{.Error}}" + [no_staged_changes] other = "❌ No staged changes to commit.\nUse 'git add' to stage your changes first" diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index a0a5138..e40ab22 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -25,6 +25,24 @@ other = "Idioma (en, es)" [suggest_no_emoji_flag_usage] other = "Desactivar emojis" +[suggest_issue_flag_usage] +other = "Número de issue/ticket para incluir contexto en el commit" + +[issue_detected_auto] +other = "🔍 Detectado issue #{{.Number}}: {{.Title}}" + +[issue_using_manual] +other = "📋 Usando issue #{{.Number}}: {{.Title}}" + +[issue_including_context] +other = "📋 Incluyendo contexto del issue #{{.Number}}" + +[issue_vcs_init_error] +other = "⚠️ No se pudo inicializar el cliente VCS: {{.Error}}" + +[issue_fetch_error] +other = "⚠️ No se pudo obtener información del issue #{{.Number}}: {{.Error}}" + [no_staged_changes] other = "❌ No hay cambios preparados para commitear.\nUsá 'git add' para preparar tus cambios primero" diff --git a/internal/infrastructure/ai/gemini/gemini_service.go b/internal/infrastructure/ai/gemini/commit_summarizer_service.go similarity index 84% rename from internal/infrastructure/ai/gemini/gemini_service.go rename to internal/infrastructure/ai/gemini/commit_summarizer_service.go index 1b6191c..cbceb5a 100644 --- a/internal/infrastructure/ai/gemini/gemini_service.go +++ b/internal/infrastructure/ai/gemini/commit_summarizer_service.go @@ -71,6 +71,10 @@ func (s *GeminiService) GenerateSuggestions(ctx context.Context, info models.Com return nil, fmt.Errorf("%s", msg) } + if info.IssueInfo != nil && info.IssueInfo.Number > 0 { + suggestions = s.ensureIssueReference(suggestions, info.IssueInfo.Number) + } + return suggestions, nil } @@ -85,12 +89,21 @@ func (s *GeminiService) generatePrompt(locale string, info models.CommitInfo, co strings.Join(info.TicketInfo.Criteria, ", ")) } + issueInstructions := "" + if info.IssueInfo != nil && info.IssueInfo.Number > 0 { + num := info.IssueInfo.Number + issueInstructions = fmt.Sprintf(ai.GetIssueReferenceInstructions(locale), num, num, num, num, num, num, num, num) + } else { + issueInstructions = "No hay issue asociado, no incluyas referencias de issues en el título." + } + return fmt.Sprintf(promptTemplate, count, count, formatChanges(info.Files), info.Diff, ticketInfo, + issueInstructions, ) } @@ -188,6 +201,12 @@ func (s *GeminiService) parseSuggestionPart(part string) *models.CommitSuggestio if collectingFiles { if strings.HasPrefix(trimmedLine, "-") { file := strings.TrimSpace(strings.TrimPrefix(trimmedLine, "-")) + if strings.Contains(file, "->") { + parts := strings.Split(file, "->") + if len(parts) > 1 { + file = strings.TrimSpace(parts[len(parts)-1]) + } + } suggestion.Files = append(suggestion.Files, file) } else if trimmedLine == "" || strings.HasPrefix(trimmedLine, s.trans.GetMessage("gemini_service.explanation_prefix", 0, nil)) { collectingFiles = false @@ -276,3 +295,29 @@ func (s *GeminiService) parseSuggestionPart(part string) *models.CommitSuggestio return suggestion } + +// ensureIssueReference asegura que todas las sugerencias incluyan la referencia al issue correcta +func (s *GeminiService) ensureIssueReference(suggestions []models.CommitSuggestion, issueNumber int) []models.CommitSuggestion { + issuePattern := regexp.MustCompile(`\(#\d+\)`) + + for i := range suggestions { + title := suggestions[i].CommitTitle + title = strings.TrimSpace(title) + + if strings.Contains(title, fmt.Sprintf("(#%d)", issueNumber)) || + strings.Contains(title, fmt.Sprintf("fixes #%d", issueNumber)) || + strings.Contains(title, fmt.Sprintf("closes #%d", issueNumber)) { + continue + } + + if issuePattern.MatchString(title) { + title = issuePattern.ReplaceAllString(title, fmt.Sprintf("(#%d)", issueNumber)) + suggestions[i].CommitTitle = title + continue + } + + suggestions[i].CommitTitle = fmt.Sprintf("%s (#%d)", title, issueNumber) + } + + return suggestions +} diff --git a/internal/infrastructure/ai/gemini/gemini_service_test.go b/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go similarity index 90% rename from internal/infrastructure/ai/gemini/gemini_service_test.go rename to internal/infrastructure/ai/gemini/commit_summarizer_service_test.go index e508b76..439dd11 100644 --- a/internal/infrastructure/ai/gemini/gemini_service_test.go +++ b/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go @@ -162,12 +162,10 @@ func TestGeminiService(t *testing.T) { assert.Contains(t, suggestion.Files, "internal/cli/command/config/set_jira_config.go") assert.Equal(t, "Se mejoró la salida de sugerencias y el manejo de errores en la configuración de Jira.", suggestion.Explanation) - // Verificar análisis de código assert.Contains(t, suggestion.CodeAnalysis.ChangesOverview, "Mejora en el manejo de la configuración de Jira") assert.Contains(t, suggestion.CodeAnalysis.PrimaryPurpose, "Mejorar la experiencia del usuario") assert.Contains(t, suggestion.CodeAnalysis.TechnicalImpact, "Se modifican varias partes del código") - // Verificar análisis de requisitos assert.Equal(t, models.CriteriaPartiallyMet, suggestion.RequirementsAnalysis.CriteriaStatus) assert.Equal(t, 2, len(suggestion.RequirementsAnalysis.MissingCriteria)) assert.Equal(t, 2, len(suggestion.RequirementsAnalysis.ImprovementSuggestions)) @@ -267,7 +265,7 @@ func TestGeminiService(t *testing.T) { ctx := context.Background() cfg := &config.Config{ Language: "en", - UseEmoji: true, // Emoji activado, pero opcional en inglés + UseEmoji: true, GeminiAPIKey: "test-api-key", } @@ -284,7 +282,6 @@ func TestGeminiService(t *testing.T) { // act prompt := service.generatePrompt(cfg.Language, info, 3) - // Imprimir el prompt generado para depuración t.Logf("Prompt generado:\n%s", prompt) // assert @@ -293,7 +290,6 @@ func TestGeminiService(t *testing.T) { assert.Contains(t, prompt, "Diff:", "Debe incluir la sección de diff") assert.Contains(t, prompt, "Technical Analysis:", "Debe incluir la sección de análisis técnico") - // Verificación opcional de emojis if cfg.UseEmoji { assert.Contains(t, prompt, "🔍", "El prompt debería contener el emoji de análisis si está activado") } @@ -327,4 +323,41 @@ func TestGeminiService(t *testing.T) { t.Errorf("Expected nil, got: %v", suggestions) } }) + t.Run("ParseSuggestions with rename format", func(t *testing.T) { + // arrange + ctx := context.Background() + cfg := &config.Config{ + GeminiAPIKey: "test-api-key", + } + trans, _ := i18n.NewTranslations("es", "../../../i18n/locales/") + service, _ := NewGeminiService(ctx, cfg, trans) + + responseTextWithRename := `📊 Análisis: +- Resumen: Renamed file. +- Propósito: Test rename. +- Impacto: Low. + +📝 Sugerencias: +Commit: refactor: Rename file +📄 Archivos modificados: + - old/path/file.go -> new/path/file.go +Explicación: Renaming file. +` + + resp := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + {Content: &genai.Content{Parts: []*genai.Part{{Text: responseTextWithRename}}}}, + }, + } + + // act + suggestions := service.parseSuggestions(resp) + + // assert + assert.Equal(t, 1, len(suggestions)) + if len(suggestions) > 0 { + assert.Contains(t, suggestions[0].Files, "new/path/file.go") + assert.NotContains(t, suggestions[0].Files, "old/path/file.go -> new/path/file.go") + } + }) } diff --git a/internal/infrastructure/ai/gemini/gemini_pr_summarizer_service.go b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go similarity index 100% rename from internal/infrastructure/ai/gemini/gemini_pr_summarizer_service.go rename to internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go diff --git a/internal/infrastructure/ai/gemini/gemini_pr_summarizer_service_test.go b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service_test.go similarity index 100% rename from internal/infrastructure/ai/gemini/gemini_pr_summarizer_service_test.go rename to internal/infrastructure/ai/gemini/pull_requests_summarizer_service_test.go diff --git a/internal/infrastructure/ai/prompts.go b/internal/infrastructure/ai/prompts.go index 5ca8d31..11ae3fc 100644 --- a/internal/infrastructure/ai/prompts.go +++ b/internal/infrastructure/ai/prompts.go @@ -1,5 +1,26 @@ package ai +// Issue reference instructions +const ( + issueReferenceInstructionsES = `Si hay un issue asociado (#%d), DEBES incluir la referencia en el título del commit: + - Para features/mejoras: "tipo: mensaje (#%d)" + - Para bugs: "fix: mensaje (#%d)" o "fix(scope): mensaje (fixes #%d)" + - Ejemplos válidos: + ✅ feat: add dark mode support (#%d) + ✅ fix: resolve authentication error (fixes #%d) + ✅ feat(api): implement caching layer (#%d) + - NUNCA omitas la referencia del issue #%d.` + + issueReferenceInstructionsEN = `There is an associated issue (#%d), you MUST include the reference in the commit title: + - For features/improvements: "type: message (#%d)" + - For bugs: "fix: message (#%d)" or "fix(scope): message (fixes #%d)" + - Valid examples: + ✅ feat: add dark mode support (#%d) + ✅ fix: resolve authentication error (fixes #%d) + ✅ feat(api): implement caching layer (#%d) + - NEVER omit the reference to issue #%d.` +) + // Templates para Pull Requests const ( prPromptTemplateEN = `Hey, could you whip up a summary for this PR with: @@ -57,6 +78,7 @@ const ( - chore: Maintenance tasks 7. Keep commit messages under 100 characters. 8. Provide specific, actionable improvement suggestions, especially related to meeting acceptance criteria. + 9. **IMPORTANT - Issue/Ticket References:** %s Suggestion Format: =========[ Suggestion ]========= @@ -115,6 +137,7 @@ const ( - chore: Tareas de mantenimiento 7. Mantené los mensajes de commit en menos de 100 caracteres. 8. Proporcioná sugerencias de mejora específicas y accionables, especialmente relacionadas con el cumplimiento de los criterios de aceptación. + 9. **IMPORTANTE - Referencias de Issues/Tickets:** %s Formato de Sugerencia: =========[ Sugerencia ]========= @@ -176,6 +199,7 @@ const ( - chore: Tareas de mantenimiento 6. Mantené los mensajes de commit en menos de 100 caracteres. 7. Proporcioná sugerencias de mejora específicas y accionables. + 8. **IMPORTANTE - Referencias de Issues:** %s Formato de Sugerencia: =========[ Sugerencia ]========= @@ -222,6 +246,7 @@ const ( - chore: Maintenance tasks 6. Keep commit messages under 100 characters. 7. Provide specific, actionable improvement suggestions. + 8. **IMPORTANT - Issue References:** %s Suggestion Format: =========[ Suggestion ]========= @@ -468,3 +493,13 @@ func GetReleasePromptTemplate(lang string) string { return releasePromptTemplateEN } } + +// GetIssueReferenceInstructions devuelve las instrucciones de referencias de issues según el idioma +func GetIssueReferenceInstructions(lang string) string { + switch lang { + case "es": + return issueReferenceInstructionsES + default: + return issueReferenceInstructionsEN + } +} diff --git a/internal/infrastructure/git/git_service.go b/internal/infrastructure/git/git_service.go index 3261cdc..6a853ad 100644 --- a/internal/infrastructure/git/git_service.go +++ b/internal/infrastructure/git/git_service.go @@ -95,13 +95,11 @@ func (s *GitService) GetDiff(ctx context.Context) (string, error) { } func (s *GitService) CreateCommit(ctx context.Context, message string) error { - // Primero verificamos si hay cambios staged if !s.HasStagedChanges(ctx) { msg := s.trans.GetMessage("git.no_staged_changes", 0, nil) return fmt.Errorf("%s", msg) } - // Creamos el commit cmd := exec.CommandContext(ctx, "git", "commit", "-m", message) return cmd.Run() } @@ -217,6 +215,15 @@ func (s *GitService) GetCommitsSinceTag(ctx context.Context, tag string) ([]mode return commits, nil } +func (s *GitService) GetRecentCommitMessages(ctx context.Context, count int) (string, error) { + cmd := exec.CommandContext(ctx, "git", "log", fmt.Sprintf("-%d", count), "--pretty=format:%s %b") + output, err := cmd.Output() + if err != nil { + return "", err + } + return string(output), nil +} + func (s *GitService) CreateTag(ctx context.Context, version, message string) error { cmd := exec.CommandContext(ctx, "git", "tag", "-a", version, "-m", message) return cmd.Run() diff --git a/internal/infrastructure/vcs/github/github_service.go b/internal/infrastructure/vcs/github/github_service.go index e194eed..07fba8d 100644 --- a/internal/infrastructure/vcs/github/github_service.go +++ b/internal/infrastructure/vcs/github/github_service.go @@ -29,12 +29,12 @@ type IssuesService interface { CreateLabel(ctx context.Context, owner, repo string, label *github.Label) (*github.Label, *github.Response, error) AddLabelsToIssue(ctx context.Context, owner, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) ListByRepo(ctx context.Context, owner, repo string, opts *github.IssueListByRepoOptions) ([]*github.Issue, *github.Response, error) + Get(ctx context.Context, owner, repo string, number int) (*github.Issue, *github.Response, error) } type RepositoriesService interface { GetCommit(ctx context.Context, owner, repo, sha string, opts *github.ListOptions) (*github.RepositoryCommit, *github.Response, error) CompareCommits(ctx context.Context, owner, repo, base, head string, opts *github.ListOptions) (*github.CommitsComparison, *github.Response, error) - GetContents(ctx context.Context, owner, repo, path string, opts *github.RepositoryContentGetOptions) (*github.RepositoryContent, []*github.RepositoryContent, *github.Response, error) } type ReleasesService interface { @@ -459,26 +459,49 @@ func (ghc *GitHubClient) GetFileStatsBetweenTags(ctx context.Context, previousTa return stats, nil } -func (ghc *GitHubClient) GetFileAtTag(ctx context.Context, tag, filepath string) (string, error) { - opts := &github.RepositoryContentGetOptions{ - Ref: tag, +func (ghc *GitHubClient) GetIssue(ctx context.Context, issueNumber int) (*models.Issue, error) { + issue, _, err := ghc.issuesService.Get(ctx, ghc.owner, ghc.repo, issueNumber) + if err != nil { + return nil, fmt.Errorf("error obteniendo issue #%d: %w", issueNumber, err) } - fileContent, _, _, err := ghc.repoService.GetContents(ctx, ghc.owner, ghc.repo, tag, opts) - if err != nil { - return "", err + labels := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + if label.Name != nil { + labels = append(labels, label.GetName()) + } } - if fileContent == nil { - return "", fmt.Errorf("archivo no encontrado: %s en %s", filepath, tag) + var author string + if issue.User != nil && issue.User.Login != nil { + author = *issue.User.Login } - content, err := fileContent.GetContent() - if err != nil { - return "", fmt.Errorf("error decodificando contenido del archivo: %w", err) + var description string + if issue.Body != nil { + description = *issue.Body } - return content, nil + var state string + if issue.State != nil { + state = *issue.State + } + + var url string + if issue.HTMLURL != nil { + url = *issue.HTMLURL + } + + return &models.Issue{ + ID: int(issue.GetID()), + Number: issue.GetNumber(), + Title: issue.GetTitle(), + Description: description, + State: state, + Labels: labels, + Author: author, + URL: url, + }, nil } func (ghc *GitHubClient) labelExists(existingLabels []string, target string) bool { diff --git a/internal/infrastructure/vcs/github/github_service_test.go b/internal/infrastructure/vcs/github/github_service_test.go index bbfc6dc..0c5de81 100644 --- a/internal/infrastructure/vcs/github/github_service_test.go +++ b/internal/infrastructure/vcs/github/github_service_test.go @@ -2,9 +2,10 @@ package github import ( "context" - "errors" + "fmt" "net/http" "testing" + "time" "github.com/Tomas-vilte/MateCommit/internal/domain/models" "github.com/Tomas-vilte/MateCommit/internal/i18n" @@ -28,19 +29,6 @@ func newTestClient(pr *MockPRService, issues *MockIssuesService, release *MockRe ) } -func newTestClientWithRepo(pr *MockPRService, issues *MockIssuesService, repo *MockRepoService, release *MockReleaseService) *GitHubClient { - trans, _ := i18n.NewTranslations("es", "../../../i18n/locales/") - return NewGitHubClientWithServices( - pr, - issues, - repo, - release, - "test-owner", - "test-repo", - trans, - ) -} - func TestGitHubClient_UpdatePR(t *testing.T) { t.Run("should update PR successfully", func(t *testing.T) { mockPR := &MockPRService{} @@ -586,61 +574,442 @@ func TestGitHubClient_UpdateRelease(t *testing.T) { }) } -func TestGitHubClient_GetFileAtTag(t *testing.T) { - t.Run("should get file content successfully", func(t *testing.T) { +func TestGitHubClient_GetIssue(t *testing.T) { + t.Run("should get issue successfully", func(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} - mockRepo := &MockRepoService{} mockRelease := &MockReleaseService{} - client := newTestClientWithRepo(mockPR, mockIssues, mockRepo, mockRelease) + client := newTestClient(mockPR, mockIssues, mockRelease) - fileContent := &github.RepositoryContent{ - Content: github.Ptr("ZmlsZSBjb250ZW50IGhlcmU="), // base64 encoded + issueNumber := 123 + expectedIssue := &github.Issue{ + ID: github.Ptr(int64(456)), + Number: github.Ptr(issueNumber), + Title: github.Ptr("Issue Title"), + Body: github.Ptr("Issue Body"), + State: github.Ptr("open"), + User: &github.User{Login: github.Ptr("author-name")}, + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Labels: []*github.Label{ + {Name: github.Ptr("bug")}, + {Name: github.Ptr("high-priority")}, + }, } - mockRepo.On("GetContents", mock.Anything, "test-owner", "test-repo", "v1.0.0", mock.MatchedBy(func(opts *github.RepositoryContentGetOptions) bool { - return opts.Ref == "v1.0.0" - })).Return(fileContent, []*github.RepositoryContent{}, &github.Response{}, nil) + mockIssues.On("Get", mock.Anything, "test-owner", "test-repo", issueNumber). + Return(expectedIssue, &github.Response{}, nil) - content, err := client.GetFileAtTag(context.Background(), "v1.0.0", "go.mod") + result, err := client.GetIssue(context.Background(), issueNumber) assert.NoError(t, err) - assert.NotEmpty(t, content) - mockRepo.AssertExpectations(t) + assert.Equal(t, 456, result.ID) + assert.Equal(t, issueNumber, result.Number) + assert.Equal(t, "Issue Title", result.Title) + assert.Equal(t, "Issue Body", result.Description) + assert.Equal(t, "open", result.State) + assert.Equal(t, "author-name", result.Author) + assert.Equal(t, "https://github.com/owner/repo/issues/123", result.URL) + assert.Contains(t, result.Labels, "bug") + assert.Contains(t, result.Labels, "high-priority") + mockIssues.AssertExpectations(t) }) - t.Run("should return error if file not found", func(t *testing.T) { + t.Run("should handle nil fields in issue", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + issueNumber := 123 + expectedIssue := &github.Issue{ + ID: github.Ptr(int64(456)), + Number: github.Ptr(issueNumber), + Title: github.Ptr("Issue Title"), + } + + mockIssues.On("Get", mock.Anything, "test-owner", "test-repo", issueNumber). + Return(expectedIssue, &github.Response{}, nil) + + result, err := client.GetIssue(context.Background(), issueNumber) + + assert.NoError(t, err) + assert.Equal(t, "Issue Title", result.Title) + assert.Empty(t, result.Description) + assert.Empty(t, result.State) + assert.Empty(t, result.Author) + assert.Empty(t, result.URL) + assert.Empty(t, result.Labels) + }) + + t.Run("should return error when Get fails", func(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} - mockRepo := &MockRepoService{} mockRelease := &MockReleaseService{} - client := newTestClientWithRepo(mockPR, mockIssues, mockRepo, mockRelease) + client := newTestClient(mockPR, mockIssues, mockRelease) - mockRepo.On("GetContents", mock.Anything, "test-owner", "test-repo", "v1.0.0", mock.Anything). - Return((*github.RepositoryContent)(nil), []*github.RepositoryContent{}, &github.Response{}, errors.New("file not found")) + issueNumber := 123 - content, err := client.GetFileAtTag(context.Background(), "v1.0.0", "go.mod") + mockIssues.On("Get", mock.Anything, "test-owner", "test-repo", issueNumber). + Return((*github.Issue)(nil), &github.Response{}, assert.AnError) + + _, err := client.GetIssue(context.Background(), issueNumber) assert.Error(t, err) - assert.Empty(t, content) - mockRepo.AssertExpectations(t) + assert.Contains(t, err.Error(), fmt.Sprintf("error obteniendo issue #%d", issueNumber)) + mockIssues.AssertExpectations(t) + }) +} + +func TestGitHubClient_GetClosedIssuesBetweenTags(t *testing.T) { + t.Run("should get closed issues between tags", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + prevTag := "v1.0.0" + currTag := "v1.1.0" + prevReleaseDate := github.Timestamp{Time: github.Timestamp{}.Time.Add(-24 * time.Hour)} + + prevRelease := &github.RepositoryRelease{ + CreatedAt: &prevReleaseDate, + } + + expectedIssues := []*github.Issue{ + {Number: github.Ptr(1), Title: github.Ptr("Issue 1"), PullRequestLinks: nil, Labels: []*github.Label{{Name: github.Ptr("bug")}}}, + {Number: github.Ptr(2), Title: github.Ptr("Issue 2"), PullRequestLinks: nil}, + {Number: github.Ptr(3), Title: github.Ptr("PR 3"), PullRequestLinks: &github.PullRequestLinks{}}, + } + + mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", prevTag). + Return(prevRelease, &github.Response{}, nil) + + mockIssues.On("ListByRepo", mock.Anything, "test-owner", "test-repo", mock.MatchedBy(func(opts *github.IssueListByRepoOptions) bool { + return opts.State == "closed" && opts.Since == prevReleaseDate.Time && opts.Sort == "updated" && opts.Direction == "desc" + })).Return(expectedIssues, &github.Response{}, nil) + + issues, err := client.GetClosedIssuesBetweenTags(context.Background(), prevTag, currTag) + + assert.NoError(t, err) + assert.Len(t, issues, 2) + assert.Equal(t, 1, issues[0].Number) + assert.Equal(t, "Issue 1", issues[0].Title) + assert.Equal(t, 2, issues[1].Number) + assert.Equal(t, "Issue 2", issues[1].Title) + mockRelease.AssertExpectations(t) + mockIssues.AssertExpectations(t) + }) + + t.Run("should handle pagination", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + prevTag := "v1.0.0" + prevReleaseDate := github.Timestamp{Time: github.Timestamp{}.Time.Add(-24 * time.Hour)} + + prevRelease := &github.RepositoryRelease{ + CreatedAt: &prevReleaseDate, + } + + page1Issues := []*github.Issue{ + {Number: github.Ptr(1), PullRequestLinks: nil}, + } + page2Issues := []*github.Issue{ + {Number: github.Ptr(2), PullRequestLinks: nil}, + } + + mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", prevTag). + Return(prevRelease, &github.Response{}, nil) + + mockIssues.On("ListByRepo", mock.Anything, "test-owner", "test-repo", mock.MatchedBy(func(opts *github.IssueListByRepoOptions) bool { + return opts.ListOptions.Page == 0 + })).Return(page1Issues, &github.Response{NextPage: 2}, nil) + + mockIssues.On("ListByRepo", mock.Anything, "test-owner", "test-repo", mock.MatchedBy(func(opts *github.IssueListByRepoOptions) bool { + return opts.ListOptions.Page == 2 + })).Return(page2Issues, &github.Response{NextPage: 0}, nil) + + issues, err := client.GetClosedIssuesBetweenTags(context.Background(), prevTag, "v1.1.0") + + assert.NoError(t, err) + assert.Len(t, issues, 2) + assert.Equal(t, 1, issues[0].Number) + assert.Equal(t, 2, issues[1].Number) + }) + + t.Run("should return error if GetReleaseByTag fails", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", "v1.0.0"). + Return((*github.RepositoryRelease)(nil), &github.Response{}, assert.AnError) + + _, err := client.GetClosedIssuesBetweenTags(context.Background(), "v1.0.0", "v1.1.0") + + assert.Error(t, err) + }) + + t.Run("should return error if ListByRepo fails", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + prevRelease := &github.RepositoryRelease{ + CreatedAt: &github.Timestamp{}, + } + + mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", "v1.0.0"). + Return(prevRelease, &github.Response{}, nil) + + mockIssues.On("ListByRepo", mock.Anything, "test-owner", "test-repo", mock.Anything). + Return([]*github.Issue{}, &github.Response{}, assert.AnError) + + _, err := client.GetClosedIssuesBetweenTags(context.Background(), "v1.0.0", "v1.1.0") + + assert.Error(t, err) + }) +} + +func TestGitHubClient_GetMergedPRsBetweenTags(t *testing.T) { + t.Run("should get merged PRs between tags", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + prevTag := "v1.0.0" + currTag := "v1.1.0" + + prevTime := time.Now().Add(-48 * time.Hour) + mergedTime1 := time.Now().Add(-24 * time.Hour) + mergedTime2 := time.Now().Add(-72 * time.Hour) + + prevReleaseDate := github.Timestamp{Time: prevTime} + + prevRelease := &github.RepositoryRelease{ + CreatedAt: &prevReleaseDate, + } + + pr1 := &github.PullRequest{ + Number: github.Ptr(1), + Title: github.Ptr("PR 1"), + Body: github.Ptr("Description 1"), + User: &github.User{Login: github.Ptr("user1")}, + Merged: github.Ptr(true), + MergedAt: &github.Timestamp{Time: mergedTime1}, + HTMLURL: github.Ptr("url1"), + Labels: []*github.Label{{Name: github.Ptr("bug")}}, + } + + pr2 := &github.PullRequest{ + Number: github.Ptr(2), + Merged: github.Ptr(false), + } + + pr3 := &github.PullRequest{ + Number: github.Ptr(3), + Merged: github.Ptr(true), + MergedAt: &github.Timestamp{Time: mergedTime2}, + } + + mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", prevTag). + Return(prevRelease, &github.Response{}, nil) + + mockPR.On("List", mock.Anything, "test-owner", "test-repo", mock.MatchedBy(func(opts *github.PullRequestListOptions) bool { + return opts.State == "closed" && opts.Sort == "updated" && opts.Direction == "desc" + })).Return([]*github.PullRequest{pr1, pr2, pr3}, &github.Response{}, nil) + + prs, err := client.GetMergedPRsBetweenTags(context.Background(), prevTag, currTag) + + assert.NoError(t, err) + assert.Len(t, prs, 1) + assert.Equal(t, 1, prs[0].Number) + assert.Equal(t, "PR 1", prs[0].Title) + assert.Equal(t, "Description 1", prs[0].Description) + assert.Equal(t, "user1", prs[0].Author) + assert.Contains(t, prs[0].Labels, "bug") + mockRelease.AssertExpectations(t) + mockPR.AssertExpectations(t) + }) + + t.Run("should handle pagination", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + prevTag := "v1.0.0" + prevReleaseDate := github.Timestamp{Time: time.Now().Add(-24 * time.Hour)} + + prevRelease := &github.RepositoryRelease{ + CreatedAt: &prevReleaseDate, + } + + mergedTime := time.Now() + pr1 := &github.PullRequest{ + Number: github.Ptr(1), + Merged: github.Ptr(true), + MergedAt: &github.Timestamp{Time: mergedTime}, + } + + mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", prevTag). + Return(prevRelease, &github.Response{}, nil) + + mockPR.On("List", mock.Anything, "test-owner", "test-repo", mock.MatchedBy(func(opts *github.PullRequestListOptions) bool { + return opts.ListOptions.Page == 0 + })).Return([]*github.PullRequest{pr1}, &github.Response{NextPage: 2}, nil) + + mockPR.On("List", mock.Anything, "test-owner", "test-repo", mock.MatchedBy(func(opts *github.PullRequestListOptions) bool { + return opts.ListOptions.Page == 2 + })).Return([]*github.PullRequest{}, &github.Response{NextPage: 0}, nil) + + prs, err := client.GetMergedPRsBetweenTags(context.Background(), prevTag, "v1.1.0") + + assert.NoError(t, err) + assert.Len(t, prs, 1) }) - t.Run("should return error if file content is nil", func(t *testing.T) { + t.Run("should return error if GetReleaseByTag fails", func(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", "v1.0.0"). + Return((*github.RepositoryRelease)(nil), &github.Response{}, assert.AnError) + + _, err := client.GetMergedPRsBetweenTags(context.Background(), "v1.0.0", "v1.1.0") + + assert.Error(t, err) + }) + + t.Run("should return error if List fails", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", "v1.0.0"). + Return(&github.RepositoryRelease{CreatedAt: &github.Timestamp{}}, &github.Response{}, nil) + + mockPR.On("List", mock.Anything, "test-owner", "test-repo", mock.Anything). + Return([]*github.PullRequest{}, &github.Response{}, assert.AnError) + + _, err := client.GetMergedPRsBetweenTags(context.Background(), "v1.0.0", "v1.1.0") + + assert.Error(t, err) + }) +} + +func TestGitHubClient_GetContributorsBetweenTags(t *testing.T) { + t.Run("should get distinct contributors between tags", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + prevTag := "v1.0.0" + currTag := "v1.1.0" + + comparison := &github.CommitsComparison{ + Commits: []*github.RepositoryCommit{ + {Author: &github.User{Login: github.Ptr("user1")}}, + {Author: &github.User{Login: github.Ptr("user2")}}, + {Author: &github.User{Login: github.Ptr("user1")}}, + }, + } + mockRepo := &MockRepoService{} + client.repoService = mockRepo + + mockRepo.On("CompareCommits", mock.Anything, "test-owner", "test-repo", prevTag, currTag, mock.Anything). + Return(comparison, &github.Response{}, nil) + + contributors, err := client.GetContributorsBetweenTags(context.Background(), prevTag, currTag) + + assert.NoError(t, err) + assert.Len(t, contributors, 2) + assert.Contains(t, contributors, "user1") + assert.Contains(t, contributors, "user2") + mockRepo.AssertExpectations(t) + }) + + t.Run("should return error if CompareCommits fails", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClientWithRepo(mockPR, mockIssues, mockRepo, mockRelease) + client := newTestClient(mockPR, mockIssues, mockRelease) - mockRepo.On("GetContents", mock.Anything, "test-owner", "test-repo", "v1.0.0", mock.Anything). - Return((*github.RepositoryContent)(nil), []*github.RepositoryContent{}, &github.Response{}, nil) + mockRepo := &MockRepoService{} + client.repoService = mockRepo - content, err := client.GetFileAtTag(context.Background(), "v1.0.0", "go.mod") + mockRepo.On("CompareCommits", mock.Anything, "test-owner", "test-repo", "v1.0.0", "v1.1.0", mock.Anything). + Return((*github.CommitsComparison)(nil), &github.Response{}, assert.AnError) + + _, err := client.GetContributorsBetweenTags(context.Background(), "v1.0.0", "v1.1.0") assert.Error(t, err) - assert.Empty(t, content) - assert.Contains(t, err.Error(), "archivo no encontrado") + }) +} + +func TestGitHubClient_GetFileStatsBetweenTags(t *testing.T) { + t.Run("should get file stats successfully", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + prevTag := "v1.0.0" + currTag := "v1.1.0" + + comparison := &github.CommitsComparison{ + Files: []*github.CommitFile{ + {Filename: github.Ptr("file1.go"), Additions: github.Ptr(10), Deletions: github.Ptr(5)}, + {Filename: github.Ptr("file2.go"), Additions: github.Ptr(2), Deletions: github.Ptr(3)}, + {Filename: github.Ptr("file3.go"), Additions: github.Ptr(100), Deletions: github.Ptr(0)}, // Top file + }, + } + + mockRepo := &MockRepoService{} + client.repoService = mockRepo + + mockRepo.On("CompareCommits", mock.Anything, "test-owner", "test-repo", prevTag, currTag, mock.Anything). + Return(comparison, &github.Response{}, nil) + + stats, err := client.GetFileStatsBetweenTags(context.Background(), prevTag, currTag) + + assert.NoError(t, err) + assert.Equal(t, 3, stats.FilesChanged) + assert.Equal(t, 112, stats.Insertions) + assert.Equal(t, 8, stats.Deletions) + + assert.Len(t, stats.TopFiles, 3) + assert.Equal(t, "file3.go", stats.TopFiles[0].Path) + assert.Equal(t, "file1.go", stats.TopFiles[1].Path) + assert.Equal(t, "file2.go", stats.TopFiles[2].Path) + mockRepo.AssertExpectations(t) }) + + t.Run("should return error if CompareCommits fails", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + client := newTestClient(mockPR, mockIssues, mockRelease) + + mockRepo := &MockRepoService{} + client.repoService = mockRepo + + mockRepo.On("CompareCommits", mock.Anything, "test-owner", "test-repo", "v1.0.0", "v1.1.0", mock.Anything). + Return((*github.CommitsComparison)(nil), &github.Response{}, assert.AnError) + + _, err := client.GetFileStatsBetweenTags(context.Background(), "v1.0.0", "v1.1.0") + + assert.Error(t, err) + }) } diff --git a/internal/infrastructure/vcs/github/mocks.go b/internal/infrastructure/vcs/github/mocks.go index 098c259..95868d4 100644 --- a/internal/infrastructure/vcs/github/mocks.go +++ b/internal/infrastructure/vcs/github/mocks.go @@ -63,6 +63,11 @@ func (m *MockIssuesService) ListByRepo(ctx context.Context, owner, repo string, return args.Get(0).([]*github.Issue), args.Get(1).(*github.Response), args.Error(2) } +func (m *MockIssuesService) Get(ctx context.Context, owner, repo string, number int) (*github.Issue, *github.Response, error) { + args := m.Called(ctx, owner, repo, number) + return args.Get(0).(*github.Issue), args.Get(1).(*github.Response), args.Error(2) +} + type MockRepoService struct { mock.Mock } diff --git a/internal/services/commit_service.go b/internal/services/commit_service.go index aca1faa..19f45c5 100644 --- a/internal/services/commit_service.go +++ b/internal/services/commit_service.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "regexp" + "strconv" "github.com/Tomas-vilte/MateCommit/internal/config" "github.com/Tomas-vilte/MateCommit/internal/domain/models" "github.com/Tomas-vilte/MateCommit/internal/domain/ports" "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/Tomas-vilte/MateCommit/internal/infrastructure/vcs/github" ) var _ ports.CommitService = (*CommitService)(nil) @@ -17,36 +19,60 @@ type CommitService struct { git ports.GitService ai ports.CommitSummarizer ticketManager ports.TickerManager + vcsClient ports.VCSClient config *config.Config trans *i18n.Translations } -func NewCommitService(git ports.GitService, ai ports.CommitSummarizer, ticketManager ports.TickerManager, cfg *config.Config, trans *i18n.Translations) *CommitService { +func NewCommitService( + git ports.GitService, + ai ports.CommitSummarizer, + ticketManager ports.TickerManager, + vcsClient ports.VCSClient, + cfg *config.Config, + trans *i18n.Translations) *CommitService { return &CommitService{ git: git, ai: ai, ticketManager: ticketManager, + vcsClient: vcsClient, config: cfg, trans: trans, } } func (s *CommitService) GenerateSuggestions(ctx context.Context, count int) ([]models.CommitSuggestion, error) { + commitInfo, err := s.buildCommitInfo(ctx, 0) + if err != nil { + return nil, err + } + return s.ai.GenerateSuggestions(ctx, commitInfo, count) +} + +func (s *CommitService) GenerateSuggestionsWithIssue(ctx context.Context, count int, issueNumber int) ([]models.CommitSuggestion, error) { + commitInfo, err := s.buildCommitInfo(ctx, issueNumber) + if err != nil { + return nil, err + } + return s.ai.GenerateSuggestions(ctx, commitInfo, count) +} + +func (s *CommitService) buildCommitInfo(ctx context.Context, issueNumber int) (models.CommitInfo, error) { var commitInfo models.CommitInfo if s.ai == nil { - msg := s.trans.GetMessage("ai_missing_for_suggest", 0, nil) - return nil, fmt.Errorf("%s", msg) + msg := s.trans.GetMessage("ai_missing.for_suggest", 0, nil) + return commitInfo, fmt.Errorf("%s", msg) } changes, err := s.git.GetChangedFiles(ctx) if err != nil { - return nil, err + return commitInfo, err } if len(changes) == 0 { msg := s.trans.GetMessage("commit_service.undetected_changes", 0, nil) - return nil, fmt.Errorf("%s", msg) + return commitInfo, fmt.Errorf("%s", msg) } diff, err := s.git.GetDiff(ctx) @@ -54,12 +80,12 @@ func (s *CommitService) GenerateSuggestions(ctx context.Context, count int) ([]m msg := s.trans.GetMessage("commit_service.error_getting_diff", 0, map[string]interface{}{ "Error": err, }) - return nil, fmt.Errorf("%s", msg) + return commitInfo, fmt.Errorf("%s", msg) } if diff == "" { msg := s.trans.GetMessage("commit_service.no_differences_detected", 0, nil) - return nil, fmt.Errorf("%s", msg) + return commitInfo, fmt.Errorf("%s", msg) } files := make([]string, 0) @@ -78,7 +104,7 @@ func (s *CommitService) GenerateSuggestions(ctx context.Context, count int) ([]m msg := s.trans.GetMessage("commit_service.error_get_id_ticket", 0, map[string]interface{}{ "Error": err, }) - return nil, fmt.Errorf("%s", msg) + return commitInfo, fmt.Errorf("%s", msg) } ticketInfo, err := s.ticketManager.GetTicketInfo(ticketID) @@ -86,13 +112,163 @@ func (s *CommitService) GenerateSuggestions(ctx context.Context, count int) ([]m msg := s.trans.GetMessage("commit_service.error_get_ticket_info", 0, map[string]interface{}{ "Error": err, }) - return nil, fmt.Errorf("%s", msg) + return commitInfo, fmt.Errorf("%s", msg) } commitInfo.TicketInfo = ticketInfo } - return s.ai.GenerateSuggestions(ctx, commitInfo, count) + detectedIssue := issueNumber + if detectedIssue == 0 { + detectedIssue = s.detectIssueNumber(ctx) + } + + if detectedIssue > 0 { + vcsClient, err := s.getOrCreateVCSClient(ctx) + if err != nil { + msg := s.trans.GetMessage("issue_vcs_init_error", 0, map[string]interface{}{ + "Error": err.Error(), + }) + fmt.Println(msg) + } else { + issueInfo, err := vcsClient.GetIssue(ctx, detectedIssue) + if err != nil { + msg := s.trans.GetMessage("issue_fetch_error", 0, map[string]interface{}{ + "Number": detectedIssue, + "Error": err.Error(), + }) + fmt.Println(msg) + } else { + var msg string + if issueNumber == 0 { + msg = s.trans.GetMessage("issue_detected_auto", 0, map[string]interface{}{ + "Number": detectedIssue, + "Title": issueInfo.Title, + }) + } else { + msg = s.trans.GetMessage("issue_using_manual", 0, map[string]interface{}{ + "Number": detectedIssue, + "Title": issueInfo.Title, + }) + } + fmt.Println(msg) + commitInfo.IssueInfo = issueInfo + } + } + } + + return commitInfo, nil +} + +func (s *CommitService) getOrCreateVCSClient(ctx context.Context) (ports.VCSClient, error) { + if s.vcsClient != nil { + return s.vcsClient, nil + } + + owner, repo, provider, err := s.git.GetRepoInfo(ctx) + if err != nil { + return nil, fmt.Errorf("error al obtener información del repositorio: %w", err) + } + + vcsConfig, exists := s.config.VCSConfigs[provider] + if !exists { + if s.config.ActiveVCSProvider != "" { + vcsConfig, exists = s.config.VCSConfigs[s.config.ActiveVCSProvider] + if !exists { + return nil, fmt.Errorf("configuración para el proveedor de VCS '%s' no encontrada", s.config.ActiveVCSProvider) + } + provider = s.config.ActiveVCSProvider + } else { + return nil, fmt.Errorf("proveedor de VCS '%s' detectado automáticamente pero no configurado", provider) + } + } + + switch provider { + case "github": + return github.NewGitHubClient(owner, repo, vcsConfig.Token, s.trans), nil + default: + return nil, fmt.Errorf("proveedor de VCS no compatible: %s", provider) + } +} + +// detectIssueNumber intenta detectar automáticamente el número de issue +// Prioridad: 1) Branch name, 2) Commits recientes +func (s *CommitService) detectIssueNumber(ctx context.Context) int { + if issueNum := s.detectIssueFromBranch(ctx); issueNum > 0 { + return issueNum + } + + if issueNum := s.detectIssueFromCommits(ctx); issueNum > 0 { + return issueNum + } + + return 0 +} + +// detectIssueFromBranch detecta issue number desde el nombre de la rama +// Patrones soportados: 123-desc, feature/123-desc, #123, issue-123, issue/123 +func (s *CommitService) detectIssueFromBranch(ctx context.Context) int { + branchName, err := s.git.GetCurrentBranch(ctx) + if err != nil { + return 0 + } + + patterns := []string{ + `#(\d+)`, // #123 + `issue[/-](\d+)`, // issue-123, issue/123 + `^(\d+)-`, // 123-feature + `/(\d+)-`, // feature/123-desc + `-(\d+)-`, // bugfix-123-description + } + + for _, pattern := range patterns { + re := regexp.MustCompile(pattern) + if match := re.FindStringSubmatch(branchName); len(match) > 1 { + if num, err := strconv.Atoi(match[1]); err == nil { + return num + } + } + } + + return 0 +} + +// detectIssueFromCommits detecta issue number desde commits recientes +// Busca keywords de GitHub: fixes, closes, resolves seguido de #123 +func (s *CommitService) detectIssueFromCommits(ctx context.Context) int { + commitMessages, err := s.git.GetRecentCommitMessages(ctx, 5) + if err != nil { + return 0 + } + + // https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue + keywords := []string{ + "fix", "fixes", "fixed", + "close", "closes", "closed", + "resolve", "resolves", "resolved", + } + + for _, keyword := range keywords { + pattern := fmt.Sprintf(`(?i)\b%s\s+#(\d+)\b`, keyword) + re := regexp.MustCompile(pattern) + if match := re.FindStringSubmatch(commitMessages); len(match) > 1 { + if num, err := strconv.Atoi(match[1]); err == nil { + return num + } + } + } + + simplePattern := regexp.MustCompile(`#(\d+)`) + matches := simplePattern.FindAllStringSubmatch(commitMessages, -1) + for _, match := range matches { + if len(match) > 1 { + if num, err := strconv.Atoi(match[1]); err == nil { + return num + } + } + } + + return 0 } func (s *CommitService) getTicketIDFromBranch(ctx context.Context) (string, error) { diff --git a/internal/services/commit_service_test.go b/internal/services/commit_service_test.go index 1aa1036..fc47a12 100644 --- a/internal/services/commit_service_test.go +++ b/internal/services/commit_service_test.go @@ -13,577 +13,327 @@ import ( "github.com/stretchr/testify/require" ) -type ( - MockGitService struct { - mock.Mock - } - MockAIProvider struct { - mock.Mock - } - - MockJiraService struct { - mock.Mock - } -) - -func (m *MockJiraService) GetTicketInfo(ticketID string) (*models.TicketInfo, error) { - args := m.Called(ticketID) - return args.Get(0).(*models.TicketInfo), args.Error(1) -} - -func (m *MockGitService) HasStagedChanges(ctx context.Context) bool { - args := m.Called(ctx) - return args.Bool(0) -} - -func (m *MockGitService) AddFileToStaging(ctx context.Context, file string) error { - args := m.Called(ctx, file) - return args.Error(0) -} - -func (m *MockGitService) GetChangedFiles(ctx context.Context) ([]models.GitChange, error) { - args := m.Called(ctx) - return args.Get(0).([]models.GitChange), args.Error(1) -} - -func (m *MockGitService) GetDiff(ctx context.Context) (string, error) { - args := m.Called(ctx) - return args.String(0), args.Error(1) -} - -func (m *MockGitService) StageAllChanges(ctx context.Context) error { - args := m.Called(ctx) - return args.Error(0) -} - -func (m *MockGitService) CreateCommit(ctx context.Context, message string) error { - args := m.Called(ctx, message) - return args.Error(0) -} - -func (m *MockGitService) GetCurrentBranch(ctx context.Context) (string, error) { - args := m.Called(ctx) - return args.String(0), args.Error(1) -} - -func (m *MockGitService) GetRepoInfo(ctx context.Context) (string, string, string, error) { - args := m.Called(ctx) - return args.String(0), args.String(1), args.String(2), args.Error(3) -} - -func (m *MockGitService) GetLastTag(ctx context.Context) (string, error) { - args := m.Called(ctx) - return args.String(0), args.Error(1) -} - -func (m *MockGitService) GetCommitCount(ctx context.Context) (int, error) { - args := m.Called(ctx) - return args.Int(0), args.Error(1) -} - -func (m *MockGitService) GetCommitsSinceTag(ctx context.Context, tag string) ([]models.Commit, error) { - args := m.Called(ctx, tag) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]models.Commit), args.Error(1) -} - -func (m *MockGitService) CreateTag(ctx context.Context, version, message string) error { - args := m.Called(ctx, version, message) - return args.Error(0) -} - -func (m *MockGitService) PushTag(ctx context.Context, version string) error { - args := m.Called(ctx, version) - return args.Error(0) -} - -func (m *MockAIProvider) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) { - args := m.Called(ctx, info, count) - return args.Get(0).([]models.CommitSuggestion), args.Error(1) +func setupTest(t *testing.T) (*MockGitService, *MockAIProvider, *MockJiraService, *MockVCSClient, *config.Config, *i18n.Translations) { + mockGit := new(MockGitService) + mockAI := new(MockAIProvider) + mockJiraService := new(MockJiraService) + mockVCS := new(MockVCSClient) + cfgApp := &config.Config{UseTicket: true} + trans, err := i18n.NewTranslations("es", "../i18n/locales") + require.NoError(t, err) + return mockGit, mockAI, mockJiraService, mockVCS, cfgApp, trans } func TestCommitService_GenerateSuggestions(t *testing.T) { t.Run("successful generation with ticket info", func(t *testing.T) { - // arrange - mockGit := new(MockGitService) - mockAI := new(MockAIProvider) - mockJiraService := new(MockJiraService) - cfgApp := &config.Config{UseTicket: true} - trans, err := i18n.NewTranslations("es", "../i18n/locales") - require.NoError(t, err) + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) mockGit.On("GetCurrentBranch", mock.Anything).Return("feature/PROJ-1234-user-authentication", nil) ticketInfo := &models.TicketInfo{ TicketID: "PROJ-1234", TicketTitle: "Implement user authentication", - TitleDesc: "As a user, I want to log in to the system so that I can access my account.", - Criteria: []string{"User can log in with valid credentials", "User cannot log in with invalid credentials"}, + TitleDesc: "As a user, I want to log in...", + Criteria: []string{"User can log in"}, } - mockJiraService.On("GetTicketInfo", "PROJ-1234").Return(ticketInfo, nil) + mockJira.On("GetTicketInfo", "PROJ-1234").Return(ticketInfo, nil) - changes := []models.GitChange{{ - Path: "file1.go", - Status: "M", - }} + changes := []models.GitChange{{Path: "file1.go", Status: "M"}} mockGit.On("GetChangedFiles", mock.Anything).Return(changes, nil) mockGit.On("GetDiff", mock.Anything).Return("some diff", nil) + mockVCS.On("GetIssue", mock.Anything, 1234).Return(&models.Issue{Number: 1234, Title: "Issue Title"}, nil) + + cfg.VCSConfigs = map[string]config.VCSConfig{ + "github": {Token: "token"}, + } expectedResponse := []models.CommitSuggestion{{ CommitTitle: "feat: implement user authentication", Files: []string{"file1.go"}, Explanation: "some explanation", }} - expectedInfo := models.CommitInfo{ - Files: []string{"file1.go"}, - Diff: "some diff", - TicketInfo: &models.TicketInfo{ - TicketID: "PROJ-1234", - TicketTitle: "Implement user authentication", - TitleDesc: "As a user, I want to log in to the system so that I can access my account.", - Criteria: []string{"User can log in with valid credentials", "User cannot log in with invalid credentials"}, - }, - } - mockAI.On("GenerateSuggestions", mock.Anything, expectedInfo, 3).Return(expectedResponse, nil) - service := NewCommitService(mockGit, mockAI, mockJiraService, cfgApp, trans) + mockAI.On("GenerateSuggestions", mock.Anything, mock.MatchedBy(func(info models.CommitInfo) bool { + return info.TicketInfo.TicketID == "PROJ-1234" && info.Diff == "some diff" + }), 3).Return(expectedResponse, nil) - // act + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) suggestions, err := service.GenerateSuggestions(context.Background(), 3) - // assert assert.NoError(t, err) assert.Equal(t, expectedResponse, suggestions) - mockGit.AssertExpectations(t) + mockJira.AssertExpectations(t) mockAI.AssertExpectations(t) - mockJiraService.AssertExpectations(t) }) t.Run("no changes detected", func(t *testing.T) { - // arrange - mockGit := new(MockGitService) - mockAI := new(MockAIProvider) - mockJiraService := new(MockJiraService) - cfgApp := &config.Config{UseTicket: true} - trans, err := i18n.NewTranslations("es", "../i18n/locales") - require.NoError(t, err) + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{}, nil) - service := NewCommitService(mockGit, mockAI, mockJiraService, cfgApp, trans) - - // act + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) suggestions, err := service.GenerateSuggestions(context.Background(), 3) - // assert assert.Error(t, err) assert.Nil(t, suggestions) - assert.EqualError(t, err, "No hay cambios detectados") - - mockGit.AssertExpectations(t) + assert.Contains(t, err.Error(), "No hay cambios detectados") }) t.Run("error getting diff", func(t *testing.T) { - // arrange - mockGit := new(MockGitService) - mockAI := new(MockAIProvider) - mockJiraService := new(MockJiraService) - cfgApp := &config.Config{UseTicket: true} - trans, err := i18n.NewTranslations("es", "../i18n/locales") - require.NoError(t, err) - - changes := []models.GitChange{ - { - Path: "file1.go", - Status: "M", - }, - } + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) + + changes := []models.GitChange{{Path: "file1.go", Status: "M"}} mockGit.On("GetChangedFiles", mock.Anything).Return(changes, nil) mockGit.On("GetDiff", mock.Anything).Return("", errors.New("git error")) - service := NewCommitService(mockGit, mockAI, mockJiraService, cfgApp, trans) - - // act + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) suggestions, err := service.GenerateSuggestions(context.Background(), 3) - // assert assert.Error(t, err) assert.Nil(t, suggestions) - assert.EqualError(t, err, "Error al obtener los cambios: git error") - - mockGit.AssertExpectations(t) + assert.Contains(t, err.Error(), "Error al obtener los cambios") }) - t.Run("branch without ticket ID", func(t *testing.T) { - // arrange - mockGit := new(MockGitService) - mockAI := new(MockAIProvider) - mockJiraService := new(MockJiraService) - cfgApp := &config.Config{UseTicket: true} - trans, err := i18n.NewTranslations("es", "../i18n/locales") - require.NoError(t, err) - - mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "file1.go", Status: "M"}}, nil) - mockGit.On("GetDiff", mock.Anything).Return("some diff", nil) - mockGit.On("GetCurrentBranch", mock.Anything).Return("main", nil) + t.Run("no differences string (empty diff)", func(t *testing.T) { + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) - service := NewCommitService(mockGit, mockAI, mockJiraService, cfgApp, trans) + changes := []models.GitChange{{Path: "file1.go", Status: "M"}} + mockGit.On("GetChangedFiles", mock.Anything).Return(changes, nil) + mockGit.On("GetDiff", mock.Anything).Return("", nil) - // act + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) suggestions, err := service.GenerateSuggestions(context.Background(), 3) - // assert assert.Error(t, err) assert.Nil(t, suggestions) - assert.EqualError(t, err, "Error al obtener el ID del ticket: No se encontro un ID de ticket en el nombre de la branch") - - mockGit.AssertExpectations(t) + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Contains(t, err.Error(), "No se detectaron diferencias en los archivos") }) -} - -func TestGitService_HasStagedChanges(t *testing.T) { - t.Run("has staged changes", func(t *testing.T) { - // arrange - mockGit := new(MockGitService) - mockGit.On("HasStagedChanges", mock.Anything).Return(true) - - // act - result := mockGit.HasStagedChanges(context.Background()) - // assert - assert.True(t, result) - }) + t.Run("error getting branch name", func(t *testing.T) { + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) - t.Run("no staged changes", func(t *testing.T) { - // arrange - mockGit := new(MockGitService) - mockGit.On("HasStagedChanges", mock.Anything).Return(false) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f.go"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("diff", nil) + mockGit.On("GetCurrentBranch", mock.Anything).Return("", errors.New("branch error")) - // act - result := mockGit.HasStagedChanges(context.Background()) + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) + suggestions, err := service.GenerateSuggestions(context.Background(), 3) - // assert - assert.False(t, result) + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Contains(t, err.Error(), "Error al obtener el nombre de la branch") }) -} -func TestCommitService_GenerateSuggestions_DifferentBranchNames(t *testing.T) { - t.Run("branch with feature prefix and ticket ID", func(t *testing.T) { - // Arrange - mockGit := new(MockGitService) - mockAI := new(MockAIProvider) - mockJiraService := new(MockJiraService) - cfgApp := &config.Config{UseTicket: true} - trans, err := i18n.NewTranslations("es", "../i18n/locales") - require.NoError(t, err) + t.Run("branch without ticket ID", func(t *testing.T) { + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) - mockGit.On("GetCurrentBranch", mock.Anything).Return("feature/PROJ-1234-user-authentication", nil) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f.go"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("diff", nil) + mockGit.On("GetCurrentBranch", mock.Anything).Return("main", nil) - ticketInfo := &models.TicketInfo{ - TicketID: "PROJ-1234", - TicketTitle: "Implement user authentication", - TitleDesc: "As a user, I want to log in to the system so that I can access my account.", - Criteria: []string{"User can log in with valid credentials", "User cannot log in with invalid credentials"}, - } - mockJiraService.On("GetTicketInfo", "PROJ-1234").Return(ticketInfo, nil) + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) + suggestions, err := service.GenerateSuggestions(context.Background(), 3) - changes := []models.GitChange{{ - Path: "file1.go", - Status: "M", - }} - mockGit.On("GetChangedFiles", mock.Anything).Return(changes, nil) - mockGit.On("GetDiff", mock.Anything).Return("some diff", nil) + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Contains(t, err.Error(), "No se encontro un ID de ticket en el nombre de la branch") + }) - expectedResponse := []models.CommitSuggestion{{ - CommitTitle: "feat: implement user authentication", - Files: []string{"file1.go"}, - Explanation: "some explanation", - }} - expectedInfo := models.CommitInfo{ - Files: []string{"file1.go"}, - Diff: "some diff", - TicketInfo: &models.TicketInfo{ - TicketID: "PROJ-1234", - TicketTitle: "Implement user authentication", - TitleDesc: "As a user, I want to log in to the system so that I can access my account.", - Criteria: []string{"User can log in with valid credentials", "User cannot log in with invalid credentials"}, - }, - } - mockAI.On("GenerateSuggestions", mock.Anything, expectedInfo, 3).Return(expectedResponse, nil) + t.Run("error getting ticket info", func(t *testing.T) { + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) - service := NewCommitService(mockGit, mockAI, mockJiraService, cfgApp, trans) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f.go"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("diff", nil) + mockGit.On("GetCurrentBranch", mock.Anything).Return("feat/PROJ-123", nil) + mockJira.On("GetTicketInfo", "PROJ-123").Return(&models.TicketInfo{}, errors.New("jira error")) - // Act + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) suggestions, err := service.GenerateSuggestions(context.Background(), 3) - // Assert - assert.NoError(t, err) - assert.Equal(t, expectedResponse, suggestions) - - mockGit.AssertExpectations(t) - mockAI.AssertExpectations(t) - mockJiraService.AssertExpectations(t) + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Contains(t, err.Error(), "Error al obtener informacion del ticket") }) - t.Run("branch with bugfix prefix and ticket ID", func(t *testing.T) { - // Arrange - mockGit := new(MockGitService) - mockAI := new(MockAIProvider) - mockJiraService := new(MockJiraService) - cfgApp := &config.Config{UseTicket: true} - trans, err := i18n.NewTranslations("es", "../i18n/locales") - require.NoError(t, err) + t.Run("AI service nil", func(t *testing.T) { + mockGit, _, mockJira, mockVCS, cfg, trans := setupTest(t) + service := NewCommitService(mockGit, nil, mockJira, mockVCS, cfg, trans) + suggestions, err := service.GenerateSuggestions(context.Background(), 3) + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Error(t, err) + assert.Nil(t, suggestions) + assert.Contains(t, err.Error(), "La IA no está configurada") + }) - mockGit.On("GetCurrentBranch", mock.Anything).Return("bugfix/PROJ-5678-fix-login", nil) + t.Run("Detect Issue from Commits - Error", func(t *testing.T) { + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) + cfg.UseTicket = false - ticketInfo := &models.TicketInfo{ - TicketID: "PROJ-5678", - TicketTitle: "Fix login issue", - TitleDesc: "As a user, I want to log in without errors so that I can access my account.", - Criteria: []string{"User can log in without errors", "Error messages are clear"}, - } - mockJiraService.On("GetTicketInfo", "PROJ-5678").Return(ticketInfo, nil) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f.go"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("diff", nil) + mockGit.On("GetCurrentBranch", mock.Anything).Return("main", nil) + mockGit.On("GetRecentCommitMessages", mock.Anything, 5).Return("", errors.New("git log error")) - changes := []models.GitChange{{ - Path: "file2.go", - Status: "M", - }} - mockGit.On("GetChangedFiles", mock.Anything).Return(changes, nil) - mockGit.On("GetDiff", mock.Anything).Return("some diff", nil) + mockAI.On("GenerateSuggestions", mock.Anything, mock.MatchedBy(func(info models.CommitInfo) bool { + return info.IssueInfo == nil + }), 3).Return([]models.CommitSuggestion{}, nil) - expectedResponse := []models.CommitSuggestion{{ - CommitTitle: "fix: resolve login issue", - Files: []string{"file2.go"}, - Explanation: "some explanation", - }} - expectedInfo := models.CommitInfo{ - Files: []string{"file2.go"}, - Diff: "some diff", - TicketInfo: &models.TicketInfo{ - TicketID: "PROJ-5678", - TicketTitle: "Fix login issue", - TitleDesc: "As a user, I want to log in without errors so that I can access my account.", - Criteria: []string{"User can log in without errors", "Error messages are clear"}, - }, - } - mockAI.On("GenerateSuggestions", mock.Anything, expectedInfo, 3).Return(expectedResponse, nil) + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) + _, err := service.GenerateSuggestions(context.Background(), 3) + assert.NoError(t, err) + }) - service := NewCommitService(mockGit, mockAI, mockJiraService, cfgApp, trans) + t.Run("Detect Issue from Commits - Simple Pattern", func(t *testing.T) { + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) + cfg.UseTicket = false - // Act - suggestions, err := service.GenerateSuggestions(context.Background(), 3) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f.go"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("diff", nil) + mockGit.On("GetCurrentBranch", mock.Anything).Return("main", nil) + mockGit.On("GetRecentCommitMessages", mock.Anything, 5).Return("Just a commit #999", nil) - // Assert - assert.NoError(t, err) - assert.Equal(t, expectedResponse, suggestions) + mockGit.On("GetRepoInfo", mock.Anything).Return("owner", "repo", "github", nil) + cfg.VCSConfigs = map[string]config.VCSConfig{"github": {Token: "token"}} - mockGit.AssertExpectations(t) - mockAI.AssertExpectations(t) - mockJiraService.AssertExpectations(t) - }) - - t.Run("branch with hotfix prefix and ticket ID", func(t *testing.T) { - // Arrange - mockGit := new(MockGitService) - mockAI := new(MockAIProvider) - mockJiraService := new(MockJiraService) - cfgApp := &config.Config{UseTicket: true} - trans, err := i18n.NewTranslations("es", "../i18n/locales") - require.NoError(t, err) + mockVCS.On("GetIssue", mock.Anything, 999).Return(&models.Issue{Number: 999}, nil) - mockGit.On("GetCurrentBranch", mock.Anything).Return("hotfix/PROJ-9999-critical-bug", nil) + mockAI.On("GenerateSuggestions", mock.Anything, mock.MatchedBy(func(info models.CommitInfo) bool { + return info.IssueInfo != nil && info.IssueInfo.Number == 999 + }), 3).Return([]models.CommitSuggestion{}, nil) - ticketInfo := &models.TicketInfo{ - TicketID: "PROJ-9999", - TicketTitle: "Fix critical bug", - TitleDesc: "As a user, I want the system to be stable so that I can use it without issues.", - Criteria: []string{"System should not crash", "Critical functionality should work"}, - } - mockJiraService.On("GetTicketInfo", "PROJ-9999").Return(ticketInfo, nil) + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) + _, err := service.GenerateSuggestions(context.Background(), 3) + assert.NoError(t, err) + mockVCS.AssertCalled(t, "GetIssue", mock.Anything, 999) + }) - changes := []models.GitChange{{ - Path: "file3.go", - Status: "M", - }} - mockGit.On("GetChangedFiles", mock.Anything).Return(changes, nil) - mockGit.On("GetDiff", mock.Anything).Return("some diff", nil) + t.Run("GetOrCreateVCSClient - Error getting repo info", func(t *testing.T) { + mockGit, mockAI, mockJira, _, cfg, trans := setupTest(t) + cfg.UseTicket = false - expectedResponse := []models.CommitSuggestion{{ - CommitTitle: "fix: resolve critical bug", - Files: []string{"file3.go"}, - Explanation: "some explanation", - }} - expectedInfo := models.CommitInfo{ - Files: []string{"file3.go"}, - Diff: "some diff", - TicketInfo: &models.TicketInfo{ - TicketID: "PROJ-9999", - TicketTitle: "Fix critical bug", - TitleDesc: "As a user, I want the system to be stable so that I can use it without issues.", - Criteria: []string{"System should not crash", "Critical functionality should work"}, - }, - } - mockAI.On("GenerateSuggestions", mock.Anything, expectedInfo, 3).Return(expectedResponse, nil) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f.go"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("diff", nil) + mockGit.On("GetCurrentBranch", mock.Anything).Return("issue/123", nil) - service := NewCommitService(mockGit, mockAI, mockJiraService, cfgApp, trans) + mockGit.On("GetRepoInfo", mock.Anything).Return("", "", "", errors.New("repo error")) - // Act - suggestions, err := service.GenerateSuggestions(context.Background(), 3) + mockAI.On("GenerateSuggestions", mock.Anything, mock.MatchedBy(func(info models.CommitInfo) bool { + return info.IssueInfo == nil + }), 3).Return([]models.CommitSuggestion{}, nil) - // Assert + service := NewCommitService(mockGit, mockAI, mockJira, nil, cfg, trans) + _, err := service.GenerateSuggestions(context.Background(), 3) assert.NoError(t, err) - assert.Equal(t, expectedResponse, suggestions) - - mockGit.AssertExpectations(t) - mockAI.AssertExpectations(t) - mockJiraService.AssertExpectations(t) }) - t.Run("branch with release prefix and ticket ID", func(t *testing.T) { - // Arrange - mockGit := new(MockGitService) - mockAI := new(MockAIProvider) - mockJiraService := new(MockJiraService) - cfgApp := &config.Config{UseTicket: true} - trans, err := i18n.NewTranslations("es", "../i18n/locales") - require.NoError(t, err) + t.Run("GetOrCreateVCSClient - Provider config not found", func(t *testing.T) { + mockGit, mockAI, mockJira, _, cfg, trans := setupTest(t) + cfg.UseTicket = false - mockGit.On("GetCurrentBranch", mock.Anything).Return("release/PROJ-1000-final-release", nil) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f.go"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("diff", nil) + mockGit.On("GetCurrentBranch", mock.Anything).Return("issue/123", nil) - ticketInfo := &models.TicketInfo{ - TicketID: "PROJ-1000", - TicketTitle: "Final release", - TitleDesc: "As a user, I want the final version of the system so that I can use all features.", - Criteria: []string{"All features should work", "No known bugs"}, - } - mockJiraService.On("GetTicketInfo", "PROJ-1000").Return(ticketInfo, nil) + mockGit.On("GetRepoInfo", mock.Anything).Return("owner", "repo", "gitlab", nil) + cfg.VCSConfigs = map[string]config.VCSConfig{} - changes := []models.GitChange{{ - Path: "file4.go", - Status: "M", - }} - mockGit.On("GetChangedFiles", mock.Anything).Return(changes, nil) - mockGit.On("GetDiff", mock.Anything).Return("some diff", nil) + mockAI.On("GenerateSuggestions", mock.Anything, mock.MatchedBy(func(info models.CommitInfo) bool { + return info.IssueInfo == nil + }), 3).Return([]models.CommitSuggestion{}, nil) - expectedResponse := []models.CommitSuggestion{{ - CommitTitle: "chore: prepare for final release", - Files: []string{"file4.go"}, - Explanation: "some explanation", - }} - expectedInfo := models.CommitInfo{ - Files: []string{"file4.go"}, - Diff: "some diff", - TicketInfo: &models.TicketInfo{ - TicketID: "PROJ-1000", - TicketTitle: "Final release", - TitleDesc: "As a user, I want the final version of the system so that I can use all features.", - Criteria: []string{"All features should work", "No known bugs"}, - }, - } - mockAI.On("GenerateSuggestions", mock.Anything, expectedInfo, 3).Return(expectedResponse, nil) + service := NewCommitService(mockGit, mockAI, mockJira, nil, cfg, trans) + _, err := service.GenerateSuggestions(context.Background(), 3) + assert.NoError(t, err) + }) - service := NewCommitService(mockGit, mockAI, mockJiraService, cfgApp, trans) + t.Run("GetOrCreateVCSClient - Unsupported provider", func(t *testing.T) { + mockGit, mockAI, mockJira, _, cfg, trans := setupTest(t) + cfg.UseTicket = false - // Act - suggestions, err := service.GenerateSuggestions(context.Background(), 3) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f.go"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("diff", nil) + mockGit.On("GetCurrentBranch", mock.Anything).Return("issue/123", nil) - // Assert - assert.NoError(t, err) - assert.Equal(t, expectedResponse, suggestions) + mockGit.On("GetRepoInfo", mock.Anything).Return("owner", "repo", "bitbucket", nil) + cfg.VCSConfigs = map[string]config.VCSConfig{"bitbucket": {Token: "token"}} - mockGit.AssertExpectations(t) - mockAI.AssertExpectations(t) - mockJiraService.AssertExpectations(t) + mockAI.On("GenerateSuggestions", mock.Anything, mock.MatchedBy(func(info models.CommitInfo) bool { + return info.IssueInfo == nil + }), 3).Return([]models.CommitSuggestion{}, nil) + + service := NewCommitService(mockGit, mockAI, mockJira, nil, cfg, trans) + _, err := service.GenerateSuggestions(context.Background(), 3) + assert.NoError(t, err) }) - t.Run("branch without ticket ID", func(t *testing.T) { - // Arrange - mockGit := new(MockGitService) - mockAI := new(MockAIProvider) - mockJiraService := new(MockJiraService) - cfgApp := &config.Config{UseTicket: true} - trans, err := i18n.NewTranslations("es", "../i18n/locales") - require.NoError(t, err) +} - mockGit.On("GetCurrentBranch", mock.Anything).Return("main", nil) +func TestCommitService_GenerateSuggestionsWithIssue(t *testing.T) { + t.Run("explicit issue number", func(t *testing.T) { + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) + cfg.UseTicket = false - changes := []models.GitChange{{ - Path: "file5.go", - Status: "M", - }} - mockGit.On("GetChangedFiles", mock.Anything).Return(changes, nil) - mockGit.On("GetDiff", mock.Anything).Return("some diff", nil) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f.go"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("diff", nil) - service := NewCommitService(mockGit, mockAI, mockJiraService, cfgApp, trans) + mockVCS.On("GetIssue", mock.Anything, 100).Return(&models.Issue{Number: 100, Title: "Explicit Issue"}, nil) - // Act - suggestions, err := service.GenerateSuggestions(context.Background(), 3) + mockAI.On("GenerateSuggestions", mock.Anything, mock.MatchedBy(func(info models.CommitInfo) bool { + return info.IssueInfo != nil && info.IssueInfo.Number == 100 + }), 3).Return([]models.CommitSuggestion{}, nil) - // Assert - assert.Error(t, err) - assert.Nil(t, suggestions) - assert.EqualError(t, err, "Error al obtener el ID del ticket: No se encontro un ID de ticket en el nombre de la branch") + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) + suggestions, err := service.GenerateSuggestionsWithIssue(context.Background(), 3, 100) - mockGit.AssertExpectations(t) + assert.NoError(t, err) + assert.NotNil(t, suggestions) }) - t.Run("branch with custom prefix and ticket ID", func(t *testing.T) { - // Arrange - mockGit := new(MockGitService) - mockAI := new(MockAIProvider) - mockJiraService := new(MockJiraService) - cfgApp := &config.Config{UseTicket: true} - trans, err := i18n.NewTranslations("es", "../i18n/locales") - require.NoError(t, err) + t.Run("issue fetch error", func(t *testing.T) { + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) + cfg.UseTicket = false - mockGit.On("GetCurrentBranch", mock.Anything).Return("custom/PROJ-2000-custom-feature", nil) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f.go"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("diff", nil) - ticketInfo := &models.TicketInfo{ - TicketID: "PROJ-2000", - TicketTitle: "Custom feature", - TitleDesc: "As a user, I want a custom feature so that I can do something specific.", - Criteria: []string{"Custom feature should work", "No side effects"}, - } - mockJiraService.On("GetTicketInfo", "PROJ-2000").Return(ticketInfo, nil) + mockVCS.On("GetIssue", mock.Anything, 100).Return(&models.Issue{}, errors.New("fetch error")) - changes := []models.GitChange{{ - Path: "file5.go", - Status: "M", - }} - mockGit.On("GetChangedFiles", mock.Anything).Return(changes, nil) - mockGit.On("GetDiff", mock.Anything).Return("some diff", nil) + mockAI.On("GenerateSuggestions", mock.Anything, mock.MatchedBy(func(info models.CommitInfo) bool { + return info.IssueInfo == nil + }), 3).Return([]models.CommitSuggestion{}, nil) - expectedResponse := []models.CommitSuggestion{{ - CommitTitle: "feat: add custom feature", - Files: []string{"file5.go"}, - Explanation: "some explanation", - }} - expectedInfo := models.CommitInfo{ - Files: []string{"file5.go"}, - Diff: "some diff", - TicketInfo: &models.TicketInfo{ - TicketID: "PROJ-2000", - TicketTitle: "Custom feature", - TitleDesc: "As a user, I want a custom feature so that I can do something specific.", - Criteria: []string{"Custom feature should work", "No side effects"}, - }, - } - mockAI.On("GenerateSuggestions", mock.Anything, expectedInfo, 3).Return(expectedResponse, nil) + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) + _, err := service.GenerateSuggestionsWithIssue(context.Background(), 3, 100) - service := NewCommitService(mockGit, mockAI, mockJiraService, cfgApp, trans) + assert.NoError(t, err) + }) +} - // Act - suggestions, err := service.GenerateSuggestions(context.Background(), 3) +func TestCommitService_IssueDetection(t *testing.T) { + t.Run("detect from branch name", func(t *testing.T) { + mockGit, mockAI, mockJira, mockVCS, cfg, trans := setupTest(t) + cfg.UseTicket = false - // Assert - assert.NoError(t, err) - assert.Equal(t, expectedResponse, suggestions) + mockGit.On("GetCurrentBranch", mock.Anything).Return("issue/123-fix", nil) + mockGit.On("GetChangedFiles", mock.Anything).Return([]models.GitChange{{Path: "f"}}, nil) + mockGit.On("GetDiff", mock.Anything).Return("d", nil) + mockVCS.On("GetIssue", mock.Anything, 123).Return(&models.Issue{Number: 123}, nil) - mockGit.AssertExpectations(t) - mockAI.AssertExpectations(t) - mockJiraService.AssertExpectations(t) + mockAI.On("GenerateSuggestions", mock.Anything, mock.MatchedBy(func(info models.CommitInfo) bool { + return info.IssueInfo != nil && info.IssueInfo.Number == 123 + }), 3).Return([]models.CommitSuggestion{}, nil) + + service := NewCommitService(mockGit, mockAI, mockJira, mockVCS, cfg, trans) + _, err := service.GenerateSuggestions(context.Background(), 3) + assert.NoError(t, err) + mockVCS.AssertCalled(t, "GetIssue", mock.Anything, 123) }) } diff --git a/internal/services/mocks.go b/internal/services/mocks.go new file mode 100644 index 0000000..dc6778c --- /dev/null +++ b/internal/services/mocks.go @@ -0,0 +1,194 @@ +package services + +import ( + "context" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/stretchr/testify/mock" +) + +type ( + MockGitService struct { + mock.Mock + } + MockAIProvider struct { + mock.Mock + } + + MockJiraService struct { + mock.Mock + } + + MockVCSClient struct { + mock.Mock + } + + MockPRSummarizer struct { + mock.Mock + } + + MockReleaseNotesGenerator struct { + mock.Mock + } +) + +func (m *MockJiraService) GetTicketInfo(ticketID string) (*models.TicketInfo, error) { + args := m.Called(ticketID) + return args.Get(0).(*models.TicketInfo), args.Error(1) +} + +func (m *MockGitService) HasStagedChanges(ctx context.Context) bool { + args := m.Called(ctx) + return args.Bool(0) +} + +func (m *MockGitService) AddFileToStaging(ctx context.Context, file string) error { + args := m.Called(ctx, file) + return args.Error(0) +} + +func (m *MockGitService) GetChangedFiles(ctx context.Context) ([]models.GitChange, error) { + args := m.Called(ctx) + return args.Get(0).([]models.GitChange), args.Error(1) +} + +func (m *MockGitService) GetDiff(ctx context.Context) (string, error) { + args := m.Called(ctx) + return args.String(0), args.Error(1) +} + +func (m *MockGitService) StageAllChanges(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockGitService) CreateCommit(ctx context.Context, message string) error { + args := m.Called(ctx, message) + return args.Error(0) +} + +func (m *MockGitService) GetCurrentBranch(ctx context.Context) (string, error) { + args := m.Called(ctx) + return args.String(0), args.Error(1) +} + +func (m *MockGitService) GetRepoInfo(ctx context.Context) (string, string, string, error) { + args := m.Called(ctx) + return args.String(0), args.String(1), args.String(2), args.Error(3) +} + +func (m *MockGitService) GetLastTag(ctx context.Context) (string, error) { + args := m.Called(ctx) + return args.String(0), args.Error(1) +} + +func (m *MockGitService) GetCommitCount(ctx context.Context) (int, error) { + args := m.Called(ctx) + return args.Int(0), args.Error(1) +} + +func (m *MockGitService) GetCommitsSinceTag(ctx context.Context, tag string) ([]models.Commit, error) { + args := m.Called(ctx, tag) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]models.Commit), args.Error(1) +} + +func (m *MockGitService) GetRecentCommitMessages(ctx context.Context, count int) (string, error) { + args := m.Called(ctx, count) + return args.String(0), args.Error(1) +} + +func (m *MockGitService) CreateTag(ctx context.Context, version, message string) error { + args := m.Called(ctx, version, message) + return args.Error(0) +} + +func (m *MockGitService) PushTag(ctx context.Context, version string) error { + args := m.Called(ctx, version) + return args.Error(0) +} + +func (m *MockAIProvider) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) { + args := m.Called(ctx, info, count) + return args.Get(0).([]models.CommitSuggestion), args.Error(1) +} + +func (m *MockVCSClient) UpdatePR(ctx context.Context, prNumber int, summary models.PRSummary) error { + args := m.Called(ctx, prNumber, summary) + return args.Error(0) +} + +func (m *MockVCSClient) GetPR(ctx context.Context, prNumber int) (models.PRData, error) { + args := m.Called(ctx, prNumber) + return args.Get(0).(models.PRData), args.Error(1) +} + +func (m *MockVCSClient) GetRepoLabels(ctx context.Context) ([]string, error) { + args := m.Called(ctx) + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockVCSClient) CreateLabel(ctx context.Context, name, color, description string) error { + args := m.Called(ctx, name, color, description) + return args.Error(0) +} + +func (m *MockVCSClient) AddLabelsToPR(ctx context.Context, prNumber int, labels []string) error { + args := m.Called(ctx, prNumber, labels) + return args.Error(0) +} + +func (m *MockVCSClient) CreateRelease(ctx context.Context, release *models.Release, notes *models.ReleaseNotes, draft bool) error { + args := m.Called(ctx, release, notes, draft) + return args.Error(0) +} + +func (m *MockVCSClient) GetRelease(ctx context.Context, version string) (*models.VCSRelease, error) { + args := m.Called(ctx, version) + return args.Get(0).(*models.VCSRelease), args.Error(1) +} + +func (m *MockVCSClient) UpdateRelease(ctx context.Context, version, body string) error { + args := m.Called(ctx, version, body) + return args.Error(0) +} + +func (m *MockVCSClient) GetClosedIssuesBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.Issue, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).([]models.Issue), args.Error(1) +} + +func (m *MockVCSClient) GetMergedPRsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.PullRequest, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).([]models.PullRequest), args.Error(1) +} + +func (m *MockVCSClient) GetContributorsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]string, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockVCSClient) GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).(*models.FileStatistics), args.Error(1) +} + +func (m *MockVCSClient) GetIssue(ctx context.Context, issueNumber int) (*models.Issue, error) { + args := m.Called(ctx, issueNumber) + return args.Get(0).(*models.Issue), args.Error(1) +} + +func (m *MockPRSummarizer) GeneratePRSummary(ctx context.Context, prompt string) (models.PRSummary, error) { + args := m.Called(ctx, prompt) + return args.Get(0).(models.PRSummary), args.Error(1) +} + +func (m *MockReleaseNotesGenerator) GenerateNotes(ctx context.Context, release *models.Release) (*models.ReleaseNotes, error) { + args := m.Called(ctx, release) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.ReleaseNotes), args.Error(1) +} diff --git a/internal/services/pr_service.go b/internal/services/pull_request_service.go similarity index 100% rename from internal/services/pr_service.go rename to internal/services/pull_request_service.go diff --git a/internal/services/pr_service_test.go b/internal/services/pull_request_service_test.go similarity index 69% rename from internal/services/pr_service_test.go rename to internal/services/pull_request_service_test.go index d0cb172..50cfeaf 100644 --- a/internal/services/pr_service_test.go +++ b/internal/services/pull_request_service_test.go @@ -16,84 +16,6 @@ import ( "github.com/stretchr/testify/require" ) -type MockVCSClient struct { - mock.Mock -} - -func (m *MockVCSClient) UpdatePR(ctx context.Context, prNumber int, summary models.PRSummary) error { - args := m.Called(ctx, prNumber, summary) - return args.Error(0) -} - -func (m *MockVCSClient) GetPR(ctx context.Context, prNumber int) (models.PRData, error) { - args := m.Called(ctx, prNumber) - return args.Get(0).(models.PRData), args.Error(1) -} - -func (m *MockVCSClient) GetRepoLabels(ctx context.Context) ([]string, error) { - args := m.Called(ctx) - return args.Get(0).([]string), args.Error(1) -} - -func (m *MockVCSClient) CreateLabel(ctx context.Context, name, color, description string) error { - args := m.Called(ctx, name, color, description) - return args.Error(0) -} - -func (m *MockVCSClient) AddLabelsToPR(ctx context.Context, prNumber int, labels []string) error { - args := m.Called(ctx, prNumber, labels) - return args.Error(0) -} - -func (m *MockVCSClient) CreateRelease(ctx context.Context, release *models.Release, notes *models.ReleaseNotes, draft bool) error { - args := m.Called(ctx, release, notes, draft) - return args.Error(0) -} - -func (m *MockVCSClient) GetRelease(ctx context.Context, version string) (*models.VCSRelease, error) { - args := m.Called(ctx, version) - return args.Get(0).(*models.VCSRelease), args.Error(1) -} - -func (m *MockVCSClient) UpdateRelease(ctx context.Context, version, body string) error { - args := m.Called(ctx, version, body) - return args.Error(0) -} - -func (m *MockVCSClient) GetClosedIssuesBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.Issue, error) { - args := m.Called(ctx, previousTag, currentTag) - return args.Get(0).([]models.Issue), args.Error(1) -} - -func (m *MockVCSClient) GetMergedPRsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.PullRequest, error) { - args := m.Called(ctx, previousTag, currentTag) - return args.Get(0).([]models.PullRequest), args.Error(1) -} - -func (m *MockVCSClient) GetContributorsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]string, error) { - args := m.Called(ctx, previousTag, currentTag) - return args.Get(0).([]string), args.Error(1) -} - -func (m *MockVCSClient) GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) { - args := m.Called(ctx, previousTag, currentTag) - return args.Get(0).(*models.FileStatistics), args.Error(1) -} - -func (m *MockVCSClient) GetFileAtTag(ctx context.Context, tag, filepath string) (string, error) { - args := m.Called(ctx, tag, filepath) - return args.String(0), args.Error(1) -} - -type MockPRSummarizer struct { - mock.Mock -} - -func (m *MockPRSummarizer) GeneratePRSummary(ctx context.Context, prompt string) (models.PRSummary, error) { - args := m.Called(ctx, prompt) - return args.Get(0).(models.PRSummary), args.Error(1) -} - func TestPRService_SummarizePR_Success(t *testing.T) { // Arrange ctx := context.Background() diff --git a/internal/services/release_service_test.go b/internal/services/release_service_test.go index 384a028..55ebdaa 100644 --- a/internal/services/release_service_test.go +++ b/internal/services/release_service_test.go @@ -11,18 +11,6 @@ import ( "github.com/stretchr/testify/mock" ) -type MockReleaseNotesGenerator struct { - mock.Mock -} - -func (m *MockReleaseNotesGenerator) GenerateNotes(ctx context.Context, release *models.Release) (*models.ReleaseNotes, error) { - args := m.Called(ctx, release) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*models.ReleaseNotes), args.Error(1) -} - func TestReleaseService_AnalyzeNextRelease(t *testing.T) { t.Run("Success with existing tag and feature commits", func(t *testing.T) { mockGit := new(MockGitService)