From be117b8a32aa43883ecd47e8207a66513ef853c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Thu, 18 Dec 2025 01:17:24 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(issue):=20agrego=20comando=20para=20ge?= =?UTF-8?q?neraci=C3=B3n=20y=20gesti=C3=B3n=20de=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/bug_report.yml | 49 ++ .github/ISSUE_TEMPLATE/custom.yml | 21 + .github/ISSUE_TEMPLATE/feature_request.yml | 35 ++ cmd/main.go | 10 + internal/cli/command/config/doctor.go | 2 +- internal/cli/command/config/init.go | 2 +- internal/cli/command/issues/issues.go | 340 ++++++++++++++ internal/cli/command/issues/issues_test.go | 166 +++++++ internal/cli/command/issues/mocks.go | 113 +++++ internal/cli/command/issues/templates.go | 83 ++++ internal/cli/command/issues/templates_test.go | 74 +++ internal/domain/models/issue_generation.go | 59 +++ internal/domain/models/issue_template.go | 35 ++ internal/domain/models/pr.go | 3 +- internal/domain/ports/ai_service.go | 13 + .../domain/ports/issue_generator_service.go | 34 ++ .../domain/ports/issue_template_service.go | 26 ++ internal/domain/ports/release_service.go | 4 - internal/domain/ports/vcs_client.go | 4 + internal/i18n/i18n.go | 10 +- internal/i18n/i18n_test.go | 2 +- internal/i18n/locales/active.en.toml | 152 +++++++ internal/i18n/locales/active.es.toml | 154 ++++++- .../ai/gemini/commit_summarizer_service.go | 14 +- .../gemini/commit_summarizer_service_test.go | 26 +- .../ai/gemini/gemini_factory.go | 11 +- .../ai/gemini/issue_content_generator.go | 174 ++++++++ .../ai/gemini/issue_content_generator_test.go | 176 ++++++++ internal/infrastructure/ai/prompts.go | 74 +++ internal/infrastructure/ai/prompts_test.go | 26 ++ .../ai/registry/ai_provider_registry.go | 3 + .../ai/registry/ai_provider_registry_test.go | 4 + .../dependency/gomod_analyzer_test.go | 10 + internal/infrastructure/di/container.go | 61 ++- internal/infrastructure/di/container_test.go | 158 +++++-- .../vcs/github/github_service.go | 79 +++- .../vcs/github/github_service_test.go | 210 ++++++--- internal/infrastructure/vcs/github/mocks.go | 14 + internal/services/issue_generator_service.go | 403 +++++++++++++++++ .../services/issue_generator_service_test.go | 183 ++++++++ internal/services/issue_template_service.go | 422 ++++++++++++++++++ .../services/issue_template_service_test.go | 256 +++++++++++ internal/services/mocks.go | 62 +++ internal/services/pull_request_service.go | 2 +- .../services/pull_request_service_test.go | 11 +- 45 files changed, 3639 insertions(+), 131 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/custom.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 internal/cli/command/issues/issues.go create mode 100644 internal/cli/command/issues/issues_test.go create mode 100644 internal/cli/command/issues/mocks.go create mode 100644 internal/cli/command/issues/templates.go create mode 100644 internal/cli/command/issues/templates_test.go create mode 100644 internal/domain/models/issue_generation.go create mode 100644 internal/domain/models/issue_template.go create mode 100644 internal/domain/ports/issue_generator_service.go create mode 100644 internal/domain/ports/issue_template_service.go create mode 100644 internal/infrastructure/ai/gemini/issue_content_generator.go create mode 100644 internal/infrastructure/ai/gemini/issue_content_generator_test.go create mode 100644 internal/services/issue_generator_service.go create mode 100644 internal/services/issue_generator_service_test.go create mode 100644 internal/services/issue_template_service.go create mode 100644 internal/services/issue_template_service_test.go diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..32146de --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,49 @@ +body: + - attributes: + value: Gracias por reportar este problema, tu feedback ayuda a mejorar el proyecto. + type: markdown + - attributes: + description: Contame con tus palabras qué problema encontraste + label: ¿Qué pasó? + placeholder: Cuando intento guardar los cambios, la aplicación deja de responder... + id: description + type: textarea + validations: + required: true + - attributes: + description: Detallá los pasos para que pueda reproducir el error + label: ¿Cómo lo reproduzco? + placeholder: "1. \n2. \n3. " + id: steps + type: textarea + validations: + required: true + - attributes: + label: ¿Qué esperabas que pasara? + placeholder: Esperaba que se guardaran los cambios sin problemas + id: expected + type: textarea + validations: + required: true + - attributes: + label: ¿Qué pasó en realidad? + placeholder: La aplicación se quedó trabada y tuve que cerrarla + id: actual + type: textarea + validations: + required: true + - attributes: + label: Versión + placeholder: v1.0.0 + id: version + type: input + - attributes: + description: Screenshots, logs, o cualquier detalle que pueda ayudar a entender el problema + label: Información extra + id: additional + type: textarea +description: Crea un reporte para ayudarnos a mejorar +labels: + - bug +name: Reporte de Error +title: '[BUG] ' diff --git a/.github/ISSUE_TEMPLATE/custom.yml b/.github/ISSUE_TEMPLATE/custom.yml new file mode 100644 index 0000000..7a40197 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.yml @@ -0,0 +1,21 @@ +body: + - attributes: + value: Describí el tema que querés reportar o discutir. + type: markdown + - attributes: + description: Contame de qué se trata + label: Descripción + placeholder: Quisiera proponer/reportar/discutir... + id: description + type: textarea + validations: + required: true + - attributes: + description: Cualquier detalle extra que sea relevante + label: Información adicional + id: additional + type: textarea +description: Crea una issue personalizada +labels: [] +name: Issue Personalizada +title: '[ISSUE] ' diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..26a87db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +body: + - attributes: + value: Gracias por contribuir con ideas para mejorar el proyecto. + type: markdown + - attributes: + description: Contame qué necesidad o dificultad tenés actualmente + label: ¿Qué problema querés resolver? + placeholder: Me cuesta mucho hacer X porque no hay forma de Y... + id: problem + type: textarea + validations: + required: true + - attributes: + description: Describí cómo te imaginás que debería funcionar + label: ¿Qué solución proponés? + placeholder: Estaría bueno poder hacer X desde Y para que sea más rápido... + id: solution + type: textarea + validations: + required: true + - attributes: + description: Si consideraste alternativas, contame cuáles + label: ¿Pensaste en otras opciones? + id: alternatives + type: textarea + - attributes: + description: Screenshots, ejemplos, o links que puedan ayudar a entender la propuesta + label: Contexto adicional + id: additional + type: textarea +description: Sugiere una idea para este proyecto +labels: + - enhancement +name: Solicitud de Funcionalidad +title: '[FEATURE] ' diff --git a/cmd/main.go b/cmd/main.go index 7044487..87ea925 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -9,6 +9,7 @@ import ( "github.com/Tomas-vilte/MateCommit/internal/cli/command/completion" "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/issues" "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/suggests_commits" @@ -90,12 +91,21 @@ func initializeApp() (*cli.Command, error) { prServiceFactory := factory.NewPrServiceFactory(cfgApp, translations, nil, gitService) prCommand := pull_requests.NewSummarizeCommand(prServiceFactory) + issueGeneratorService, err := container.GetIssueGeneratorService(ctx) + if err != nil { + log.Printf("Warning: no se pudo inicializar el servicio de generación de issues: %v", err) + } + registerCommand := registry.NewRegistry(cfgApp, translations) if err := registerCommand.Register("suggest", suggests_commits.NewSuggestCommandFactory(commitService, commitHandler)); err != nil { log.Fatalf("Error al registrar el comando 'suggest': %v", err) } + if err := registerCommand.Register("issue", issues.NewIssuesCommandFactory(issueGeneratorService, container.GetIssueTemplateService())); err != nil { + log.Fatalf("Error al registrar el comando 'issue': %v", err) + } + if err := registerCommand.Register("config", config.NewConfigCommandFactory()); err != nil { log.Fatalf("Error al registrar el comando 'config': %v", err) } diff --git a/internal/cli/command/config/doctor.go b/internal/cli/command/config/doctor.go index 2e80d13..59eb175 100644 --- a/internal/cli/command/config/doctor.go +++ b/internal/cli/command/config/doctor.go @@ -206,7 +206,7 @@ func (d *DoctorCommand) checkGeminiAPIKey(ctx context.Context, t *i18n.Translati testCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - service, err := gemini.NewGeminiService(testCtx, cfg, t) + service, err := gemini.NewGeminiCommitSummarizer(testCtx, cfg, t) if err != nil { return checkResult{ status: checkStatusError, diff --git a/internal/cli/command/config/init.go b/internal/cli/command/config/init.go index 6667b1d..6050828 100644 --- a/internal/cli/command/config/init.go +++ b/internal/cli/command/config/init.go @@ -382,7 +382,7 @@ func validateGeminiAPIKey(ctx context.Context, apiKey string, t *i18n.Translatio testCtx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - _, err := gemini.NewGeminiService(testCtx, testCfg, t) + _, err := gemini.NewGeminiCommitSummarizer(testCtx, testCfg, t) if err != nil { spinner.Error(t.GetMessage("config.api_key_invalid", 0, nil)) ui.PrintError(t.GetMessage("config.check_api_key_error", 0, map[string]interface{}{ diff --git a/internal/cli/command/issues/issues.go b/internal/cli/command/issues/issues.go new file mode 100644 index 0000000..c3b38cb --- /dev/null +++ b/internal/cli/command/issues/issues.go @@ -0,0 +1,340 @@ +package issues + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/Tomas-vilte/MateCommit/internal/cli/completion_helper" + "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/ui" + "github.com/urfave/cli/v3" +) + +// IssuesCommandFactory es el factory para crear el comando de issues. +type IssuesCommandFactory struct { + issueService ports.IssueGeneratorService + templateService ports.IssueTemplateService +} + +// NewIssuesCommandFactory crea una nueva instancia del factory. +func NewIssuesCommandFactory(issueService ports.IssueGeneratorService, templateService ports.IssueTemplateService) *IssuesCommandFactory { + return &IssuesCommandFactory{ + issueService: issueService, + templateService: templateService, + } +} + +// CreateCommand crea el comando principal de issues con sus subcomandos. +func (f *IssuesCommandFactory) CreateCommand(t *i18n.Translations, cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "issue", + Aliases: []string{"i"}, + Usage: t.GetMessage("issue.command_usage", 0, nil), + Commands: []*cli.Command{ + f.newGenerateCommand(t, cfg), + f.newTemplateCommand(t, cfg), + }, + } +} + +// newGenerateCommand crea el subcomando 'generate'. +func (f *IssuesCommandFactory) newGenerateCommand(t *i18n.Translations, cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "generate", + Aliases: []string{"g"}, + Usage: t.GetMessage("issue.generate_usage", 0, nil), + Flags: f.createGenerateFlags(t), + ShellComplete: completion_helper.DefaultFlagComplete, + Action: f.createGenerateAction(t, cfg), + } +} + +// createGenerateFlags define los flags para el comando generate. +func (f *IssuesCommandFactory) createGenerateFlags(t *i18n.Translations) []cli.Flag { + return []cli.Flag{ + &cli.BoolFlag{ + Name: "from-diff", + Aliases: []string{"d"}, + Usage: t.GetMessage("issue.flag_from_diff", 0, nil), + }, + &cli.IntFlag{ + Name: "from-pr", + Aliases: []string{"p", "pr"}, + Usage: t.GetMessage("issue.flag_from_pr", 0, nil), + Value: 0, + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"m"}, + Usage: t.GetMessage("issue.flag_description", 0, nil), + }, + &cli.StringFlag{ + Name: "hint", + Aliases: []string{"h"}, + Usage: t.GetMessage("issue.flag_hint", 0, nil), + }, + &cli.StringFlag{ + Name: "template", + Aliases: []string{"t"}, + Usage: t.GetMessage("issue.flag_template", 0, nil), + }, + &cli.BoolFlag{ + Name: "no-labels", + Usage: t.GetMessage("issue.flag_no_labels", 0, nil), + }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: t.GetMessage("issue.flag_dry_run", 0, nil), + }, + &cli.BoolFlag{ + Name: "assign-me", + Aliases: []string{"a"}, + Usage: t.GetMessage("issue.flag_assign_me", 0, nil), + }, + &cli.BoolFlag{ + Name: "checkout", + Aliases: []string{"c"}, + Usage: t.GetMessage("issue.flag_checkout", 0, nil), + }, + } +} + +func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *config.Config) cli.ActionFunc { + return func(ctx context.Context, command *cli.Command) error { + fromDiff := command.Bool("from-diff") + fromPR := command.Int("from-pr") + description := command.String("description") + hint := command.String("hint") + noLabels := command.Bool("no-labels") + dryRun := command.Bool("dry-run") + assignMe := command.Bool("assign-me") + checkoutBranch := command.Bool("checkout") + templateName := command.String("template") + + sourcesCount := 0 + if fromDiff { + sourcesCount++ + } + if fromPR > 0 { + sourcesCount++ + } + if description != "" { + sourcesCount++ + } + + if sourcesCount == 0 { + ui.PrintError(t.GetMessage("issue.error_no_input", 0, nil)) + return fmt.Errorf("%s", t.GetMessage("issue.error_no_input", 0, nil)) + } + + if sourcesCount > 1 { + ui.PrintError(t.GetMessage("issue.error_multiple_sources", 0, nil)) + return fmt.Errorf("%s", t.GetMessage("issue.error_multiple_sources", 0, nil)) + } + + ui.PrintSectionBanner(t.GetMessage("issue.banner", 0, nil)) + + var spinnerMsg string + if fromPR > 0 { + spinnerMsg = t.GetMessage("issue.analyzing_pr", 0, map[string]interface{}{ + "Number": fromPR, + }) + } else { + spinnerMsg = t.GetMessage("issue.analyzing", 0, nil) + } + + spinner := ui.NewSmartSpinner(spinnerMsg) + spinner.Start() + + var result *models.IssueGenerationResult + var err error + + if templateName != "" { + result, err = f.issueService.GenerateWithTemplate(ctx, templateName, hint, fromDiff, description, noLabels) + } else if fromDiff { + result, err = f.issueService.GenerateFromDiff(ctx, hint, noLabels) + } else if fromPR > 0 { + result, err = f.issueService.GenerateFromPR(ctx, fromPR, hint, noLabels) + } else { + result, err = f.issueService.GenerateFromDescription(ctx, description, noLabels) + } + + spinner.Stop() + + if err != nil { + f.handleGenerationError(err, t) + return err + } + + f.printPreview(result, t, cfg) + ui.PrintTokenUsage(result.Usage, t) + + if dryRun { + ui.PrintInfo(t.GetMessage("issue.dry_run_complete", 0, nil)) + return nil + } + + if !f.promptConfirmation(t) { + ui.PrintInfo(t.GetMessage("issue.cancelled", 0, nil)) + return nil + } + + var assignees []string + if assignMe { + spinner = ui.NewSmartSpinner(t.GetMessage("issue.getting_user", 0, nil)) + spinner.Start() + + username, err := f.issueService.GetAuthenticatedUser(ctx) + spinner.Stop() + + if err != nil { + ui.PrintWarning(fmt.Sprintf("%s: %v", t.GetMessage("issue.warn_assignee_failed", 0, nil), err)) + } else { + assignees = []string{username} + ui.PrintInfo(t.GetMessage("issue.will_assign", 0, map[string]interface{}{ + "User": username, + })) + } + } + + spinner = ui.NewSmartSpinner(t.GetMessage("issue.creating", 0, nil)) + spinner.Start() + + issue, err := f.issueService.CreateIssue(ctx, result, assignees) + spinner.Stop() + + if err != nil { + f.handleCreationError(err, t) + return err + } + + ui.PrintSuccess(t.GetMessage("issue.created_successfully", 0, map[string]interface{}{ + "Number": issue.Number, + "URL": issue.URL, + })) + + if fromPR > 0 { + if err := f.issueService.LinkIssueToPR(ctx, fromPR, issue.Number); err != nil { + ui.PrintWarning(t.GetMessage("issue.link_error", 0, map[string]interface{}{ + "PR": fromPR, + "Error": err, + })) + } else { + ui.PrintInfo(t.GetMessage("issue.link_success", 0, map[string]interface{}{ + "PR": fromPR, + "Issue": issue.Number, + })) + } + } + + if checkoutBranch { + branchName := f.issueService.InferBranchName(issue.Number, result.Labels) + + ui.PrintInfo(t.GetMessage("issue.creating_branch", 0, map[string]interface{}{ + "Branch": branchName, + })) + + if err := f.checkoutBranch(branchName); err != nil { + ui.PrintWarning(fmt.Sprintf("%s: %v", t.GetMessage("issue.warn_checkout_failed", 0, nil), err)) + } else { + ui.PrintSuccess(t.GetMessage("issue.branch_created", 0, map[string]interface{}{ + "Branch": branchName, + })) + } + } + + return nil + } +} + +// handleGenerationError maneja errores durante la generacion de contenido. +func (f *IssuesCommandFactory) handleGenerationError(err error, t *i18n.Translations) { + errStr := err.Error() + + if strings.Contains(strings.ToLower(errStr), "api key") || strings.Contains(errStr, "not configured") { + ui.PrintErrorWithSuggestion( + t.GetMessage("issue.error_generating", 0, nil)+": "+errStr, + t.GetMessage("ui_error.run_config_init", 0, nil), + ) + } else { + ui.PrintError(fmt.Sprintf("%s: %v", t.GetMessage("issue.error_generating", 0, nil), err)) + } +} + +// handleCreationError maneja errores durante la creacion de la issue. +func (f *IssuesCommandFactory) handleCreationError(err error, t *i18n.Translations) { + errStr := err.Error() + + if strings.Contains(strings.ToLower(errStr), "token") { + ui.PrintErrorWithSuggestion( + t.GetMessage("issue.error_creating", 0, nil)+": "+errStr, + t.GetMessage("ui_error.run_config_init", 0, nil), + ) + } else { + ui.PrintError(fmt.Sprintf("%s: %v", t.GetMessage("issue.error_creating", 0, nil), err)) + } +} + +// printPreview muestra un preview de la issue que se va a crear. +func (f *IssuesCommandFactory) printPreview(result *models.IssueGenerationResult, t *i18n.Translations, cfg *config.Config) { + separator := strings.Repeat("\u2500", 60) + + fmt.Println() + fmt.Println(separator) + + emoji := "" + if cfg.UseEmoji { + emoji = "\U0001F4CB " + } + + ui.PrintInfo(fmt.Sprintf("%s%s", emoji, t.GetMessage("issue.preview_title", 0, nil))) + fmt.Println() + + ui.PrintKeyValue(t.GetMessage("issue.preview_title_label", 0, nil), result.Title) + fmt.Println() + + ui.PrintInfo(fmt.Sprintf("%s:", t.GetMessage("issue.preview_description_label", 0, nil))) + fmt.Println(result.Description) + fmt.Println() + + if len(result.Labels) > 0 { + ui.PrintInfo(fmt.Sprintf("%s: %s", t.GetMessage("issue.preview_labels_label", 0, nil), strings.Join(result.Labels, ", "))) + } + + fmt.Println(separator) + fmt.Println() +} + +// promptConfirmation solicita confirmacion al usuario para crear la issue. +func (f *IssuesCommandFactory) promptConfirmation(t *i18n.Translations) bool { + reader := bufio.NewReader(os.Stdin) + + prompt := t.GetMessage("issue.confirm_prompt", 0, nil) + fmt.Printf("%s (Y/n): ", prompt) + + response, err := reader.ReadString('\n') + if err != nil { + return false + } + + response = strings.ToLower(strings.TrimSpace(response)) + + return response == "" || response == "y" || response == "yes" || + response == "s" || response == "si" +} + +func (f *IssuesCommandFactory) checkoutBranch(branchName string) error { + cmd := exec.Command("git", "checkout", "-b", branchName) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git checkout fallo: %w (output: %s)", err, string(output)) + } + return nil +} diff --git a/internal/cli/command/issues/issues_test.go b/internal/cli/command/issues/issues_test.go new file mode 100644 index 0000000..6eb6f7b --- /dev/null +++ b/internal/cli/command/issues/issues_test.go @@ -0,0 +1,166 @@ +package issues + +import ( + "context" + "os" + "testing" + + "github.com/Tomas-vilte/MateCommit/internal/config" + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" +) + +func setupIssuesTest(t *testing.T) (*MockIssueGeneratorService, *MockIssueTemplateService, *i18n.Translations, *config.Config) { + mockGen := &MockIssueGeneratorService{} + mockTemp := &MockIssueTemplateService{} + trans, err := i18n.NewTranslations("en", "../../../../internal/i18n/locales") + require.NoError(t, err) + cfg := &config.Config{Language: "en"} + return mockGen, mockTemp, trans, cfg +} + +func withStdin(input string, f func()) { + origStdin := os.Stdin + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + defer func() { + if err := w.Close(); err != nil { + panic(err) + } + }() + _, _ = w.Write([]byte(input)) + }() + f() + os.Stdin = origStdin +} + +func TestIssueGenerateAction(t *testing.T) { + t.Run("should fail if no input provided", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "generate"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "You must specify either --from-diff or --description") + }) + + t.Run("should fail if multiple sources provided", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "generate", "--from-diff", "--from-pr", "123"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "You can only specify one of: --from-diff, --from-pr, or --description") + }) + + t.Run("should generate from diff successfully", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + expectedResult := &models.IssueGenerationResult{ + Title: "Test Issue", + Description: "Test Description", + Labels: []string{"bug"}, + } + + mockGen.On("GenerateFromDiff", mock.Anything, "hint", false).Return(expectedResult, nil) + mockGen.On("CreateIssue", mock.Anything, expectedResult, []string(nil)).Return(&models.Issue{Number: 1, URL: "http://test.com"}, nil) + + withStdin("y\n", func() { + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "generate", "--from-diff", "--hint", "hint"}) + assert.NoError(t, err) + }) + + mockGen.AssertExpectations(t) + }) + + t.Run("should handle dry-run correctly", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + expectedResult := &models.IssueGenerationResult{ + Title: "Dry Run Issue", + Description: "Dry Run Desc", + } + + mockGen.On("GenerateFromDescription", mock.Anything, "desc", false).Return(expectedResult, nil) + + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "generate", "--description", "desc", "--dry-run"}) + + assert.NoError(t, err) + mockGen.AssertExpectations(t) + mockGen.AssertNotCalled(t, "CreateIssue", mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("should handle assign-me flag", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + expectedResult := &models.IssueGenerationResult{ + Title: "Assigned Issue", + } + + mockGen.On("GenerateFromDiff", mock.Anything, "", false).Return(expectedResult, nil) + mockGen.On("GetAuthenticatedUser", mock.Anything).Return("test-user", nil) + mockGen.On("CreateIssue", mock.Anything, expectedResult, []string{"test-user"}).Return(&models.Issue{Number: 1}, nil) + + withStdin("y\n", func() { + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "generate", "--from-diff", "--assign-me"}) + assert.NoError(t, err) + }) + + mockGen.AssertExpectations(t) + }) + + t.Run("should cancel if user chooses no", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + mockGen.On("GenerateFromDiff", mock.Anything, "", false).Return(&models.IssueGenerationResult{Title: "T"}, nil) + + withStdin("n\n", func() { + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "generate", "--from-diff"}) + assert.NoError(t, err) + }) + + mockGen.AssertNotCalled(t, "CreateIssue", mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("should use template if specified", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + expectedResult := &models.IssueGenerationResult{Title: "Template Issue"} + + mockGen.On("GenerateWithTemplate", mock.Anything, "bug", "", true, "", false).Return(expectedResult, nil) + mockGen.On("CreateIssue", mock.Anything, expectedResult, []string(nil)).Return(&models.Issue{Number: 1}, nil) + + withStdin("y\n", func() { + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "generate", "--from-diff", "--template", "bug"}) + assert.NoError(t, err) + }) + + mockGen.AssertExpectations(t) + }) +} diff --git a/internal/cli/command/issues/mocks.go b/internal/cli/command/issues/mocks.go new file mode 100644 index 0000000..f08d644 --- /dev/null +++ b/internal/cli/command/issues/mocks.go @@ -0,0 +1,113 @@ +package issues + +import ( + "context" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/stretchr/testify/mock" +) + +type MockIssueGeneratorService struct { + mock.Mock +} + +func (m *MockIssueGeneratorService) GenerateFromDiff(ctx context.Context, hint string, skipLabels bool) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, hint, skipLabels) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.IssueGenerationResult), args.Error(1) +} + +func (m *MockIssueGeneratorService) GenerateFromDescription(ctx context.Context, description string, skipLabels bool) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, description, skipLabels) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.IssueGenerationResult), args.Error(1) +} + +func (m *MockIssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, prNumber, hint, skipLabels) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.IssueGenerationResult), args.Error(1) +} + +func (m *MockIssueGeneratorService) GenerateWithTemplate(ctx context.Context, templateName string, hint string, fromDiff bool, description string, skipLabels bool) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, templateName, hint, fromDiff, description, skipLabels) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.IssueGenerationResult), args.Error(1) +} + +func (m *MockIssueGeneratorService) CreateIssue(ctx context.Context, result *models.IssueGenerationResult, assignees []string) (*models.Issue, error) { + args := m.Called(ctx, result, assignees) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.Issue), args.Error(1) +} + +func (m *MockIssueGeneratorService) GetAuthenticatedUser(ctx context.Context) (string, error) { + args := m.Called(ctx) + return args.String(0), args.Error(1) +} + +func (m *MockIssueGeneratorService) InferBranchName(issueNumber int, labels []string) string { + args := m.Called(issueNumber, labels) + return args.String(0) +} + +func (m *MockIssueGeneratorService) LinkIssueToPR(ctx context.Context, prNumber int, issueNumber int) error { + args := m.Called(ctx, prNumber, issueNumber) + return args.Error(0) +} + +type MockIssueTemplateService struct { + mock.Mock +} + +func (m *MockIssueTemplateService) GetTemplatesDir() (string, error) { + args := m.Called() + return args.String(0), args.Error(1) +} + +func (m *MockIssueTemplateService) ListTemplates() ([]models.TemplateMetadata, error) { + args := m.Called() + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]models.TemplateMetadata), args.Error(1) +} + +func (m *MockIssueTemplateService) LoadTemplate(filePath string) (*models.IssueTemplate, error) { + args := m.Called(filePath) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.IssueTemplate), args.Error(1) +} + +func (m *MockIssueTemplateService) GetTemplateByName(name string) (*models.IssueTemplate, error) { + args := m.Called(name) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.IssueTemplate), args.Error(1) +} + +func (m *MockIssueTemplateService) InitializeTemplates(force bool) error { + args := m.Called(force) + return args.Error(0) +} + +func (m *MockIssueTemplateService) MergeWithGeneratedContent(template *models.IssueTemplate, generated *models.IssueGenerationResult) *models.IssueGenerationResult { + args := m.Called(template, generated) + if args.Get(0) == nil { + return nil + } + return args.Get(0).(*models.IssueGenerationResult) +} diff --git a/internal/cli/command/issues/templates.go b/internal/cli/command/issues/templates.go new file mode 100644 index 0000000..46f146d --- /dev/null +++ b/internal/cli/command/issues/templates.go @@ -0,0 +1,83 @@ +package issues + +import ( + "context" + "fmt" + + "github.com/Tomas-vilte/MateCommit/internal/config" + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/Tomas-vilte/MateCommit/internal/ui" + "github.com/urfave/cli/v3" +) + +// newTemplateCommand crea el subcomando 'template' para gestión de templates. +func (f *IssuesCommandFactory) newTemplateCommand(t *i18n.Translations, _ *config.Config) *cli.Command { + templateService := f.templateService + + return &cli.Command{ + Name: "template", + Aliases: []string{"t"}, + Usage: t.GetMessage("issue.template_usage", 0, nil), + Commands: []*cli.Command{ + { + Name: "init", + Aliases: []string{"i"}, + Usage: t.GetMessage("issue.template_init_usage", 0, nil), + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "force", + Usage: t.GetMessage("issue.template_force_flag", 0, nil), + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + force := cmd.Bool("force") + + ui.PrintSectionBanner(t.GetMessage("issue.template_init_banner", 0, nil)) + ui.PrintInfo(t.GetMessage("issue.template_init_info", 0, nil)) + + if err := templateService.InitializeTemplates(force); err != nil { + ui.PrintError(fmt.Sprintf("%s: %v", t.GetMessage("issue.template_init_error", 0, nil), err)) + return err + } + + templatesDir, _ := templateService.GetTemplatesDir() + ui.PrintSuccess(t.GetMessage("issue.template_init_success", 0, map[string]interface{}{ + "Dir": templatesDir, + })) + + return nil + }, + }, + { + Name: "list", + Aliases: []string{"ls", "l"}, + Usage: t.GetMessage("issue.template_list_usage", 0, nil), + Action: func(ctx context.Context, cmd *cli.Command) error { + templates, err := templateService.ListTemplates() + if err != nil { + ui.PrintError(fmt.Sprintf("%s: %v", t.GetMessage("issue.template_list_error", 0, nil), err)) + return err + } + + if len(templates) == 0 { + ui.PrintWarning(t.GetMessage("issue.template_list_empty", 0, nil)) + ui.PrintInfo(t.GetMessage("issue.template_list_hint", 0, nil)) + return nil + } + + ui.PrintSectionBanner(t.GetMessage("issue.template_list_banner", 0, nil)) + fmt.Println() + + for _, tmpl := range templates { + ui.PrintKeyValue(tmpl.FilePath, fmt.Sprintf("%s - %s", tmpl.Name, tmpl.About)) + } + + fmt.Println() + ui.PrintInfo(t.GetMessage("issue.template_list_usage_hint", 0, nil)) + + return nil + }, + }, + }, + } +} diff --git a/internal/cli/command/issues/templates_test.go b/internal/cli/command/issues/templates_test.go new file mode 100644 index 0000000..9cd4099 --- /dev/null +++ b/internal/cli/command/issues/templates_test.go @@ -0,0 +1,74 @@ +package issues + +import ( + "context" + "testing" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v3" +) + +func TestIssueTemplateAction(t *testing.T) { + t.Run("should init templates successfully", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + mockTemp.On("InitializeTemplates", false).Return(nil) + mockTemp.On("GetTemplatesDir").Return("/path/to/templates", nil) + + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "template", "init"}) + + assert.NoError(t, err) + mockTemp.AssertExpectations(t) + }) + + t.Run("should init templates with force flag", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + mockTemp.On("InitializeTemplates", true).Return(nil) + mockTemp.On("GetTemplatesDir").Return("/path/to/templates", nil) + + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "template", "init", "--force"}) + + assert.NoError(t, err) + mockTemp.AssertExpectations(t) + }) + + t.Run("should list templates successfully", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + templates := []models.TemplateMetadata{ + {Name: "Bug", About: "Bug report", FilePath: "bug.md"}, + {Name: "Feature", About: "Feature request", FilePath: "feat.md"}, + } + mockTemp.On("ListTemplates").Return(templates, nil) + + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "template", "list"}) + + assert.NoError(t, err) + mockTemp.AssertExpectations(t) + }) + + t.Run("should handle empty template list", func(t *testing.T) { + mockGen, mockTemp, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(mockGen, mockTemp) + cmd := factory.CreateCommand(trans, cfg) + + mockTemp.On("ListTemplates").Return([]models.TemplateMetadata{}, nil) + + app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} + err := app.Run(context.Background(), []string{"test", "issue", "template", "list"}) + + assert.NoError(t, err) + mockTemp.AssertExpectations(t) + }) +} diff --git a/internal/domain/models/issue_generation.go b/internal/domain/models/issue_generation.go new file mode 100644 index 0000000..0a17327 --- /dev/null +++ b/internal/domain/models/issue_generation.go @@ -0,0 +1,59 @@ +package models + +// IssueGenerationRequest contiene la información necesaria para generar una issue. +// Soporta múltiples fuentes de contexto: descripción manual, diff de git, o ambos. +type IssueGenerationRequest struct { + // Description es la descripción manual proporcionada por el usuario (opcional) + Description string + + // Diff contiene los cambios locales de git (opcional) + Diff string + + // ChangedFiles es la lista de archivos modificados (opcional) + ChangedFiles []string + + // Hint es contexto adicional proporcionado por el usuario para guiar la generación (opcional) + Hint string + + // Language es el idioma para la generación de contenido (ej: "es", "en") + Language string +} + +// IssueGenerationResult contiene el resultado de la generación de contenido de una issue. +type IssueGenerationResult struct { + // Title es el título generado para la issue + Title string + + // Description es la descripción completa generada para la issue + Description string + + // Labels son las etiquetas sugeridas para la issue + Labels []string + + // Assignees son los responsables sugeridos para la issue + Assignees []string + + // Usage contiene los metadatos de uso de tokens de la IA + Usage *UsageMetadata +} + +// DiffAnalysis contiene el análisis estructurado del diff para inferencia de labels. +type DiffAnalysis struct { + // HasGoFiles indica si el diff incluye archivos .go + HasGoFiles bool + + // HasTestFiles indica si el diff incluye archivos de test + HasTestFiles bool + + // HasDocFiles indica si el diff incluye archivos de documentación + HasDocFiles bool + + // HasConfigFiles indica si el diff incluye archivos de configuración + HasConfigFiles bool + + // HasUIFiles indica si el diff incluye archivos de UI (CSS, HTML, JSX, etc) + HasUIFiles bool + + // Keywords contiene palabras clave encontradas en el diff (fix, feat, refactor, etc) + Keywords map[string]bool +} diff --git a/internal/domain/models/issue_template.go b/internal/domain/models/issue_template.go new file mode 100644 index 0000000..92cee07 --- /dev/null +++ b/internal/domain/models/issue_template.go @@ -0,0 +1,35 @@ +package models + +// IssueTemplate representa un template de issue con su metadata. +type IssueTemplate struct { + // Metadata del frontmatter YAML + Name string `yaml:"name"` + About string `yaml:"about,omitempty"` + Description string `yaml:"description,omitempty"` + Title string `yaml:"title"` + Labels []string `yaml:"labels"` + Assignees []string `yaml:"assignees,omitempty"` + + // Contenido del template + // Para .md: string con markdown + // Para .yml (GitHub Issue Forms): array de campos del formulario + Body interface{} `yaml:"body,omitempty"` + BodyContent string `yaml:"-"` // Para retrocompatibilidad con .md + + // Path al archivo del template + FilePath string `yaml:"-"` +} + +// GetAbout retorna la descripción del template (usa 'description' o 'about') +func (t *IssueTemplate) GetAbout() string { + if t.Description != "" { + return t.Description + } + return t.About +} + +type TemplateMetadata struct { + Name string + About string + FilePath string +} diff --git a/internal/domain/models/pr.go b/internal/domain/models/pr.go index d426a86..ab2a377 100644 --- a/internal/domain/models/pr.go +++ b/internal/domain/models/pr.go @@ -4,12 +4,13 @@ type ( // PRData contiene la información extraída de una Pull Request. PRData struct { ID int + Title string Creator string Commits []Commit Diff string BranchName string RelatedIssues []Issue - PRDescription string + Description string } // Commit representa un commit incluido en el PR. diff --git a/internal/domain/ports/ai_service.go b/internal/domain/ports/ai_service.go index 4e5b21f..310db34 100644 --- a/internal/domain/ports/ai_service.go +++ b/internal/domain/ports/ai_service.go @@ -2,6 +2,7 @@ package ports import ( "context" + "github.com/Tomas-vilte/MateCommit/internal/domain/models" ) @@ -16,3 +17,15 @@ type PRSummarizer interface { // GeneratePRSummary genera un resumen de un Pull Request dado un prompt. GeneratePRSummary(ctx context.Context, prompt string) (models.PRSummary, error) } + +// ReleaseNotesGenerator define la interfaz para generar notas de release. +type ReleaseNotesGenerator interface { + GenerateNotes(ctx context.Context, release *models.Release) (*models.ReleaseNotes, error) +} + +// IssueContentGenerator define la interfaz para generar contenido de issues con IA. +type IssueContentGenerator interface { + // GenerateIssueContent genera el título, descripción y labels de una issue + // basándose en el contexto proporcionado en el request. + GenerateIssueContent(ctx context.Context, request models.IssueGenerationRequest) (*models.IssueGenerationResult, error) +} diff --git a/internal/domain/ports/issue_generator_service.go b/internal/domain/ports/issue_generator_service.go new file mode 100644 index 0000000..2ad81a5 --- /dev/null +++ b/internal/domain/ports/issue_generator_service.go @@ -0,0 +1,34 @@ +package ports + +import ( + "context" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" +) + +// IssueGeneratorService define la interfaz para el servicio de generación de issues +type IssueGeneratorService interface { + // GenerateFromDiff genera contenido para una issue a partir del diff actual + GenerateFromDiff(ctx context.Context, hint string, skipLabels bool) (*models.IssueGenerationResult, error) + + // GenerateFromDescription genera contenido para una issue a partir de una descripción manual + GenerateFromDescription(ctx context.Context, description string, skipLabels bool) (*models.IssueGenerationResult, error) + + // GenerateFromPR genera contenido para una issue a partir de un Pull Request existente + GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool) (*models.IssueGenerationResult, error) + + // GenerateWithTemplate genera contenido usando un template específico + GenerateWithTemplate(ctx context.Context, templateName string, hint string, fromDiff bool, description string, skipLabels bool) (*models.IssueGenerationResult, error) + + // CreateIssue crea la issue en el sistema VCS + CreateIssue(ctx context.Context, result *models.IssueGenerationResult, assignees []string) (*models.Issue, error) + + // GetAuthenticatedUser obtiene el usuario autenticado actual + GetAuthenticatedUser(ctx context.Context) (string, error) + + // InferBranchName infiere un nombre de rama basado en el número de issue y las etiquetas + InferBranchName(issueNumber int, labels []string) string + + // LinkIssueToPR vincula una issue a un Pull Request + LinkIssueToPR(ctx context.Context, prNumber int, issueNumber int) error +} diff --git a/internal/domain/ports/issue_template_service.go b/internal/domain/ports/issue_template_service.go new file mode 100644 index 0000000..d407a3a --- /dev/null +++ b/internal/domain/ports/issue_template_service.go @@ -0,0 +1,26 @@ +package ports + +import ( + "github.com/Tomas-vilte/MateCommit/internal/domain/models" +) + +// IssueTemplateService define la interfaz para el servicio de gestión de templates de issues +type IssueTemplateService interface { + // GetTemplatesDir obtiene el directorio donde se almacenan los templates + GetTemplatesDir() (string, error) + + // ListTemplates lista todos los templates disponibles + ListTemplates() ([]models.TemplateMetadata, error) + + // LoadTemplate carga un template desde un archivo + LoadTemplate(filePath string) (*models.IssueTemplate, error) + + // GetTemplateByName obtiene un template por su nombre + GetTemplateByName(name string) (*models.IssueTemplate, error) + + // InitializeTemplates inicializa los templates predefinidos + InitializeTemplates(force bool) error + + // MergeWithGeneratedContent combina un template con contenido generado por IA + MergeWithGeneratedContent(template *models.IssueTemplate, generated *models.IssueGenerationResult) *models.IssueGenerationResult +} diff --git a/internal/domain/ports/release_service.go b/internal/domain/ports/release_service.go index 694e75f..8f385a4 100644 --- a/internal/domain/ports/release_service.go +++ b/internal/domain/ports/release_service.go @@ -36,7 +36,3 @@ type ReleaseService interface { PushChanges(ctx context.Context) error UpdateAppVersion(version string) error } - -type ReleaseNotesGenerator interface { - GenerateNotes(ctx context.Context, release *models.Release) (*models.ReleaseNotes, error) -} diff --git a/internal/domain/ports/vcs_client.go b/internal/domain/ports/vcs_client.go index 4a2255d..0a3d6bb 100644 --- a/internal/domain/ports/vcs_client.go +++ b/internal/domain/ports/vcs_client.go @@ -41,4 +41,8 @@ type VCSClient interface { GetPRIssues(ctx context.Context, branchName string, commits []string, prDescription string) ([]models.Issue, error) // UpdateIssueChecklist actualiza el checklist de un issue marcando elementos como completados UpdateIssueChecklist(ctx context.Context, issueNumber int, indices []int) error + // CreateIssue crea una nueva issue en el repositorio + CreateIssue(ctx context.Context, title string, body string, labels []string, assignees []string) (*models.Issue, error) + // GetAuthenticatedUser obtiene el usuario autenticado actual + GetAuthenticatedUser(ctx context.Context) (string, error) } diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go index 57ae72f..ecda39a 100644 --- a/internal/i18n/i18n.go +++ b/internal/i18n/i18n.go @@ -21,7 +21,7 @@ type Translations struct { func NewTranslations(defaultLang string, localesPath string) (*Translations, error) { if defaultLang == "" { - return nil, fmt.Errorf("default language cannot be empty") + return nil, fmt.Errorf("el idioma predeterminado no puede estar vacío") } var files []os.DirEntry @@ -49,7 +49,7 @@ func NewTranslations(defaultLang string, localesPath string) (*Translations, err } if err != nil { - return nil, fmt.Errorf("error reading file %s: %w", file.Name(), err) + return nil, fmt.Errorf("error leyendo archivo %s: %w", file.Name(), err) } bundle.MustParseMessageFileBytes(data, file.Name()) @@ -63,8 +63,6 @@ func NewTranslations(defaultLang string, localesPath string) (*Translations, err }, nil } -// Force re-embed check 2 - func (t *Translations) SetLanguage(lang string) error { for _, tag := range t.bundle.LanguageTags() { if tag.String() == lang { @@ -72,7 +70,7 @@ func (t *Translations) SetLanguage(lang string) error { return nil } } - return fmt.Errorf("language '%s' not supported", lang) + return fmt.Errorf("lenguaje '%s' no soportado", lang) } func (t *Translations) GetMessage(messageID string, count int, templateData map[string]interface{}) string { @@ -84,7 +82,7 @@ func (t *Translations) GetMessage(messageID string, count int, templateData map[ TemplateData: templateData, }) if err != nil { - return "Translation missing: " + messageID + return "Falta la traducción: " + messageID } return localized } diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go index 1843a4c..98b1df3 100644 --- a/internal/i18n/i18n_test.go +++ b/internal/i18n/i18n_test.go @@ -225,7 +225,7 @@ func TestGetMessage(t *testing.T) { result := trans.GetMessage("NonExistent", 1, nil) // assert - expected := "Translation missing: NonExistent" + expected := "Falta la traducción: NonExistent" if result != expected { t.Errorf("GetMessage() = %v, quiere %v", result, expected) } diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index 4e1236b..35f93ae 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -759,3 +759,155 @@ create_backup = "Error creating backup: {{.Error}}" install_binary = "Error installing new binary: {{.Error}}" chmod = "Error setting permissions: {{.Error}}" +# Issue Generator Command +[issue] +command_usage = "Manage and generate issues" +generate_usage = "Generate a new issue from code changes or description" +flag_from_diff = "Generate issue from current git diff" +flag_description = "Manual description for the issue" +flag_hint = "Additional hint to guide AI generation" +flag_no_labels = "Skip automatic label inference" +flag_dry_run = "Preview only, don't create the issue" +banner = "Issue Generator" +analyzing = "Analyzing changes and generating content..." +creating = "Creating issue..." +preview_title = "Issue Preview" +preview_title_label = "Title" +preview_description_label = "Description" +preview_labels_label = "Labels" +confirm_prompt = "Create this issue?" +created_successfully = "✅ Issue #{{.Number}} created successfully: {{.URL}}" +cancelled = "Issue creation cancelled" +dry_run_complete = "Dry run complete. No issue was created." +error_no_input = "You must specify either --from-diff or --description" +error_conflicting_flags = "Cannot use --from-diff and --description together" +error_generating = "Error generating issue content" +error_creating = "Error creating issue" +flag_assign_me = "Assign the issue to yourself" +flag_checkout = "Create and checkout to a new branch after creating the issue" +getting_user = "Getting authenticated user..." +will_assign = "Will assign to: {{.User}}" +warn_assignee_failed = "Could not assign issue automatically" +creating_branch = "Creating branch: {{.Branch}}" +branch_created = "✅ Switched to branch: {{.Branch}}" +warn_checkout_failed = "Could not create branch automatically" +flag_from_pr = "Generate issue from an existing Pull Request" +analyzing_pr = "Analyzing Pull Request #{{.Number}}..." +error_multiple_sources = "You can only specify one of: --from-diff, --from-pr, or --description" +link_success = "✅ Link successful: PR #{{.PR}} updated to close #{{.Issue}}" +link_error = "Could not link issue to PR #{{.PR}} automatically: {{.Error}}" +# Template Management +flag_template = "Use a specific issue template" +template_usage = "Manage issue templates" +template_init_usage = "Initialize default issue templates" +template_init_banner = "Template Initialization" +template_init_info = "Creating default issue templates in .github/ISSUE_TEMPLATE/" +template_init_success = "✅ Templates created successfully in: {{.Dir}}" +template_init_error = "Error creating templates" +template_force_flag = "Overwrite existing templates" +template_list_usage = "List available issue templates" +template_list_banner = "Available Issue Templates" +template_list_empty = "No templates found" +template_list_hint = "Run 'matecommit issue template init' to create default templates" +template_list_usage_hint = "Use --template with the generate command" +template_list_error = "Error listing templates" + + +[issue_generator] +ai_not_configured = "AI provider is not configured. Please run 'matecommit config init' first." +error_getting_diff = "Error getting git diff" +no_changes_detected = "No changes detected. Make some changes first or use --description instead." +error_getting_files = "Error getting changed files" +error_generating_content = "Error generating content with AI" +description_required = "Description is required when not using --from-diff" +vcs_not_configured = "VCS provider is not configured. Please run 'matecommit config init' first." +error_getting_pr = "Error getting Pull Request" +error_loading_template = "Error loading template" + +[issue_template] +bug_report_name = "Bug Report" +bug_report_about = "Create a report to help us improve" +bug_report_title = "[BUG] " +bug_report_body = """ +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. +""" + +feature_request_name = "Feature Request" +feature_request_about = "Suggest an idea for this project" +feature_request_title = "[FEATURE] " +feature_request_body = """ +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. +""" + +custom_issue_name = "Custom Issue" +custom_issue_about = "Create a custom issue" +custom_issue_title = "[ISSUE] " +custom_issue_body = """ +**Context** +Provide context about this issue. + +**Details** +Add detailed information here. +""" + +# GitHub Issue Forms - Bug Report +bug_report_intro = "Thanks for taking the time to fill out this bug report!" +bug_description_label = "What happened?" +bug_description_help = "A clear and concise description of what the bug is" +bug_description_placeholder = "The application crashes when..." +bug_steps_label = "Steps to reproduce" +bug_steps_help = "How can we reproduce this issue?" +bug_expected_label = "Expected behavior" +bug_expected_placeholder = "What did you expect to happen?" +bug_actual_label = "Actual behavior" +bug_actual_placeholder = "What actually happened?" +bug_version_label = "Version" +bug_additional_label = "Additional context" +bug_additional_help = "Add any other context about the problem (screenshots, logs, etc.)" + +# GitHub Issue Forms - Feature Request +feature_intro = "Thanks for suggesting a new feature!" +feature_problem_label = "Is your feature request related to a problem?" +feature_problem_help = "Describe the problem you're trying to solve" +feature_problem_placeholder = "I'm always frustrated when..." +feature_solution_label = "Describe the solution you'd like" +feature_solution_help = "What would you like to happen?" +feature_solution_placeholder = "I would like to be able to..." +feature_alternatives_label = "Describe alternatives you've considered" +feature_alternatives_help = "Have you considered any alternative solutions?" +feature_additional_label = "Additional context" +feature_additional_help = "Add any other context or screenshots about the feature" + +# GitHub Issue Forms - Custom Issue +custom_intro = "Provide details about your issue" +custom_description_label = "Description" +custom_description_help = "Describe your issue" +custom_description_placeholder = "Please provide details..." +custom_additional_label = "Additional information" +custom_additional_help = "Any other relevant details" diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index 93ddca9..e05ce24 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -788,4 +788,156 @@ get_executable = "Error obteniendo ruta del ejecutable: {{.Error}}" resolve_symlinks = "Error resolviendo symlinks: {{.Error}}" create_backup = "Error creando backup: {{.Error}}" install_binary = "Error instalando nuevo binario: {{.Error}}" -chmod = "Error estableciendo permisos: {{.Error}}" \ No newline at end of file +chmod = "Error estableciendo permisos: {{.Error}}" + +# Comando Generador de Issues +[issue] +command_usage = "Gestionar y generar issues" +generate_usage = "Generar una nueva issue desde cambios de código o descripción" +flag_from_diff = "Generar issue desde el diff actual de git" +flag_description = "Descripción manual para la issue" +flag_hint = "Pista adicional para guiar la generación de la IA" +flag_no_labels = "Omitir inferencia automática de labels" +flag_dry_run = "Solo vista previa, no crear la issue" +banner = "Generador de Issues" +analyzing = "Analizando cambios y generando contenido..." +creating = "Creando issue..." +preview_title = "Vista Previa de la Issue" +preview_title_label = "Título" +preview_description_label = "Descripción" +preview_labels_label = "Labels" +confirm_prompt = "¿Crear esta issue?" +created_successfully = "✅ Issue #{{.Number}} creada exitosamente: {{.URL}}" +cancelled = "Creación de issue cancelada" +dry_run_complete = "Dry run completado. No se creó ninguna issue." +error_no_input = "Debés especificar --from-diff o --description" +error_conflicting_flags = "No podés usar --from-diff y --description juntos" +error_generating = "Error generando contenido de la issue" +error_creating = "Error creando la issue" +flag_assign_me = "Asignarte la issue automáticamente" +flag_checkout = "Crear y cambiar a un nuevo branch después de crear la issue" +getting_user = "Obteniendo usuario autenticado..." +will_assign = "Se asignará a: {{.User}}" +warn_assignee_failed = "No se pudo asignar la issue automáticamente" +creating_branch = "Creando branch: {{.Branch}}" +branch_created = "✅ Cambiado al branch: {{.Branch}}" +warn_checkout_failed = "No se pudo crear el branch automáticamente" +flag_from_pr = "Generar issue desde un Pull Request existente" +analyzing_pr = "Analizando Pull Request #{{.Number}}..." +error_multiple_sources = "Solo podés especificar una opción: --from-diff, --from-pr, o --description" +link_success = "Vinculación exitosa: PR #{{.PR}} actualizado para cerrar #{{.Issue}}" +link_error = "No se pudo vincular la issue al PR #{{.PR}} automáticamente: {{.Error}}" +# Gestión de Templates +flag_template = "Usar un template específico de issue" +template_usage = "Gestionar templates de issues" +template_init_usage = "Inicializar templates de issues por defecto" +template_init_banner = "Inicialización de Templates" +template_init_info = "Creando templates de issues por defecto en .github/ISSUE_TEMPLATE/" +template_init_success = "✅ Templates creados exitosamente en: {{.Dir}}" +template_init_error = "Error creando templates" +template_force_flag = "Sobrescribir templates existentes" +template_list_usage = "Listar templates de issues disponibles" +template_list_banner = "Templates de Issues Disponibles" +template_list_empty = "No se encontraron templates" +template_list_hint = "Ejecutá 'matecommit issue template init' para crear templates por defecto" +template_list_usage_hint = "Usá --template con el comando generate" +template_list_error = "Error listando templates" + +[issue_generator] +ai_not_configured = "El proveedor de IA no está configurado. Por favor ejecutá 'matecommit config init' primero." +error_getting_diff = "Error obteniendo git diff" +no_changes_detected = "No se detectaron cambios. Hacé algunos cambios primero o usá --description." +error_getting_files = "Error obteniendo archivos modificados" +error_generating_content = "Error generando contenido con IA" +description_required = "La descripción es requerida cuando no se usa --from-diff" +vcs_not_configured = "El proveedor VCS no está configurado. Por favor ejecutá 'matecommit config init' primero." +error_getting_pr = "Error obteniendo Pull Request" +error_loading_template = "Error cargando template" + +[issue_template] +bug_report_name = "Reporte de Error" +bug_report_about = "Crea un reporte para ayudarnos a mejorar" +bug_report_title = "[BUG] " +bug_report_body = """ +**Descripción del error** +Una descripción clara y concisa de lo que es el error. + +**Para Reproducir** +Pasos para reproducir el comportamiento: +1. Ir a '...' +2. Hacer clic en '....' +3. Ver el error + +**Comportamiento esperado** +Una descripción clara y concisa de lo que esperabas que sucediera. + +**Capturas de pantalla** +Si corresponde, agrega capturas de pantalla para ayudar a explicar el problema. + +**Contexto adicional** +Agrega cualquier otro contexto sobre el problema aquí. +""" + +feature_request_name = "Solicitud de Funcionalidad" +feature_request_about = "Sugiere una idea para este proyecto" +feature_request_title = "[FEATURE] " +feature_request_body = """ +**¿Tu solicitud de funcionalidad está relacionada con un problema? Por favor descríbelo.** +Una descripción clara y concisa de cuál es el problema. Ej. Siempre me frustra cuando [...] + +**Describe la solución que te gustaría** +Una descripción clara y concisa de lo que quieres que suceda. + +**Describe las alternativas que has considerado** +Una descripción clara y concisa de cualquier solución o funcionalidad alternativa que hayas considerado. + +**Contexto adicional** +Agrega cualquier otro contexto o capturas de pantalla sobre la solicitud de funcionalidad aquí. +""" + +custom_issue_name = "Issue Personalizada" +custom_issue_about = "Crea una issue personalizada" +custom_issue_title = "[ISSUE] " +custom_issue_body = """ +**Contexto** +Proporciona contexto sobre esta issue. + +**Detalles** +Agrega información detallada aquí. +""" + +# GitHub Issue Forms - Reporte de Error +bug_report_intro = "Gracias por reportar este problema, tu feedback ayuda a mejorar el proyecto." +bug_description_label = "¿Qué pasó?" +bug_description_help = "Contame con tus palabras qué problema encontraste" +bug_description_placeholder = "Cuando intento guardar los cambios, la aplicación deja de responder..." +bug_steps_label = "¿Cómo lo reproduzco?" +bug_steps_help = "Detallá los pasos para que pueda reproducir el error" +bug_expected_label = "¿Qué esperabas que pasara?" +bug_expected_placeholder = "Esperaba que se guardaran los cambios sin problemas" +bug_actual_label = "¿Qué pasó en realidad?" +bug_actual_placeholder = "La aplicación se quedó trabada y tuve que cerrarla" +bug_version_label = "Versión" +bug_additional_label = "Información extra" +bug_additional_help = "Screenshots, logs, o cualquier detalle que pueda ayudar a entender el problema" + +# GitHub Issue Forms - Solicitud de Funcionalidad +feature_intro = "Gracias por contribuir con ideas para mejorar el proyecto." +feature_problem_label = "¿Qué problema querés resolver?" +feature_problem_help = "Contame qué necesidad o dificultad tenés actualmente" +feature_problem_placeholder = "Me cuesta mucho hacer X porque no hay forma de Y..." +feature_solution_label = "¿Qué solución proponés?" +feature_solution_help = "Describí cómo te imaginás que debería funcionar" +feature_solution_placeholder = "Estaría bueno poder hacer X desde Y para que sea más rápido..." +feature_alternatives_label = "¿Pensaste en otras opciones?" +feature_alternatives_help = "Si consideraste alternativas, contame cuáles" +feature_additional_label = "Contexto adicional" +feature_additional_help = "Screenshots, ejemplos, o links que puedan ayudar a entender la propuesta" + +# GitHub Issue Forms - Issue Personalizada +custom_intro = "Describí el tema que querés reportar o discutir." +custom_description_label = "Descripción" +custom_description_help = "Contame de qué se trata" +custom_description_placeholder = "Quisiera proponer/reportar/discutir..." +custom_additional_label = "Información adicional" +custom_additional_help = "Cualquier detalle extra que sea relevante" \ No newline at end of file diff --git a/internal/infrastructure/ai/gemini/commit_summarizer_service.go b/internal/infrastructure/ai/gemini/commit_summarizer_service.go index bdba774..81693a7 100644 --- a/internal/infrastructure/ai/gemini/commit_summarizer_service.go +++ b/internal/infrastructure/ai/gemini/commit_summarizer_service.go @@ -14,7 +14,7 @@ import ( "google.golang.org/genai" ) -type GeminiService struct { +type GeminiCommitSummarizer struct { client *genai.Client config *config.Config trans *i18n.Translations @@ -42,7 +42,7 @@ type ( } ) -func NewGeminiService(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (*GeminiService, error) { +func NewGeminiCommitSummarizer(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (*GeminiCommitSummarizer, error) { providerCfg, exists := cfg.AIProviders["gemini"] if !exists || providerCfg.APIKey == "" { msg := trans.GetMessage("error_missing_api_key", 0, map[string]interface{}{"Provider": "gemini"}) @@ -60,14 +60,14 @@ func NewGeminiService(ctx context.Context, cfg *config.Config, trans *i18n.Trans return nil, fmt.Errorf("%s", msg) } - return &GeminiService{ + return &GeminiCommitSummarizer{ client: client, config: cfg, trans: trans, }, nil } -func (s *GeminiService) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) { +func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) { if count <= 0 { msg := s.trans.GetMessage("error_invalid_suggestion_count", 0, nil) return nil, fmt.Errorf("%s", msg) @@ -125,7 +125,7 @@ func (s *GeminiService) GenerateSuggestions(ctx context.Context, info models.Com return suggestions, nil } -func (s *GeminiService) parseSuggestionsJSON(resp *genai.GenerateContentResponse) ([]models.CommitSuggestion, error) { +func (s *GeminiCommitSummarizer) parseSuggestionsJSON(resp *genai.GenerateContentResponse) ([]models.CommitSuggestion, error) { if resp == nil || len(resp.Candidates) == 0 { return nil, fmt.Errorf("respuesta vacía de la IA") } @@ -176,7 +176,7 @@ func (s *GeminiService) parseSuggestionsJSON(resp *genai.GenerateContentResponse return suggestions, nil } -func (s *GeminiService) generatePrompt(locale string, info models.CommitInfo, count int) string { +func (s *GeminiCommitSummarizer) generatePrompt(locale string, info models.CommitInfo, count int) string { promptTemplate := ai.GetCommitPromptTemplate(locale, info.TicketInfo != nil && info.TicketInfo.TicketTitle != "") @@ -286,7 +286,7 @@ func formatResponse(resp *genai.GenerateContentResponse) string { } // ensureIssueReference asegura que todas las sugerencias incluyan la referencia al issue correcta -func (s *GeminiService) ensureIssueReference(suggestions []models.CommitSuggestion, issueNumber int) []models.CommitSuggestion { +func (s *GeminiCommitSummarizer) ensureIssueReference(suggestions []models.CommitSuggestion, issueNumber int) []models.CommitSuggestion { issuePattern := regexp.MustCompile(`\(#\d+\)`) for i := range suggestions { diff --git a/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go b/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go index daa4f07..1f9ff37 100644 --- a/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go +++ b/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go @@ -41,8 +41,8 @@ const ( ]` ) -func TestGeminiService(t *testing.T) { - t.Run("NewGeminiService with empty API key", func(t *testing.T) { +func TestGeminiCommitSummarizer(t *testing.T) { + t.Run("NewGeminiCommitSummarizer with empty API key", func(t *testing.T) { // arrange ctx := context.Background() cfg := &config.Config{ @@ -53,7 +53,7 @@ func TestGeminiService(t *testing.T) { assert.NoError(t, err) // act - service, err := NewGeminiService(ctx, cfg, trans) + service, err := NewGeminiCommitSummarizer(ctx, cfg, trans) // assert if service != nil { @@ -76,7 +76,7 @@ func TestGeminiService(t *testing.T) { if err != nil { t.Fatalf("Error al crear el traductor: %v", err) } - service, err := NewGeminiService(ctx, cfg, trans) + service, err := NewGeminiCommitSummarizer(ctx, cfg, trans) if err != nil { t.Fatalf("Error creando servicio: %v", err) } @@ -110,7 +110,7 @@ func TestGeminiService(t *testing.T) { if err != nil { t.Fatalf("Error creando traductor: %v", err) } - service, err := NewGeminiService(ctx, cfg, trans) + service, err := NewGeminiCommitSummarizer(ctx, cfg, trans) if err != nil { t.Fatalf("Error creando servicio: %v", err) } @@ -141,7 +141,7 @@ func TestGeminiService(t *testing.T) { trans, err := i18n.NewTranslations("es", "../../../i18n/locales/") assert.NoError(t, err) - service, err := NewGeminiService(ctx, cfg, trans) + service, err := NewGeminiCommitSummarizer(ctx, cfg, trans) assert.NoError(t, err) resp := &genai.GenerateContentResponse{ @@ -215,7 +215,7 @@ func TestGeminiService(t *testing.T) { trans, err := i18n.NewTranslations("es", "../../../i18n/locales/") assert.NoError(t, err) - service, err := NewGeminiService(ctx, cfg, trans) + service, err := NewGeminiCommitSummarizer(ctx, cfg, trans) assert.NoError(t, err) info := models.CommitInfo{ @@ -246,7 +246,7 @@ func TestGeminiService(t *testing.T) { trans, err := i18n.NewTranslations("es", "../../../i18n/locales/") assert.NoError(t, err) - service, err := NewGeminiService(ctx, cfg, trans) + service, err := NewGeminiCommitSummarizer(ctx, cfg, trans) assert.NoError(t, err) info := models.CommitInfo{ @@ -277,7 +277,7 @@ func TestGeminiService(t *testing.T) { trans, err := i18n.NewTranslations("es", "../../../i18n/locales/") assert.NoError(t, err) - service, err := NewGeminiService(ctx, cfg, trans) + service, err := NewGeminiCommitSummarizer(ctx, cfg, trans) assert.NoError(t, err) info := models.CommitInfo{ @@ -299,7 +299,7 @@ func TestGeminiService(t *testing.T) { t.Run("parseSuggestionsJSON with nil response", func(t *testing.T) { // arrange - service := &GeminiService{} + service := &GeminiCommitSummarizer{} resp := (*genai.GenerateContentResponse)(nil) // act @@ -313,7 +313,7 @@ func TestGeminiService(t *testing.T) { t.Run("parseSuggestionsJSON with empty candidates", func(t *testing.T) { // arrange - service := &GeminiService{} + service := &GeminiCommitSummarizer{} resp := &genai.GenerateContentResponse{ Candidates: []*genai.Candidate{}, } @@ -334,7 +334,7 @@ func TestGeminiService(t *testing.T) { AIProviders: map[string]config.AIProviderConfig{"gemini": {APIKey: "test-api-key", Model: "gemini-2.5-flash", Temperature: 0.3, MaxTokens: 10000}}, } trans, _ := i18n.NewTranslations("es", "../../../i18n/locales/") - service, _ := NewGeminiService(ctx, cfg, trans) + service, _ := NewGeminiCommitSummarizer(ctx, cfg, trans) resp := &genai.GenerateContentResponse{ Candidates: []*genai.Candidate{ @@ -356,7 +356,7 @@ func TestGeminiService(t *testing.T) { ctx := context.Background() cfg := &config.Config{AIProviders: map[string]config.AIProviderConfig{"gemini": {APIKey: "test-api-key", Model: "gemini-2.5-flash", Temperature: 0.3, MaxTokens: 10000}}} trans, _ := i18n.NewTranslations("es", "../../../i18n/locales/") - service, _ := NewGeminiService(ctx, cfg, trans) + service, _ := NewGeminiCommitSummarizer(ctx, cfg, trans) testCases := []struct { inputStatus string diff --git a/internal/infrastructure/ai/gemini/gemini_factory.go b/internal/infrastructure/ai/gemini/gemini_factory.go index 008e5c4..14572a3 100644 --- a/internal/infrastructure/ai/gemini/gemini_factory.go +++ b/internal/infrastructure/ai/gemini/gemini_factory.go @@ -23,7 +23,7 @@ func (f *GeminiProviderFactory) CreateCommitSummarizer( cfg *config.Config, trans *i18n.Translations, ) (ports.CommitSummarizer, error) { - return NewGeminiService(ctx, cfg, trans) + return NewGeminiCommitSummarizer(ctx, cfg, trans) } // CreatePRSummarizer crea un servicio Gemini para resumir PRs @@ -35,6 +35,15 @@ func (f *GeminiProviderFactory) CreatePRSummarizer( return NewGeminiPRSummarizer(ctx, cfg, trans) } +// CreateIssueContentGenerator crea un servicio Gemini para generar contenido de issues +func (f *GeminiProviderFactory) CreateIssueContentGenerator( + ctx context.Context, + cfg *config.Config, + trans *i18n.Translations, +) (ports.IssueContentGenerator, error) { + return NewGeminiIssueContentGenerator(ctx, cfg, trans) +} + // ValidateConfig valida la configuración de Gemini func (f *GeminiProviderFactory) ValidateConfig(cfg *config.Config) error { providerCfg, exists := cfg.AIProviders["gemini"] diff --git a/internal/infrastructure/ai/gemini/issue_content_generator.go b/internal/infrastructure/ai/gemini/issue_content_generator.go new file mode 100644 index 0000000..eb19f49 --- /dev/null +++ b/internal/infrastructure/ai/gemini/issue_content_generator.go @@ -0,0 +1,174 @@ +package gemini + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "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/ai" + "google.golang.org/genai" +) + +type GeminiIssueContentGenerator struct { + client *genai.Client + config *config.Config + trans *i18n.Translations +} + +var _ ports.IssueContentGenerator = (*GeminiIssueContentGenerator)(nil) + +func NewGeminiIssueContentGenerator(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (*GeminiIssueContentGenerator, error) { + providerCfg, exists := cfg.AIProviders["gemini"] + if !exists || providerCfg.APIKey == "" { + msg := trans.GetMessage("error_missing_api_key", 0, map[string]interface{}{"Provider": "gemini"}) + return nil, fmt.Errorf("%s", msg) + } + + client, err := genai.NewClient(ctx, &genai.ClientConfig{ + APIKey: providerCfg.APIKey, + Backend: genai.BackendGeminiAPI, + }) + if err != nil { + msg := trans.GetMessage("ai_service.error_ai_client", 0, map[string]interface{}{ + "Error": err, + }) + return nil, fmt.Errorf("%s", msg) + } + + return &GeminiIssueContentGenerator{ + client: client, + config: cfg, + trans: trans, + }, nil +} + +// GenerateIssueContent genera contenido de issue usando Gemini AI. +func (s *GeminiIssueContentGenerator) GenerateIssueContent(ctx context.Context, request models.IssueGenerationRequest) (*models.IssueGenerationResult, error) { + prompt := s.buildIssuePrompt(request) + modelName := string(s.config.AIConfig.Models[config.AIGemini]) + + genConfig := &genai.GenerateContentConfig{ + Temperature: float32Ptr(0.3), + MaxOutputTokens: int32(10000), + ResponseMIMEType: "application/json", + MediaResolution: genai.MediaResolutionHigh, + } + + resp, err := s.client.Models.GenerateContent(ctx, modelName, genai.Text(prompt), genConfig) + if err != nil { + return nil, fmt.Errorf("error al generar contenido de la issue: %w", err) + } + + if len(resp.Candidates) == 0 { + return nil, fmt.Errorf("ningún contenido generado por IA") + } + + result, err := s.parseIssueResponse(resp) + if err != nil { + return nil, fmt.Errorf("error al parsear la respuesta de la IA: %w", err) + } + + result.Usage = extractUsage(resp) + + return result, nil +} + +// buildIssuePrompt construye el prompt para generar contenido de issue. +func (s *GeminiIssueContentGenerator) buildIssuePrompt(request models.IssueGenerationRequest) string { + var sb strings.Builder + + if request.Description != "" { + sb.WriteString(fmt.Sprintf("Global Description: %s\n\n", request.Description)) + } + + if request.Diff != "" { + sb.WriteString("Code Changes (git diff):\n\n") + sb.WriteString("```diff\n") + diff := request.Diff + sb.WriteString(diff) + sb.WriteString("\n```\n\n") + + if len(request.ChangedFiles) > 0 { + sb.WriteString("Changed files:\n") + for _, file := range request.ChangedFiles { + sb.WriteString(fmt.Sprintf("- %s\n", file)) + } + sb.WriteString("\n") + } + } + + if request.Hint != "" { + sb.WriteString(fmt.Sprintf("User Hint: %s\n\n", request.Hint)) + } + + template := ai.GetIssuePromptTemplate(request.Language) + return fmt.Sprintf(template, sb.String()) +} + +// parseIssueResponse parsea la respuesta JSON de Gemini. +func (s *GeminiIssueContentGenerator) parseIssueResponse(resp *genai.GenerateContentResponse) (*models.IssueGenerationResult, error) { + if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { + return nil, fmt.Errorf("empty response from AI") + } + + content := formatResponse(resp) + + var jsonResult struct { + Title string `json:"title"` + Description string `json:"description"` + Labels []string `json:"labels"` + } + + if err := json.Unmarshal([]byte(content), &jsonResult); err != nil { + return &models.IssueGenerationResult{ + Title: "Generated Issue", + Description: content, + Labels: []string{}, + }, nil + } + + result := &models.IssueGenerationResult{ + Title: strings.TrimSpace(jsonResult.Title), + Description: strings.TrimSpace(jsonResult.Description), + Labels: s.cleanLabels(jsonResult.Labels), + } + + if result.Title == "" { + result.Title = "Generated Issue" + } + if result.Description == "" { + result.Description = content + } + + return result, nil +} + +// cleanLabels limpia y valida las labels, mantiene solo las permitidas. +func (s *GeminiIssueContentGenerator) cleanLabels(labels []string) []string { + allowedLabels := map[string]bool{ + "feature": true, + "fix": true, + "refactor": true, + "docs": true, + "test": true, + "infra": true, + } + + cleaned := make([]string, 0) + seen := make(map[string]bool) + + for _, label := range labels { + trimmed := strings.TrimSpace(strings.ToLower(label)) + if trimmed != "" && allowedLabels[trimmed] && !seen[trimmed] { + cleaned = append(cleaned, trimmed) + seen[trimmed] = true + } + } + + return cleaned +} diff --git a/internal/infrastructure/ai/gemini/issue_content_generator_test.go b/internal/infrastructure/ai/gemini/issue_content_generator_test.go new file mode 100644 index 0000000..ca57a78 --- /dev/null +++ b/internal/infrastructure/ai/gemini/issue_content_generator_test.go @@ -0,0 +1,176 @@ +package gemini + +import ( + "context" + "testing" + + "github.com/Tomas-vilte/MateCommit/internal/config" + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/genai" +) + +func TestNewGeminiIssueContentGenerator(t *testing.T) { + trans, err := i18n.NewTranslations("en", "../../../i18n/locales/") + require.NoError(t, err) + + t.Run("should return error if API key is missing", func(t *testing.T) { + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{}, + } + gen, err := NewGeminiIssueContentGenerator(context.Background(), cfg, trans) + assert.Error(t, err) + assert.Nil(t, gen) + assert.Contains(t, err.Error(), "API key is not configured") + }) + + t.Run("should create generator if API key is present", func(t *testing.T) { + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{ + "gemini": {APIKey: "fake-key"}, + }, + } + gen, err := NewGeminiIssueContentGenerator(context.Background(), cfg, trans) + assert.NoError(t, err) + assert.NotNil(t, gen) + }) +} + +func TestBuildIssuePrompt(t *testing.T) { + trans, _ := i18n.NewTranslations("en", "../../../i18n/locales/") + cfg := &config.Config{} + gen := &GeminiIssueContentGenerator{ + config: cfg, + trans: trans, + } + + tests := []struct { + name string + request models.IssueGenerationRequest + contains []string + }{ + { + name: "from diff only", + request: models.IssueGenerationRequest{ + Diff: "test diff", + Language: "en", + }, + contains: []string{"Code Changes (git diff)", "test diff"}, + }, + { + name: "from description only", + request: models.IssueGenerationRequest{ + Description: "user description", + Language: "en", + }, + contains: []string{"Global Description: user description"}, + }, + { + name: "full request", + request: models.IssueGenerationRequest{ + Diff: "test diff", + Description: "user description", + Hint: "special hint", + Language: "es", + }, + contains: []string{"Code Changes (git diff)", "user description", "special hint"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prompt := gen.buildIssuePrompt(tt.request) + for _, c := range tt.contains { + assert.Contains(t, prompt, c) + } + }) + } +} + +func TestParseIssueResponse(t *testing.T) { + gen := &GeminiIssueContentGenerator{} + + t.Run("valid JSON response", func(t *testing.T) { + resp := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + { + Content: &genai.Content{ + Parts: []*genai.Part{ + {Text: `{"title": "Bug Fix", "description": "Fixed a bug", "labels": ["fix", "test"]}`}, + }, + }, + }, + }, + } + + result, err := gen.parseIssueResponse(resp) + assert.NoError(t, err) + assert.Equal(t, "Bug Fix", result.Title) + assert.Equal(t, "Fixed a bug", result.Description) + assert.ElementsMatch(t, []string{"fix", "test"}, result.Labels) + }) + + t.Run("invalid JSON response - fallback", func(t *testing.T) { + resp := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + { + Content: &genai.Content{ + Parts: []*genai.Part{ + {Text: "This is not JSON but raw text"}, + }, + }, + }, + }, + } + + result, err := gen.parseIssueResponse(resp) + assert.NoError(t, err) + assert.Equal(t, "Generated Issue", result.Title) + assert.Equal(t, "This is not JSON but raw text", result.Description) + }) + + t.Run("empty response", func(t *testing.T) { + resp := &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{}, + } + + result, err := gen.parseIssueResponse(resp) + assert.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestCleanLabels(t *testing.T) { + gen := &GeminiIssueContentGenerator{} + + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "only allowed labels", + input: []string{"fix", "feature", "bug", "invalid"}, + expected: []string{"fix", "feature"}, + }, + { + name: "mixed case and spaces", + input: []string{" Fix ", "FEATURE", "test"}, + expected: []string{"fix", "feature", "test"}, + }, + { + name: "duplicates", + input: []string{"fix", "fix", "FIX"}, + expected: []string{"fix"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := gen.cleanLabels(tt.input) + assert.ElementsMatch(t, tt.expected, result) + }) + } +} diff --git a/internal/infrastructure/ai/prompts.go b/internal/infrastructure/ai/prompts.go index aed26db..0410dd8 100644 --- a/internal/infrastructure/ai/prompts.go +++ b/internal/infrastructure/ai/prompts.go @@ -664,3 +664,77 @@ func GetReleaseNotesSectionHeaders(locale string) map[string]string { } return releaseHeadersEN } + +const ( + issuePromptTemplateEN = `# Task + Act as a Senior Tech Lead and generate a high-quality GitHub issue based on the provided inputs. + + # Inputs + %s + + # Golden Rules (Constraints) + 1. **Active Voice:** Write in FIRST PERSON ("I implemented", "I added", "We refactored"). Avoid passive voice like "It was implemented". + 2. **Context First:** Explain the WHY before the WHAT. + 3. **Accurate Categorization:** Always choose at least one primary category: 'feature', 'fix', or 'refactor'. Use 'fix' ONLY for bug corrections. Use 'refactor' for code improvements without logic changes. Use 'feature' for new functionality. + 4. **No Emojis:** Do not use emojis in the title or description. Keep it purely textual and professional. + 5. **Balanced Labeling:** Aim for 2-4 relevant labels. Ensure you include the primary category plus any relevant file-based labels like 'test', 'docs', or 'infra' if applicable. + 6. **Format:** Raw JSON only. Do not wrap in markdown blocks. + + # Description Structure + The 'description' field must follow this Markdown structure: + - ### Context (Motivation) + - ### Technical Details (Architectural changes, new models, etc.) + - ### Impact (Benefits) + + # Output Format + Respond with ONLY valid JSON (no markdown): + { + "title": "Concise and descriptive title", + "description": "Markdown body following the structure above", + "labels": ["label1", "label2"] + } + + Generate the issue now.` + + issuePromptTemplateES = `# Tarea + Actuá como un Tech Lead y generá un issue de GitHub profesional basado en los inputs. + + # Entradas (Inputs) + %s + + # Reglas de Oro (Constraints) + 1. **Voz Activa:** Escribí en PRIMERA PERSONA ("Implementé", "Agregué", "Corregí"). Prohibido usar voz pasiva robótica. + 2. **Contexto Real:** Explicá el POR QUÉ del cambio, no solo qué líneas tocaste. + 3. **Categorización Precisa:** Elegí siempre al menos una categoría principal: 'feature', 'fix', o 'refactor'. Solo usá 'fix' si ves una corrección de un bug. Usá 'refactor' para mejoras de código sin cambios lógicos. Usá 'feature' para funcionalidades nuevas. + 4. **Cero Emojis:** No uses emojis ni en el título ni en el cuerpo del issue. Mantené un estilo sobrio y técnico. + 5. **Etiquetado Equilibrado:** Buscá entre 2 y 4 etiquetas relevantes. Asegurate de incluir la categoría principal más cualquier etiqueta de tipo de archivo como 'test', 'docs', o 'infra' si corresponde. + 6. **Formato:** JSON crudo. No incluyas bloques de markdown (como ` + "```json" + `). + + # Estructura de la Descripción + El campo "description" tiene que ser Markdown y seguir esta estructura estricta: + - ### Contexto (¿Cuál es la motivación o el dolor que resuelve esto?) + - ### Detalles Técnicos (Lista de cambios importantes, modelos nuevos, refactors) + - ### Impacto (¿Qué gana el usuario o el desarrollador con esto?) + + # Formato de Salida + IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. + + Responde SOLO con JSON válido (sin markdown): + { + "title": "título descriptivo y con gancho", + "description": "Cuerpo en markdown siguiendo la estructura pedida", + "labels": ["etiqueta1", "etiqueta2"] + } + + Generá el issue ahora.` +) + +// GetIssuePromptTemplate devuelve el template adecuado para generación de issues según el idioma +func GetIssuePromptTemplate(lang string) string { + switch lang { + case "es": + return issuePromptTemplateES + default: + return issuePromptTemplateEN + } +} diff --git a/internal/infrastructure/ai/prompts_test.go b/internal/infrastructure/ai/prompts_test.go index dd3ee34..c3de9b9 100644 --- a/internal/infrastructure/ai/prompts_test.go +++ b/internal/infrastructure/ai/prompts_test.go @@ -145,3 +145,29 @@ func TestGetReleasePromptTemplate(t *testing.T) { assert.Equal(t, releasePromptTemplateEN, result) }) } + +func TestGetIssuePromptTemplate(t *testing.T) { + t.Run("returns English template for 'en'", func(t *testing.T) { + lang := "en" + result := GetIssuePromptTemplate(lang) + assert.Equal(t, issuePromptTemplateEN, result) + }) + + t.Run("returns Spanish template for 'es'", func(t *testing.T) { + lang := "es" + result := GetIssuePromptTemplate(lang) + assert.Equal(t, issuePromptTemplateES, result) + }) + + t.Run("defaults to English for unknown language", func(t *testing.T) { + lang := "fr" + result := GetIssuePromptTemplate(lang) + assert.Equal(t, issuePromptTemplateEN, result) + }) + + t.Run("defaults to English for empty language", func(t *testing.T) { + lang := "" + result := GetIssuePromptTemplate(lang) + assert.Equal(t, issuePromptTemplateEN, result) + }) +} diff --git a/internal/infrastructure/ai/registry/ai_provider_registry.go b/internal/infrastructure/ai/registry/ai_provider_registry.go index 50e7a38..e3632e1 100644 --- a/internal/infrastructure/ai/registry/ai_provider_registry.go +++ b/internal/infrastructure/ai/registry/ai_provider_registry.go @@ -18,6 +18,9 @@ type AIProviderFactory interface { // CreatePRSummarizer crea un servicio para resumir Pull Requests CreatePRSummarizer(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (ports.PRSummarizer, error) + // CreateIssueContentGenerator crea un servicio para generar contenido de issues + CreateIssueContentGenerator(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (ports.IssueContentGenerator, error) + // ValidateConfig valida la configuración para este proveedor ValidateConfig(cfg *config.Config) error diff --git a/internal/infrastructure/ai/registry/ai_provider_registry_test.go b/internal/infrastructure/ai/registry/ai_provider_registry_test.go index f83dd40..8d883a0 100644 --- a/internal/infrastructure/ai/registry/ai_provider_registry_test.go +++ b/internal/infrastructure/ai/registry/ai_provider_registry_test.go @@ -24,6 +24,10 @@ func (m *MockAIProviderFactory) CreatePRSummarizer(_ context.Context, _ *config. return nil, nil } +func (m *MockAIProviderFactory) CreateIssueContentGenerator(_ context.Context, _ *config.Config, _ *i18n.Translations) (ports.IssueContentGenerator, error) { + return nil, nil +} + func (m *MockAIProviderFactory) ValidateConfig(_ *config.Config) error { return nil } diff --git a/internal/infrastructure/dependency/gomod_analyzer_test.go b/internal/infrastructure/dependency/gomod_analyzer_test.go index b816644..d81bf9f 100644 --- a/internal/infrastructure/dependency/gomod_analyzer_test.go +++ b/internal/infrastructure/dependency/gomod_analyzer_test.go @@ -104,6 +104,16 @@ func (m *MockVCSClient) UpdateIssueChecklist(ctx context.Context, issueNumber in return args.Error(0) } +func (m *MockVCSClient) CreateIssue(ctx context.Context, title string, body string, labels []string, assignees []string) (*models.Issue, error) { + args := m.Called(ctx, title, body, labels, assignees) + return args.Get(0).(*models.Issue), args.Error(1) +} + +func (m *MockVCSClient) GetAuthenticatedUser(ctx context.Context) (string, error) { + args := m.Called(ctx) + return args.String(0), args.Error(1) +} + func TestGoModAnalyzer_Name(t *testing.T) { analyzer := NewGoModAnalyzer() assert.Equal(t, "go.mod", analyzer.Name()) diff --git a/internal/infrastructure/di/container.go b/internal/infrastructure/di/container.go index 8c9fdc6..b0cdd05 100644 --- a/internal/infrastructure/di/container.go +++ b/internal/infrastructure/di/container.go @@ -13,20 +13,19 @@ import ( "github.com/Tomas-vilte/MateCommit/internal/services" ) -// Container gestiona las dependencias de la aplicación type Container struct { config *config.Config translations *i18n.Translations - // Registries aiRegistry *registry.AIProviderRegistry vcsRegistry *vcsregistry.VCSProviderRegistry ticketRegistry *ticketregistry.TicketProviderRegistry - // Services (lazy initialized) - gitService ports.GitService - commitService ports.CommitService - prService ports.PRService + gitService ports.GitService + commitService ports.CommitService + prService ports.PRService + issueTemplateService ports.IssueTemplateService + issueGeneratorService ports.IssueGeneratorService } // NewContainer crea un nuevo contenedor de dependencias @@ -160,3 +159,53 @@ func (c *Container) GetConfig() *config.Config { func (c *Container) GetTranslations() *i18n.Translations { return c.translations } + +// GetIssueTemplateService retorna el servicio de templates de issues (lazy initialization) +func (c *Container) GetIssueTemplateService() ports.IssueTemplateService { + if c.issueTemplateService != nil { + return c.issueTemplateService + } + + c.issueTemplateService = services.NewIssueTemplateService(c.config, c.translations) + return c.issueTemplateService +} + +// GetIssueGeneratorService retorna el servicio de generación de issues (lazy initialization) +func (c *Container) GetIssueGeneratorService(ctx context.Context) (ports.IssueGeneratorService, error) { + if c.issueGeneratorService != nil { + return c.issueGeneratorService, nil + } + + if c.gitService == nil { + return nil, fmt.Errorf("servicio git no creado") + } + + templateService := c.GetIssueTemplateService() + + var aiGenerator ports.IssueContentGenerator + if c.config.AIConfig.ActiveAI != "" { + aiFactory, err := c.aiRegistry.Get(string(c.config.AIConfig.ActiveAI)) + if err == nil { + aiGenerator, err = aiFactory.CreateIssueContentGenerator(ctx, c.config, c.translations) + if err != nil { + aiGenerator = nil + } + } + } + + vcsClient, err := c.vcsRegistry.CreateClientFromConfig(ctx, c.gitService, c.config, c.translations) + if err != nil { + return nil, fmt.Errorf("error al crear cliente VCS: %w", err) + } + + c.issueGeneratorService = services.NewIssueGeneratorService( + c.gitService, + aiGenerator, + vcsClient, + templateService, + c.config, + c.translations, + ) + + return c.issueGeneratorService, nil +} diff --git a/internal/infrastructure/di/container_test.go b/internal/infrastructure/di/container_test.go index 24f8f12..2577363 100644 --- a/internal/infrastructure/di/container_test.go +++ b/internal/infrastructure/di/container_test.go @@ -10,49 +10,82 @@ import ( "github.com/Tomas-vilte/MateCommit/internal/infrastructure/ai/registry" ticketregistry "github.com/Tomas-vilte/MateCommit/internal/infrastructure/tickets/registry" vcsregistry "github.com/Tomas-vilte/MateCommit/internal/infrastructure/vcs/registry" + "github.com/stretchr/testify/mock" ) -// Mock factories for testing -type mockAIFactory struct{} +type mockAIFactory struct { + mock.Mock +} -func (m *mockAIFactory) CreateCommitSummarizer(_ context.Context, _ *config.Config, _ *i18n.Translations) (ports.CommitSummarizer, error) { - return nil, nil +func (m *mockAIFactory) CreateCommitSummarizer(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (ports.CommitSummarizer, error) { + args := m.Called(ctx, cfg, trans) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(ports.CommitSummarizer), args.Error(1) } -func (m *mockAIFactory) CreatePRSummarizer(_ context.Context, _ *config.Config, _ *i18n.Translations) (ports.PRSummarizer, error) { - return nil, nil +func (m *mockAIFactory) CreatePRSummarizer(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (ports.PRSummarizer, error) { + args := m.Called(ctx, cfg, trans) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(ports.PRSummarizer), args.Error(1) } -func (m *mockAIFactory) ValidateConfig(_ *config.Config) error { - return nil +func (m *mockAIFactory) CreateIssueContentGenerator(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (ports.IssueContentGenerator, error) { + args := m.Called(ctx, cfg, trans) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(ports.IssueContentGenerator), args.Error(1) +} + +func (m *mockAIFactory) ValidateConfig(cfg *config.Config) error { + args := m.Called(cfg) + return args.Error(0) } func (m *mockAIFactory) Name() string { return "mock" } -type mockVCSFactory struct{} +type mockVCSFactory struct { + mock.Mock +} -func (m *mockVCSFactory) CreateClient(_ context.Context, _, _, _ string, _ *i18n.Translations) (ports.VCSClient, error) { - return nil, nil +func (m *mockVCSFactory) CreateClient(ctx context.Context, owner, repo, token string, trans *i18n.Translations) (ports.VCSClient, error) { + args := m.Called(ctx, owner, repo, token, trans) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(ports.VCSClient), args.Error(1) } -func (m *mockVCSFactory) ValidateConfig(_ *config.VCSConfig) error { - return nil +func (m *mockVCSFactory) ValidateConfig(cfg *config.VCSConfig) error { + args := m.Called(cfg) + return args.Error(0) } func (m *mockVCSFactory) Name() string { return "mock" } -type mockTicketFactory struct{} +type mockTicketFactory struct { + mock.Mock +} -func (m *mockTicketFactory) CreateClient(_ context.Context, _ config.TicketProviderConfig, _ *i18n.Translations) (ports.TickerManager, error) { - return nil, nil +func (m *mockTicketFactory) CreateClient(ctx context.Context, cfg config.TicketProviderConfig, trans *i18n.Translations) (ports.TickerManager, error) { + args := m.Called(ctx, cfg, trans) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(ports.TickerManager), args.Error(1) } -func (m *mockTicketFactory) ValidateConfig(_ config.TicketProviderConfig) error { - return nil +func (m *mockTicketFactory) ValidateConfig(cfg config.TicketProviderConfig) error { + args := m.Called(cfg) + return args.Error(0) } func (m *mockTicketFactory) Name() string { @@ -104,7 +137,6 @@ func TestRegisterAIProvider(t *testing.T) { t.Fatalf("Failed to register AI provider: %v", err) } - // Try to register the same provider again (should fail) err = container.RegisterAIProvider("mock", mockFactory) if err == nil { t.Error("Should not allow registering the same provider twice") @@ -123,7 +155,6 @@ func TestRegisterVCSProvider(t *testing.T) { t.Fatalf("Failed to register VCS provider: %v", err) } - // Try to register the same provider again (should fail) err = container.RegisterVCSProvider("mock", mockFactory) if err == nil { t.Error("Should not allow registering the same provider twice") @@ -142,7 +173,6 @@ func TestRegisterTicketProvider(t *testing.T) { t.Fatalf("Failed to register Ticket provider: %v", err) } - // Try to register the same provider again (should fail) err = container.RegisterTicketProvider("mock", mockFactory) if err == nil { t.Error("Should not allow registering the same provider twice") @@ -159,7 +189,6 @@ func TestGetAIRegistry(t *testing.T) { t.Error("AI aiRegistry should not be nil") } - // Verify it's the same instance if aiRegistry != container.aiRegistry { t.Error("Returned aiRegistry should be the same as internal aiRegistry") } @@ -175,7 +204,6 @@ func TestGetVCSRegistry(t *testing.T) { t.Error("VCS vcsRegistry should not be nil") } - // Verify it's the same instance if vcsRegistry != container.vcsRegistry { t.Error("Returned vcsRegistry should be the same as internal vcsRegistry") } @@ -191,7 +219,6 @@ func TestGetTicketRegistry(t *testing.T) { t.Error("Ticket ticketRegistry should not be nil") } - // Verify it's the same instance if ticketRegistry != container.ticketRegistry { t.Error("Returned ticketRegistry should be the same as internal ticketRegistry") } @@ -219,7 +246,88 @@ func TestGetTranslations(t *testing.T) { } } -// Verify interface implementations +func TestGetIssueTemplateService(t *testing.T) { + cfg := &config.Config{Language: "en"} + trans := &i18n.Translations{} + container := NewContainer(cfg, trans) + + service := container.GetIssueTemplateService() + if service == nil { + t.Fatal("IssueTemplateService should not be nil") + } + + if service != container.GetIssueTemplateService() { + t.Error("Returned service should be a singleton") + } +} + +func TestGetIssueGeneratorService(t *testing.T) { + t.Run("should fail if GitService is not set", func(t *testing.T) { + cfg := &config.Config{Language: "en"} + trans := &i18n.Translations{} + container := NewContainer(cfg, trans) + + _, err := container.GetIssueGeneratorService(context.Background()) + if err == nil { + t.Error("Expected error because GitService is nil") + } + }) + + t.Run("should create IssueGeneratorService successfully", func(t *testing.T) { + cfg := &config.Config{ + Language: "en", + ActiveVCSProvider: "mock", + AIConfig: config.AIConfig{ + ActiveAI: "mock", + }, + VCSConfigs: make(map[string]config.VCSConfig), + } + cfg.VCSConfigs["mock"] = config.VCSConfig{Provider: "mock"} + + trans, _ := i18n.NewTranslations("en", "") + container := NewContainer(cfg, trans) + + aiFactory := &mockAIFactory{} + vcsFactory := &mockVCSFactory{} + + // Set expectations - Return nil client/generator is fine for basic instantiation test + aiFactory.On("CreateIssueContentGenerator", mock.Anything, cfg, trans).Return(nil, nil) + vcsFactory.On("CreateClient", mock.Anything, "owner", "repo", "", trans).Return(nil, nil) + vcsFactory.On("ValidateConfig", mock.Anything).Return(nil) + + _ = container.RegisterAIProvider("mock", aiFactory) + _ = container.RegisterVCSProvider("mock", vcsFactory) + + mockGit := &mockGitService{} + container.SetGitService(mockGit) + + service, err := container.GetIssueGeneratorService(context.Background()) + if err != nil { + t.Fatalf("Failed to get IssueGeneratorService: %v", err) + } + + if service == nil { + t.Fatal("IssueGeneratorService should not be nil") + } + + secondService, _ := container.GetIssueGeneratorService(context.Background()) + if service != secondService { + t.Error("Returned service should be a singleton") + } + + aiFactory.AssertExpectations(t) + vcsFactory.AssertExpectations(t) + }) +} + +type mockGitService struct { + ports.GitService +} + +func (m *mockGitService) GetRepoInfo(_ context.Context) (string, string, string, error) { + return "owner", "repo", "mock", nil +} + var _ registry.AIProviderFactory = (*mockAIFactory)(nil) var _ vcsregistry.VCSProviderFactory = (*mockVCSFactory)(nil) var _ ticketregistry.TicketProviderFactory = (*mockTicketFactory)(nil) diff --git a/internal/infrastructure/vcs/github/github_service.go b/internal/infrastructure/vcs/github/github_service.go index 635acb2..8082040 100644 --- a/internal/infrastructure/vcs/github/github_service.go +++ b/internal/infrastructure/vcs/github/github_service.go @@ -38,6 +38,7 @@ type IssuesService interface { 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) Edit(ctx context.Context, owner, repo string, number int, issue *github.IssueRequest) (*github.Issue, *github.Response, error) + Create(ctx context.Context, owner, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error) // ← NUEVO } type RepositoriesService interface { @@ -53,11 +54,16 @@ type ReleasesService interface { UploadReleaseAsset(ctx context.Context, owner, repo string, id int64, opt *github.UploadOptions, file *os.File) (*github.ReleaseAsset, *github.Response, error) } +type UsersService interface { + Get(ctx context.Context, user string) (*github.User, *github.Response, error) +} + type GitHubClient struct { prService PullRequestsService issuesService IssuesService repoService RepositoriesService releaseService ReleasesService + usersService UsersService owner string repo string trans *i18n.Translations @@ -92,6 +98,7 @@ func NewGitHubClient(owner, repo, token string, trans *i18n.Translations) *GitHu issuesService: client.Issues, repoService: client.Repositories, releaseService: client.Repositories, + usersService: client.Users, owner: owner, repo: repo, trans: trans, @@ -107,6 +114,7 @@ func NewGitHubClientWithServices( issuesService IssuesService, repoService RepositoriesService, releaseService ReleasesService, + usersService UsersService, owner string, repo string, trans *i18n.Translations, @@ -115,6 +123,7 @@ func NewGitHubClientWithServices( prService: prService, issuesService: issuesService, repoService: repoService, + usersService: usersService, releaseService: releaseService, owner: owner, repo: repo, @@ -197,13 +206,15 @@ func (ghc *GitHubClient) GetPR(ctx context.Context, prNumber int) (models.PRData } return models.PRData{ - ID: prNumber, - Creator: pr.GetUser().GetLogin(), - Commits: prCommits, - Diff: diff, - BranchName: pr.GetHead().GetRef(), - PRDescription: pr.GetBody(), + ID: prNumber, + Title: pr.GetTitle(), + Creator: pr.GetUser().GetLogin(), + Commits: prCommits, + Diff: diff, + BranchName: pr.GetHead().GetRef(), + Description: pr.GetBody(), }, nil + } func (ghc *GitHubClient) AddLabelsToPR(ctx context.Context, prNumber int, labels []string) error { @@ -538,6 +549,51 @@ func (ghc *GitHubClient) GetIssue(ctx context.Context, issueNumber int) (*models }, nil } +func (ghc *GitHubClient) CreateIssue(ctx context.Context, title string, body string, labels []string, assignees []string) (*models.Issue, error) { + issueRequest := &github.IssueRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + Labels: &labels, + Assignees: &assignees, + } + + ghIssue, _, err := ghc.issuesService.Create(ctx, ghc.owner, ghc.repo, issueRequest) + if err != nil { + return nil, fmt.Errorf("error creando issue: %w", err) + } + + issue := &models.Issue{ + ID: int(*ghIssue.ID), + Number: *ghIssue.Number, + Title: *ghIssue.Title, + Description: getStringValue(ghIssue.Body), + State: *ghIssue.State, + Author: *ghIssue.User.Login, + URL: *ghIssue.HTMLURL, + Labels: make([]string, 0), + } + + for _, label := range ghIssue.Labels { + if label.Name != nil { + issue.Labels = append(issue.Labels, label.GetName()) + } + } + return issue, nil +} + +func (ghc *GitHubClient) GetAuthenticatedUser(ctx context.Context) (string, error) { + user, _, err := ghc.usersService.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("error obteniendo usuario autenticado: %w", err) + } + + if user.Login == nil { + return "", fmt.Errorf("usuario autenticado no tiene login") + } + + return *user.Login, nil +} + func extractAcceptanceCriteria(body string) []string { var criteria []string lines := strings.Split(body, "\n") @@ -810,7 +866,7 @@ func (ghc *GitHubClient) uploadBinaries(ctx context.Context, releaseID int64, ve } date := time.Now().Format(time.RFC3339) - builder := ghc.binaryBuilderFactory.NewBuilder( + builderBinary := ghc.binaryBuilderFactory.NewBuilder( ghc.mainPath, "matecommit", version, @@ -821,7 +877,7 @@ func (ghc *GitHubClient) uploadBinaries(ctx context.Context, releaseID int64, ve ) fmt.Println(ghc.trans.GetMessage("build.compiling_binaries", 0, nil)) - archives, err := builder.BuildAndPackageAll(ctx) + archives, err := builderBinary.BuildAndPackageAll(ctx) if err != nil { return fmt.Errorf("%s", ghc.trans.GetMessage("build.errors.build_binaries", 0, map[string]interface{}{"Error": err})) } @@ -854,3 +910,10 @@ func (ghc *GitHubClient) getCommitSHA(ctx context.Context) (string, error) { } return ref.GetSHA(), nil } + +func getStringValue(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/internal/infrastructure/vcs/github/github_service_test.go b/internal/infrastructure/vcs/github/github_service_test.go index 39ea1b7..7888bd3 100644 --- a/internal/infrastructure/vcs/github/github_service_test.go +++ b/internal/infrastructure/vcs/github/github_service_test.go @@ -16,7 +16,7 @@ import ( "github.com/stretchr/testify/require" ) -func newTestClient(pr *MockPRService, issues *MockIssuesService, release *MockReleaseService) *GitHubClient { +func newTestClient(pr *MockPRService, issues *MockIssuesService, release *MockReleaseService, userService *MockUserService) *GitHubClient { trans, _ := i18n.NewTranslations("es", "../../../i18n/locales/") repo := &MockRepoService{} client := NewGitHubClientWithServices( @@ -24,6 +24,7 @@ func newTestClient(pr *MockPRService, issues *MockIssuesService, release *MockRe issues, repo, release, + userService, "test-owner", "test-repo", trans, @@ -37,7 +38,8 @@ func TestGitHubClient_UpdatePR(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prNumber := 123 summary := models.PRSummary{ @@ -70,7 +72,8 @@ func TestGitHubClient_UpdatePR(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) labelsToAdd := []string{"fix"} @@ -101,7 +104,8 @@ func TestGitHubClient_AddLabelsToPR(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prNumber := 123 labels := []string{"feature", "fix"} @@ -130,7 +134,8 @@ func TestGitHubClient_GetPR(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prNumber := 123 expectedUser := "test-user" @@ -164,7 +169,8 @@ func TestGitHubClient_CreateLabel(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) labelName := "new-label" color := "FFFFFF" @@ -197,7 +203,7 @@ func TestLabelExists(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client := newTestClient(nil, nil, nil) + client := newTestClient(nil, nil, nil, nil) result := client.labelExists(tt.existingLabels, tt.target) assert.Equal(t, tt.expected, result) }) @@ -209,7 +215,8 @@ func TestGitHubClient_UpdatePR_ErrorCases(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prNumber := 123 summary := models.PRSummary{Title: "Title", Body: "Body"} @@ -227,7 +234,8 @@ func TestGitHubClient_UpdatePR_ErrorCases(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prNumber := 123 summary := models.PRSummary{Labels: []string{"fix"}} @@ -249,7 +257,8 @@ func TestGitHubClient_UpdatePR_ErrorCases(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prNumber := 123 summary := models.PRSummary{Title: "Title", Body: "Body"} @@ -282,7 +291,8 @@ func TestGitHubClient_AddLabelsToPR_ErrorCases(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockIssues.On("ListLabels", mock.Anything, "test-owner", "test-repo", mock.Anything). Return([]*github.Label{}, &github.Response{}, assert.AnError) @@ -296,7 +306,8 @@ func TestGitHubClient_AddLabelsToPR_ErrorCases(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockIssues.On("ListLabels", mock.Anything, "test-owner", "test-repo", mock.Anything). Return([]*github.Label{}, &github.Response{}, nil) @@ -313,7 +324,8 @@ func TestGitHubClient_AddLabelsToPR_ErrorCases(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockIssues.On("ListLabels", mock.Anything, "test-owner", "test-repo", mock.Anything). Return([]*github.Label{{Name: github.Ptr("fix")}}, &github.Response{}, nil) @@ -332,7 +344,8 @@ func TestGitHubClient_GetPR_ErrorCases(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockPR.On("Get", mock.Anything, "test-owner", "test-repo", 123). Return(&github.PullRequest{}, &github.Response{}, assert.AnError) @@ -345,7 +358,8 @@ func TestGitHubClient_GetPR_ErrorCases(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockPR.On("Get", mock.Anything, "test-owner", "test-repo", 123). Return(&github.PullRequest{}, &github.Response{}, nil) @@ -360,7 +374,8 @@ func TestGitHubClient_GetPR_ErrorCases(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockPR.On("Get", mock.Anything, "test-owner", "test-repo", 123). Return(&github.PullRequest{User: &github.User{Login: github.Ptr("test-user")}}, &github.Response{Response: &http.Response{StatusCode: http.StatusOK}}, nil) @@ -379,7 +394,8 @@ func TestGitHubClient_CreateRelease(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) release := &models.Release{Version: "v1.0.0"} notes := &models.ReleaseNotes{Title: "Release v1.0.0", Changelog: "Changes"} @@ -393,6 +409,7 @@ func TestGitHubClient_CreateRelease(t *testing.T) { mockIssues, repo, mockRelease, + mockUserService, "test-owner", "test-repo", client.trans, @@ -432,7 +449,8 @@ func TestGitHubClient_CreateRelease(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) release := &models.Release{Version: "v1.0.0"} notes := &models.ReleaseNotes{} @@ -450,7 +468,8 @@ func TestGitHubClient_CreateRelease(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) release := &models.Release{Version: "v1.0.0"} notes := &models.ReleaseNotes{} @@ -468,7 +487,8 @@ func TestGitHubClient_CreateRelease(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) release := &models.Release{Version: "v1.0.0"} notes := &models.ReleaseNotes{} @@ -487,7 +507,8 @@ func TestGitHubClient_GetRelease(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) expectedRelease := &github.RepositoryRelease{ TagName: github.Ptr("v1.0.0"), @@ -516,7 +537,8 @@ func TestGitHubClient_GetRelease(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) resp := &github.Response{Response: &http.Response{StatusCode: http.StatusNotFound}} mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", "v1.0.0"). @@ -534,7 +556,8 @@ func TestGitHubClient_GetRelease(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) resp := &github.Response{Response: &http.Response{StatusCode: http.StatusInternalServerError}} mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", "v1.0.0"). @@ -553,7 +576,8 @@ func TestGitHubClient_UpdateRelease(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) existingRelease := &github.RepositoryRelease{ ID: github.Ptr(int64(123)), @@ -576,7 +600,8 @@ func TestGitHubClient_UpdateRelease(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) resp := &github.Response{Response: &http.Response{StatusCode: http.StatusNotFound}} mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", "v1.0.0"). @@ -593,7 +618,8 @@ func TestGitHubClient_UpdateRelease(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) existingRelease := &github.RepositoryRelease{ ID: github.Ptr(int64(123)), @@ -617,7 +643,8 @@ func TestGitHubClient_GetIssue(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) issueNumber := 123 expectedIssue := &github.Issue{ @@ -656,7 +683,8 @@ func TestGitHubClient_GetIssue(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) issueNumber := 123 expectedIssue := &github.Issue{ @@ -683,7 +711,8 @@ func TestGitHubClient_GetIssue(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) issueNumber := 123 @@ -703,7 +732,8 @@ func TestGitHubClient_GetClosedIssuesBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prevTag := "v1.0.0" currTag := "v1.1.0" @@ -742,7 +772,8 @@ func TestGitHubClient_GetClosedIssuesBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prevTag := "v1.0.0" prevReleaseDate := github.Timestamp{Time: time.Now().Add(-24 * time.Hour)} @@ -781,7 +812,8 @@ func TestGitHubClient_GetClosedIssuesBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", "v1.0.0"). Return((*github.RepositoryRelease)(nil), &github.Response{}, assert.AnError) @@ -795,7 +827,8 @@ func TestGitHubClient_GetClosedIssuesBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prevRelease := &github.RepositoryRelease{ CreatedAt: &github.Timestamp{}, @@ -818,7 +851,8 @@ func TestGitHubClient_GetMergedPRsBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prevTag := "v1.0.0" currTag := "v1.1.0" @@ -879,7 +913,8 @@ func TestGitHubClient_GetMergedPRsBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prevTag := "v1.0.0" prevReleaseDate := github.Timestamp{Time: time.Now().Add(-24 * time.Hour)} @@ -916,7 +951,8 @@ func TestGitHubClient_GetMergedPRsBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", "v1.0.0"). Return((*github.RepositoryRelease)(nil), &github.Response{}, assert.AnError) @@ -930,7 +966,8 @@ func TestGitHubClient_GetMergedPRsBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockRelease.On("GetReleaseByTag", mock.Anything, "test-owner", "test-repo", "v1.0.0"). Return(&github.RepositoryRelease{CreatedAt: &github.Timestamp{}}, &github.Response{}, nil) @@ -949,7 +986,8 @@ func TestGitHubClient_GetContributorsBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prevTag := "v1.0.0" currTag := "v1.1.0" @@ -981,7 +1019,8 @@ func TestGitHubClient_GetContributorsBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockRepo := &MockRepoService{} client.repoService = mockRepo @@ -1000,7 +1039,8 @@ func TestGitHubClient_GetFileStatsBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) prevTag := "v1.0.0" currTag := "v1.1.0" @@ -1038,7 +1078,8 @@ func TestGitHubClient_GetFileStatsBetweenTags(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockRepo := &MockRepoService{} client.repoService = mockRepo @@ -1057,7 +1098,8 @@ func TestGitHubClient_GetFileAtTag(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockRepo := &MockRepoService{} client.repoService = mockRepo @@ -1083,7 +1125,8 @@ func TestGitHubClient_GetFileAtTag(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockRepo := &MockRepoService{} client.repoService = mockRepo @@ -1103,7 +1146,8 @@ func TestGitHubClient_GetFileAtTag(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockRepo := &MockRepoService{} client.repoService = mockRepo @@ -1123,7 +1167,8 @@ func TestGitHubClient_GetDiffFromCommits(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockRepo := &MockRepoService{} client.repoService = mockRepo @@ -1170,7 +1215,8 @@ func TestGitHubClient_GetDiffFromCommits(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) mockRepo := &MockRepoService{} client.repoService = mockRepo @@ -1193,7 +1239,8 @@ func TestGitHubClient_GetPRIssues(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) branchName := "feature/123-new-feature" prDescription := "Closes #456" @@ -1247,7 +1294,8 @@ func TestGitHubClient_GetPRIssues(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) branchName := "feature/123-fix" prDescription := "Fixes #123" @@ -1271,7 +1319,8 @@ func TestGitHubClient_GetPRIssues(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) branchName := "" prDescription := "Closes #999" @@ -1291,7 +1340,8 @@ func TestGitHubClient_GetPRIssues(t *testing.T) { mockPR := &MockPRService{} mockIssues := &MockIssuesService{} mockRelease := &MockReleaseService{} - client := newTestClient(mockPR, mockIssues, mockRelease) + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) branchName := "issue/111" commits := []string{"(#222)"} @@ -1306,3 +1356,63 @@ func TestGitHubClient_GetPRIssues(t *testing.T) { assert.Len(t, issues, 3) }) } + +func TestGitHubClient_CreateIssue(t *testing.T) { + t.Run("should create issue successfully", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) + + title := "Bug Title" + body := "Bug body description" + labels := []string{"bug", "critical"} + assignees := []string{"user1"} + + expectedGHIssue := &github.Issue{ + ID: github.Ptr(int64(789)), + Number: github.Ptr(42), + Title: github.Ptr(title), + Body: github.Ptr(body), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + User: &github.User{Login: github.Ptr("test-user")}, + Labels: []*github.Label{ + {Name: github.Ptr("bug")}, + {Name: github.Ptr("critical")}, + }, + } + + mockIssues.On("Create", mock.Anything, "test-owner", "test-repo", mock.MatchedBy(func(req *github.IssueRequest) bool { + return *req.Title == title && *req.Body == body && len(*req.Labels) == 2 && len(*req.Assignees) == 1 + })).Return(expectedGHIssue, &github.Response{}, nil) + + result, err := client.CreateIssue(context.Background(), title, body, labels, assignees) + + assert.NoError(t, err) + assert.Equal(t, 42, result.Number) + assert.Equal(t, title, result.Title) + assert.Equal(t, body, result.Description) + assert.Equal(t, "test-user", result.Author) + assert.ElementsMatch(t, labels, result.Labels) + mockIssues.AssertExpectations(t) + }) + + t.Run("should return error when Create fails", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRelease := &MockReleaseService{} + mockUserService := &MockUserService{} + client := newTestClient(mockPR, mockIssues, mockRelease, mockUserService) + + mockIssues.On("Create", mock.Anything, "test-owner", "test-repo", mock.Anything). + Return((*github.Issue)(nil), &github.Response{}, assert.AnError) + + _, err := client.CreateIssue(context.Background(), "Title", "Body", nil, nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "error creando issue") + mockIssues.AssertExpectations(t) + }) +} diff --git a/internal/infrastructure/vcs/github/mocks.go b/internal/infrastructure/vcs/github/mocks.go index 6b322ea..7268a86 100644 --- a/internal/infrastructure/vcs/github/mocks.go +++ b/internal/infrastructure/vcs/github/mocks.go @@ -76,6 +76,11 @@ func (m *MockIssuesService) Edit(ctx context.Context, owner, repo string, number return args.Get(0).(*github.Issue), args.Get(1).(*github.Response), args.Error(2) } +func (m *MockIssuesService) Create(ctx context.Context, owner, repo string, issue *github.IssueRequest) (*github.Issue, *github.Response, error) { + args := m.Called(ctx, owner, repo, issue) + return args.Get(0).(*github.Issue), args.Get(1).(*github.Response), args.Error(2) +} + type MockRepoService struct { mock.Mock } @@ -142,3 +147,12 @@ func (m *MockBinaryBuilderFactory) NewBuilder(mainPath, binaryName, version, com } return args.Get(0).(ports.BinaryPackager) } + +type MockUserService struct { + mock.Mock +} + +func (m *MockUserService) Get(ctx context.Context, user string) (*github.User, *github.Response, error) { + args := m.Called(ctx, user) + return args.Get(0).(*github.User), args.Get(1).(*github.Response), args.Error(2) +} diff --git a/internal/services/issue_generator_service.go b/internal/services/issue_generator_service.go new file mode 100644 index 0000000..95409dc --- /dev/null +++ b/internal/services/issue_generator_service.go @@ -0,0 +1,403 @@ +package services + +import ( + "context" + "fmt" + "regexp" + "strings" + + "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" +) + +var _ ports.IssueGeneratorService = (*IssueGeneratorService)(nil) + +type IssueGeneratorService struct { + git ports.GitService + ai ports.IssueContentGenerator + vcsClient ports.VCSClient + templateService ports.IssueTemplateService + config *config.Config + trans *i18n.Translations +} + +func NewIssueGeneratorService( + git ports.GitService, + ai ports.IssueContentGenerator, + vcsClient ports.VCSClient, + templateService ports.IssueTemplateService, + cfg *config.Config, + trans *i18n.Translations, +) *IssueGeneratorService { + return &IssueGeneratorService{ + git: git, + ai: ai, + vcsClient: vcsClient, + templateService: templateService, + config: cfg, + trans: trans, + } +} + +// GenerateFromDiff genera contenido de issue basándose en el diff actual de git. +// Analiza los cambios locales (staged y unstaged) para crear un título, descripción y labels apropiados. +func (s *IssueGeneratorService) GenerateFromDiff(ctx context.Context, hint string, skipLabels bool) (*models.IssueGenerationResult, error) { + if s.ai == nil { + return nil, fmt.Errorf("%s", s.trans.GetMessage("issue_generator.ai_not_configured", 0, nil)) + } + + diff, err := s.git.GetDiff(ctx) + if err != nil { + return nil, fmt.Errorf("%s: %w", s.trans.GetMessage("issue_generator.error_getting_diff", 0, nil), err) + } + + if diff == "" { + return nil, fmt.Errorf("%s", s.trans.GetMessage("issue_generator.no_changes_detected", 0, nil)) + } + + changedFilesRaw, err := s.git.GetChangedFiles(ctx) + if err != nil { + return nil, fmt.Errorf("%s: %w", s.trans.GetMessage("issue_generator.error_getting_files", 0, nil), err) + } + + changedFiles := make([]string, 0, len(changedFilesRaw)) + for _, change := range changedFilesRaw { + changedFiles = append(changedFiles, change.Path) + } + + request := models.IssueGenerationRequest{ + Diff: diff, + ChangedFiles: changedFiles, + Hint: hint, + Language: s.config.Language, + } + + result, err := s.ai.GenerateIssueContent(ctx, request) + if err != nil { + return nil, fmt.Errorf("%s: %w", s.trans.GetMessage("issue_generator.error_generating_content", 0, nil), err) + } + + if !skipLabels { + smartLabels := s.inferSmartLabels(diff, changedFiles) + result.Labels = s.mergeLabels(result.Labels, smartLabels) + } + + return result, nil +} + +// GenerateFromDescription genera contenido de issue basándose en una descripción manual. +// Útil cuando el usuario quiere crear una issue sin tener cambios locales. +func (s *IssueGeneratorService) GenerateFromDescription(ctx context.Context, description string, skipLabels bool) (*models.IssueGenerationResult, error) { + if s.ai == nil { + return nil, fmt.Errorf("%s", s.trans.GetMessage("issue_generator.ai_not_configured", 0, nil)) + } + + if description == "" { + return nil, fmt.Errorf("%s", s.trans.GetMessage("issue_generator.description_required", 0, nil)) + } + + request := models.IssueGenerationRequest{ + Description: description, + Language: s.config.Language, + } + + result, err := s.ai.GenerateIssueContent(ctx, request) + if err != nil { + return nil, fmt.Errorf("%s: %w", s.trans.GetMessage("issue_generator.error_generating_content", 0, nil), err) + } + + if skipLabels { + result.Labels = []string{} + } + + return result, nil +} + +func (s *IssueGeneratorService) GenerateFromPR(ctx context.Context, prNumber int, hint string, skipLabels bool) (*models.IssueGenerationResult, error) { + if s.ai == nil { + return nil, fmt.Errorf("%s", s.trans.GetMessage("issue_generator.ai_not_configured", 0, nil)) + } + + if s.vcsClient == nil { + return nil, fmt.Errorf("%s", s.trans.GetMessage("issue_generator.vcs_not_configured", 0, nil)) + } + + prData, err := s.vcsClient.GetPR(ctx, prNumber) + if err != nil { + return nil, fmt.Errorf("%s: %w", s.trans.GetMessage("issue_generator.error_getting_pr", 0, nil), err) + } + + var contextBuilder strings.Builder + contextBuilder.WriteString(fmt.Sprintf("Pull Request #%d: %s\n\n", prNumber, prData.Title)) + + if prData.Description != "" { + contextBuilder.WriteString("PR Description:\n") + contextBuilder.WriteString(prData.Description) + contextBuilder.WriteString("\n\n") + } + + if len(prData.Commits) > 0 { + contextBuilder.WriteString("Commits:\n") + for _, commit := range prData.Commits { + contextBuilder.WriteString(fmt.Sprintf("- %s\n", commit)) + } + contextBuilder.WriteString("\n") + } + + changedFiles := s.extractFilesFromDiff(prData.Diff) + + request := models.IssueGenerationRequest{ + Description: contextBuilder.String(), + Diff: prData.Diff, + ChangedFiles: changedFiles, + Hint: hint, + Language: s.config.Language, + } + + result, err := s.ai.GenerateIssueContent(ctx, request) + if err != nil { + return nil, fmt.Errorf("%s: %w", s.trans.GetMessage("issue_generator.error_generating_content", 0, nil), err) + } + + result.Description = fmt.Sprintf("%s\n\n---\n*Related PR: #%d*", result.Description, prNumber) + + if !skipLabels { + smartLabels := s.inferSmartLabels(prData.Diff, changedFiles) + result.Labels = s.mergeLabels(result.Labels, smartLabels) + } + return result, nil +} + +func (s *IssueGeneratorService) GenerateWithTemplate(ctx context.Context, templateName string, hint string, fromDiff bool, description string, skipLabels bool) (*models.IssueGenerationResult, error) { + template, err := s.templateService.GetTemplateByName(templateName) + if err != nil { + return nil, fmt.Errorf("%s: %w", s.trans.GetMessage("issue_generator.error_loading_template", 0, nil), err) + } + + var baseResult *models.IssueGenerationResult + if fromDiff { + baseResult, err = s.GenerateFromDiff(ctx, hint, skipLabels) + } else if description != "" { + baseResult, err = s.GenerateFromDescription(ctx, description, skipLabels) + } else { + return nil, fmt.Errorf("%s", s.trans.GetMessage("issue.error_no_input", 0, nil)) + } + if err != nil { + return nil, err + } + + result := s.templateService.MergeWithGeneratedContent(template, baseResult) + + if len(template.Assignees) > 0 { + result.Assignees = s.mergeAssignees(result.Assignees, template.Assignees) + } + + return result, nil +} + +func (s *IssueGeneratorService) mergeAssignees(genAssignees, templateAssignees []string) []string { + assigneeMap := make(map[string]bool) + for _, a := range genAssignees { + if a != "" { + assigneeMap[a] = true + } + } + for _, a := range templateAssignees { + if a != "" { + assigneeMap[a] = true + } + } + + result := make([]string, 0, len(assigneeMap)) + for a := range assigneeMap { + result = append(result, a) + } + return result +} + +func (s *IssueGeneratorService) extractFilesFromDiff(diff string) []string { + files := make([]string, 0) + seen := make(map[string]bool) + + lines := strings.Split(diff, "\n") + for _, line := range lines { + var file string + + if strings.HasPrefix(line, "diff --git") { + parts := strings.Fields(line) + if len(parts) >= 3 { + file = strings.TrimPrefix(parts[2], "a/") + } + } else if strings.HasPrefix(line, "--- a/") { + file = strings.TrimPrefix(line, "--- a/") + } else if strings.HasPrefix(line, "+++ b/") { + file = strings.TrimPrefix(line, "+++ b/") + } + + if file != "" && file != "/dev/null" && !seen[file] { + seen[file] = true + files = append(files, file) + } + } + + return files +} + +// CreateIssue crea una nueva issue en el repositorio usando el VCS client configurado. +// Retorna la issue creada con su número y URL asignados. +func (s *IssueGeneratorService) CreateIssue(ctx context.Context, result *models.IssueGenerationResult, assignees []string) (*models.Issue, error) { + return s.vcsClient.CreateIssue(ctx, result.Title, result.Description, result.Labels, assignees) +} + +// GetAuthenticatedUser obtiene el username del usuario autenticado actual. +func (s *IssueGeneratorService) GetAuthenticatedUser(ctx context.Context) (string, error) { + return s.vcsClient.GetAuthenticatedUser(ctx) +} + +func (s *IssueGeneratorService) InferBranchName(issueNumber int, labels []string) string { + prefix := "feature" + + labelPriority := map[string]int{ + "fix": 1, + "refactor": 2, + "docs": 3, + "test": 4, + "infra": 5, + "feature": 6, + } + highestPriority := 999 + for _, label := range labels { + if priority, exists := labelPriority[label]; exists { + if priority < highestPriority { + highestPriority = priority + prefix = label + } + } + } + + return fmt.Sprintf("%s/issue-%d", prefix, issueNumber) +} + +// LinkIssueToPR vincula una issue a un Pull Request agregando "Closes #issueNumber" a la descripción del PR. +func (s *IssueGeneratorService) LinkIssueToPR(ctx context.Context, prNumber int, issueNumber int) error { + if s.vcsClient == nil { + return fmt.Errorf("%s", s.trans.GetMessage("issue_generator.vcs_not_configured", 0, nil)) + } + + prData, err := s.vcsClient.GetPR(ctx, prNumber) + if err != nil { + return fmt.Errorf("%s: %w", s.trans.GetMessage("issue_generator.error_getting_pr", 0, nil), err) + } + + linkText := fmt.Sprintf("\n\nCloses #%d", issueNumber) + newDescription := prData.Description + linkText + + summary := models.PRSummary{ + Title: prData.Title, + Body: newDescription, + } + + if err := s.vcsClient.UpdatePR(ctx, prNumber, summary); err != nil { + return fmt.Errorf("%s: %w", s.trans.GetMessage("error.update_pr", 0, map[string]interface{}{ + "pr_number": prNumber, + }), err) + } + + return nil +} + +func (s *IssueGeneratorService) inferSmartLabels(diff string, changedFiles []string) []string { + analysis := s.analyzeDiff(diff, changedFiles) + labels := make([]string, 0) + + if analysis.HasTestFiles { + labels = append(labels, "test") + } + if analysis.HasDocFiles { + labels = append(labels, "docs") + } + if analysis.HasConfigFiles || strings.Contains(diff, "Dockerfile") || strings.Contains(diff, ".github") { + labels = append(labels, "infra") + } + + return labels +} + +func (s *IssueGeneratorService) analyzeDiff(diff string, changedFiles []string) models.DiffAnalysis { + analysis := models.DiffAnalysis{ + Keywords: make(map[string]bool), + } + + diffLower := strings.ToLower(diff) + + for _, file := range changedFiles { + fileLower := strings.ToLower(file) + if strings.HasSuffix(fileLower, ".go") { + analysis.HasGoFiles = true + } + if strings.Contains(fileLower, "test") || strings.HasSuffix(fileLower, "_test.go") { + analysis.HasTestFiles = true + } + if strings.HasSuffix(fileLower, ".md") || strings.Contains(fileLower, "doc") { + analysis.HasDocFiles = true + } + if strings.Contains(fileLower, "config") || strings.HasSuffix(fileLower, ".yaml") || strings.HasSuffix(fileLower, ".yml") || strings.HasSuffix(fileLower, ".json") { + analysis.HasConfigFiles = true + } + if strings.HasSuffix(fileLower, ".css") || strings.HasSuffix(fileLower, ".html") || strings.HasSuffix(fileLower, ".jsx") || strings.HasSuffix(fileLower, ".tsx") { + analysis.HasUIFiles = true + } + } + + keywords := []string{"fix", "bug", "feat", "feature", "add", "refactor", "test", "doc"} + for _, kw := range keywords { + if strings.Contains(diffLower, kw) { + analysis.Keywords[kw] = true + } + } + + fixPattern := regexp.MustCompile(`(?i)(fix|bug|resolve|close)`) + featPattern := regexp.MustCompile(`(?i)(feat|feature|add|implement)`) + refactorPattern := regexp.MustCompile(`(?i)(refactor|restructure|reorganize)`) + + if fixPattern.MatchString(diff) { + analysis.Keywords["fix"] = true + } + if featPattern.MatchString(diff) { + analysis.Keywords["feat"] = true + } + if refactorPattern.MatchString(diff) { + analysis.Keywords["refactor"] = true + } + + return analysis +} + +func (s *IssueGeneratorService) mergeLabels(aiLabels, smartLabels []string) []string { + labelMap := make(map[string]bool) + + for _, label := range aiLabels { + if label != "" { + labelMap[label] = true + } + } + + for _, label := range smartLabels { + if label != "" { + labelMap[label] = true + } + } + + if len(labelMap) == 0 { + return []string{"feature"} + } + + result := make([]string, 0, len(labelMap)) + for label := range labelMap { + result = append(result, label) + } + + return result +} diff --git a/internal/services/issue_generator_service_test.go b/internal/services/issue_generator_service_test.go new file mode 100644 index 0000000..a0b632b --- /dev/null +++ b/internal/services/issue_generator_service_test.go @@ -0,0 +1,183 @@ +package services + +import ( + "context" + "testing" + + "github.com/Tomas-vilte/MateCommit/internal/config" + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestIssueGeneratorService(t *testing.T) { + ctx := context.Background() + trans := &i18n.Translations{} + cfg := &config.Config{Language: "en"} + + t.Run("GenerateFromDiff - Success", func(t *testing.T) { + mockGit := new(MockGitService) + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(mockGit, mockAI, nil, nil, cfg, trans) + + mockGit.On("GetDiff", ctx).Return("diff content", nil) + mockGit.On("GetChangedFiles", ctx).Return([]models.GitChange{{Path: "main.go"}}, nil) + + expectedRequest := models.IssueGenerationRequest{ + Diff: "diff content", + ChangedFiles: []string{"main.go"}, + Hint: "test hint", + Language: "en", + } + expectedResult := &models.IssueGenerationResult{ + Title: "Test Issue", + Description: "Test Description", + Labels: []string{"feature"}, + } + + mockAI.On("GenerateIssueContent", ctx, expectedRequest).Return(expectedResult, nil) + + result, err := service.GenerateFromDiff(ctx, "test hint", false) + + assert.NoError(t, err) + assert.Equal(t, "Test Issue", result.Title) + assert.Contains(t, result.Labels, "feature") + mockGit.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("GenerateFromDescription - Success", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + service := NewIssueGeneratorService(nil, mockAI, nil, nil, cfg, trans) + + expectedRequest := models.IssueGenerationRequest{ + Description: "manual description", + Language: "en", + } + expectedResult := &models.IssueGenerationResult{ + Title: "Manual Issue", + Description: "Manual Description", + } + + mockAI.On("GenerateIssueContent", ctx, expectedRequest).Return(expectedResult, nil) + + result, err := service.GenerateFromDescription(ctx, "manual description", true) + + assert.NoError(t, err) + assert.Equal(t, "Manual Issue", result.Title) + assert.Empty(t, result.Labels) + mockAI.AssertExpectations(t) + }) + + t.Run("GenerateFromPR - Success", func(t *testing.T) { + mockAI := new(MockIssueContentGenerator) + mockVCS := new(MockVCSClient) + service := NewIssueGeneratorService(nil, mockAI, mockVCS, nil, cfg, trans) + + prData := models.PRData{ + Title: "PR Title", + Description: "PR Description", + Commits: []models.Commit{{Message: "commit 1"}}, + Diff: "diff --git a/file.go b/file.go\n...", + } + mockVCS.On("GetPR", ctx, 1).Return(prData, nil) + + expectedResult := &models.IssueGenerationResult{ + Title: "PR based Issue", + Description: "PR based Description", + Labels: []string{"fix"}, + } + + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(expectedResult, nil) + + result, err := service.GenerateFromPR(ctx, 1, "", false) + + assert.NoError(t, err) + assert.Equal(t, "PR based Issue", result.Title) + assert.Contains(t, result.Description, "Related PR: #1") + mockVCS.AssertExpectations(t) + mockAI.AssertExpectations(t) + }) + + t.Run("GenerateWithTemplate - Success", func(t *testing.T) { + mockGit := new(MockGitService) + mockAI := new(MockIssueContentGenerator) + mockTemplate := new(MockIssueTemplateService) + service := NewIssueGeneratorService(mockGit, mockAI, nil, mockTemplate, cfg, trans) + + template := &models.IssueTemplate{ + Name: "bug_report", + Title: "[BUG] ", + Labels: []string{"bug"}, + Assignees: []string{"tester"}, + } + generated := &models.IssueGenerationResult{ + Title: "something broke", + Description: "it fails", + Labels: []string{"fix"}, + } + merged := &models.IssueGenerationResult{ + Title: "[BUG] something broke", + Description: "it fails\n---\nTemplate body", + Labels: []string{"bug", "fix"}, + Assignees: []string{"tester"}, + } + + mockTemplate.On("GetTemplateByName", "bug_report").Return(template, nil) + mockGit.On("GetDiff", ctx).Return("some changes", nil) + mockGit.On("GetChangedFiles", ctx).Return([]models.GitChange{{Path: "file.go"}}, nil) + mockAI.On("GenerateIssueContent", ctx, mock.Anything).Return(generated, nil) + mockTemplate.On("MergeWithGeneratedContent", template, mock.Anything).Return(merged) + + result, err := service.GenerateWithTemplate(ctx, "bug_report", "", true, "", false) + + assert.NoError(t, err) + assert.Equal(t, "[BUG] something broke", result.Title) + assert.Contains(t, result.Labels, "bug") + assert.Contains(t, result.Assignees, "tester") + }) +} + +func TestInferBranchName(t *testing.T) { + service := &IssueGeneratorService{} + + tests := []struct { + name string + issueNumber int + labels []string + expected string + }{ + { + name: "fix label has priority", + issueNumber: 123, + labels: []string{"feature", "fix", "docs"}, + expected: "fix/issue-123", + }, + { + name: "feature is default", + issueNumber: 456, + labels: []string{"feature"}, + expected: "feature/issue-456", + }, + { + name: "refactor has higher priority than docs", + issueNumber: 789, + labels: []string{"docs", "refactor"}, + expected: "refactor/issue-789", + }, + { + name: "no labels defaults to feature", + issueNumber: 999, + labels: []string{}, + expected: "feature/issue-999", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := service.InferBranchName(tt.issueNumber, tt.labels) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/services/issue_template_service.go b/internal/services/issue_template_service.go new file mode 100644 index 0000000..efa9770 --- /dev/null +++ b/internal/services/issue_template_service.go @@ -0,0 +1,422 @@ +package services + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "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" + "gopkg.in/yaml.v3" +) + +var _ ports.IssueTemplateService = (*IssueTemplateService)(nil) + +type IssueTemplateService struct { + config *config.Config + trans *i18n.Translations +} + +func NewIssueTemplateService(cfg *config.Config, trans *i18n.Translations) *IssueTemplateService { + return &IssueTemplateService{ + config: cfg, + trans: trans, + } +} + +func (s *IssueTemplateService) GetTemplatesDir() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("error obteniendo directorio actual: %w", err) + } + + provider := strings.ToLower(s.config.ActiveVCSProvider) + var templatesDir string + + switch provider { + case "gitlab": + templatesDir = filepath.Join(cwd, ".gitlab", "issue_templates") + case "github": + fallthrough + default: + templatesDir = filepath.Join(cwd, ".github", "ISSUE_TEMPLATE") + } + + return templatesDir, nil +} + +func (s *IssueTemplateService) ListTemplates() ([]models.TemplateMetadata, error) { + templatesDir, err := s.GetTemplatesDir() + if err != nil { + return nil, err + } + + if _, err := os.Stat(templatesDir); os.IsNotExist(err) { + return []models.TemplateMetadata{}, nil + } + + entries, err := os.ReadDir(templatesDir) + if err != nil { + return nil, fmt.Errorf("error leyendo directorio de templates: %w", err) + } + + templates := make([]models.TemplateMetadata, 0) + for _, entry := range entries { + if entry.IsDir() || (!strings.HasSuffix(entry.Name(), ".yml") && !strings.HasSuffix(entry.Name(), ".yaml")) { + continue + } + + filePath := filepath.Join(templatesDir, entry.Name()) + template, err := s.LoadTemplate(filePath) + if err != nil { + continue + } + + templates = append(templates, models.TemplateMetadata{ + Name: template.Name, + About: template.GetAbout(), + FilePath: entry.Name(), + }) + } + + return templates, nil +} + +func (s *IssueTemplateService) LoadTemplate(filePath string) (*models.IssueTemplate, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("error leyendo archivo de template: %w", err) + } + return s.parseTemplate(string(content), filePath) +} + +func (s *IssueTemplateService) parseTemplate(content string, filePath string) (*models.IssueTemplate, error) { + template := &models.IssueTemplate{ + FilePath: filePath, + } + + // Para archivos .yml, parseamos directamente todo el contenido como YAML + if err := yaml.Unmarshal([]byte(content), template); err != nil { + return nil, fmt.Errorf("error parseando template YAML: %w", err) + } + + return template, nil +} + +func (s *IssueTemplateService) GetTemplateByName(name string) (*models.IssueTemplate, error) { + templatesDir, err := s.GetTemplatesDir() + if err != nil { + return nil, err + } + + possiblePaths := []string{ + filepath.Join(templatesDir, name+".yml"), + filepath.Join(templatesDir, name+".yaml"), + filepath.Join(templatesDir, name), + } + + for _, path := range possiblePaths { + if _, err := os.Stat(path); err == nil { + return s.LoadTemplate(path) + } + } + + return nil, fmt.Errorf("template '%s' no encontrado", name) +} + +func (s *IssueTemplateService) InitializeTemplates(force bool) error { + templatesDir, err := s.GetTemplatesDir() + if err != nil { + return err + } + + if err := os.MkdirAll(templatesDir, 0755); err != nil { + return fmt.Errorf("error creando directorio de templates: %w", err) + } + + templates := map[string]string{ + "bug_report.yml": s.buildTemplateContent("bug_report"), + "feature_request.yml": s.buildTemplateContent("feature_request"), + "custom.yml": s.buildTemplateContent("custom"), + } + + created := 0 + skipped := 0 + + for filename, content := range templates { + filePath := filepath.Join(templatesDir, filename) + + if _, err := os.Stat(filePath); err == nil && !force { + skipped++ + continue + } + + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + return fmt.Errorf("error escribiendo template %s: %w", filePath, err) + } + created++ + } + + if created == 0 && skipped > 0 { + return fmt.Errorf("los templates ya existen, usa --force para sobrescribirlas") + } + return nil +} + +func (s *IssueTemplateService) buildTemplateContent(templateType string) string { + switch templateType { + case "bug_report": + return s.buildBugReportTemplate() + case "feature_request": + return s.buildFeatureRequestTemplate() + case "custom": + return s.buildCustomTemplate() + default: + return "" + } +} + +func (s *IssueTemplateService) buildBugReportTemplate() string { + template := map[string]interface{}{ + "name": s.trans.GetMessage("issue_template.bug_report_name", 0, nil), + "description": s.trans.GetMessage("issue_template.bug_report_about", 0, nil), + "title": s.trans.GetMessage("issue_template.bug_report_title", 0, nil), + "labels": []string{"bug"}, + "body": []map[string]interface{}{ + { + "type": "markdown", + "attributes": map[string]string{ + "value": s.trans.GetMessage("issue_template.bug_report_intro", 0, nil), + }, + }, + { + "type": "textarea", + "id": "description", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.bug_description_label", 0, nil), + "description": s.trans.GetMessage("issue_template.bug_description_help", 0, nil), + "placeholder": s.trans.GetMessage("issue_template.bug_description_placeholder", 0, nil), + }, + "validations": map[string]bool{ + "required": true, + }, + }, + { + "type": "textarea", + "id": "steps", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.bug_steps_label", 0, nil), + "description": s.trans.GetMessage("issue_template.bug_steps_help", 0, nil), + "placeholder": "1. \n2. \n3. ", + }, + "validations": map[string]bool{ + "required": true, + }, + }, + { + "type": "textarea", + "id": "expected", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.bug_expected_label", 0, nil), + "placeholder": s.trans.GetMessage("issue_template.bug_expected_placeholder", 0, nil), + }, + "validations": map[string]bool{ + "required": true, + }, + }, + { + "type": "textarea", + "id": "actual", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.bug_actual_label", 0, nil), + "placeholder": s.trans.GetMessage("issue_template.bug_actual_placeholder", 0, nil), + }, + "validations": map[string]bool{ + "required": true, + }, + }, + { + "type": "input", + "id": "version", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.bug_version_label", 0, nil), + "placeholder": "v1.0.0", + }, + }, + { + "type": "textarea", + "id": "additional", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.bug_additional_label", 0, nil), + "description": s.trans.GetMessage("issue_template.bug_additional_help", 0, nil), + }, + }, + }, + } + + content, _ := yaml.Marshal(template) + return string(content) +} + +func (s *IssueTemplateService) buildFeatureRequestTemplate() string { + template := map[string]interface{}{ + "name": s.trans.GetMessage("issue_template.feature_request_name", 0, nil), + "description": s.trans.GetMessage("issue_template.feature_request_about", 0, nil), + "title": s.trans.GetMessage("issue_template.feature_request_title", 0, nil), + "labels": []string{"enhancement"}, + "body": []map[string]interface{}{ + { + "type": "markdown", + "attributes": map[string]string{ + "value": s.trans.GetMessage("issue_template.feature_intro", 0, nil), + }, + }, + { + "type": "textarea", + "id": "problem", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.feature_problem_label", 0, nil), + "description": s.trans.GetMessage("issue_template.feature_problem_help", 0, nil), + "placeholder": s.trans.GetMessage("issue_template.feature_problem_placeholder", 0, nil), + }, + "validations": map[string]bool{ + "required": true, + }, + }, + { + "type": "textarea", + "id": "solution", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.feature_solution_label", 0, nil), + "description": s.trans.GetMessage("issue_template.feature_solution_help", 0, nil), + "placeholder": s.trans.GetMessage("issue_template.feature_solution_placeholder", 0, nil), + }, + "validations": map[string]bool{ + "required": true, + }, + }, + { + "type": "textarea", + "id": "alternatives", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.feature_alternatives_label", 0, nil), + "description": s.trans.GetMessage("issue_template.feature_alternatives_help", 0, nil), + }, + }, + { + "type": "textarea", + "id": "additional", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.feature_additional_label", 0, nil), + "description": s.trans.GetMessage("issue_template.feature_additional_help", 0, nil), + }, + }, + }, + } + + content, _ := yaml.Marshal(template) + return string(content) +} + +func (s *IssueTemplateService) buildCustomTemplate() string { + template := map[string]interface{}{ + "name": s.trans.GetMessage("issue_template.custom_issue_name", 0, nil), + "description": s.trans.GetMessage("issue_template.custom_issue_about", 0, nil), + "title": s.trans.GetMessage("issue_template.custom_issue_title", 0, nil), + "labels": []string{}, + "body": []map[string]interface{}{ + { + "type": "markdown", + "attributes": map[string]string{ + "value": s.trans.GetMessage("issue_template.custom_intro", 0, nil), + }, + }, + { + "type": "textarea", + "id": "description", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.custom_description_label", 0, nil), + "description": s.trans.GetMessage("issue_template.custom_description_help", 0, nil), + "placeholder": s.trans.GetMessage("issue_template.custom_description_placeholder", 0, nil), + }, + "validations": map[string]bool{ + "required": true, + }, + }, + { + "type": "textarea", + "id": "additional", + "attributes": map[string]interface{}{ + "label": s.trans.GetMessage("issue_template.custom_additional_label", 0, nil), + "description": s.trans.GetMessage("issue_template.custom_additional_help", 0, nil), + }, + }, + }, + } + + content, _ := yaml.Marshal(template) + return string(content) +} + +func (s *IssueTemplateService) MergeWithGeneratedContent(template *models.IssueTemplate, generated *models.IssueGenerationResult) *models.IssueGenerationResult { + result := &models.IssueGenerationResult{ + Labels: make([]string, 0), + Assignees: make([]string, 0), + } + + if template.Title != "" { + result.Title = template.Title + generated.Title + } else { + result.Title = generated.Title + } + + var descBuilder strings.Builder + + descBuilder.WriteString(generated.Description) + descBuilder.WriteString("\n\n") + + // Solo para templates .md que tienen Body como string + if template.BodyContent != "" { + descBuilder.WriteString("---\n\n") + descBuilder.WriteString(template.BodyContent) + } + + result.Description = descBuilder.String() + + labelMap := make(map[string]bool) + for _, label := range template.Labels { + if label != "" { + labelMap[label] = true + } + } + for _, label := range generated.Labels { + if label != "" { + labelMap[label] = true + } + } + + for label := range labelMap { + result.Labels = append(result.Labels, label) + } + + assigneeMap := make(map[string]bool) + for _, assignee := range template.Assignees { + if assignee != "" { + assigneeMap[assignee] = true + } + } + for _, assignee := range generated.Assignees { + if assignee != "" { + assigneeMap[assignee] = true + } + } + + for assignee := range assigneeMap { + result.Assignees = append(result.Assignees, assignee) + } + + return result +} diff --git a/internal/services/issue_template_service_test.go b/internal/services/issue_template_service_test.go new file mode 100644 index 0000000..2845d20 --- /dev/null +++ b/internal/services/issue_template_service_test.go @@ -0,0 +1,256 @@ +package services + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Tomas-vilte/MateCommit/internal/config" + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIssueTemplateService_GetTemplatesDir(t *testing.T) { + cwd, _ := os.Getwd() + + tests := []struct { + name string + provider string + expected string + }{ + { + name: "GitHub provider", + provider: "github", + expected: filepath.Join(cwd, ".github", "ISSUE_TEMPLATE"), + }, + { + name: "GitLab provider", + provider: "gitlab", + expected: filepath.Join(cwd, ".gitlab", "issue_templates"), + }, + { + name: "Default provider", + provider: "", + expected: filepath.Join(cwd, ".github", "ISSUE_TEMPLATE"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{ActiveVCSProvider: tt.provider} + service := NewIssueTemplateService(cfg, nil) + dir, err := service.GetTemplatesDir() + + assert.NoError(t, err) + assert.Equal(t, tt.expected, dir) + }) + } +} + +func TestIssueTemplateService_ParseTemplate(t *testing.T) { + service := &IssueTemplateService{} + + t.Run("Valid GitHub Issue Form YAML", func(t *testing.T) { + content := `name: Bug Report +description: Create a report to help us improve +title: '[BUG] ' +labels: + - bug + - fix +assignees: + - user1 + - user2 +body: + - type: markdown + attributes: + value: "Thanks for reporting!" + - type: textarea + id: description + attributes: + label: "What happened?" + validations: + required: true` + template, err := service.parseTemplate(content, "test.yml") + + require.NoError(t, err) + assert.Equal(t, "Bug Report", template.Name) + assert.Equal(t, "Create a report to help us improve", template.Description) + assert.Equal(t, "[BUG] ", template.Title) + assert.Equal(t, []string{"bug", "fix"}, template.Labels) + assert.Equal(t, []string{"user1", "user2"}, template.Assignees) + assert.NotNil(t, template.Body) + }) + + t.Run("Legacy markdown template with frontmatter", func(t *testing.T) { + content := `name: Feature Request +about: Suggest an idea +title: '[FEATURE] ' +labels: + - enhancement` + template, err := service.parseTemplate(content, "test.yml") + + require.NoError(t, err) + assert.Equal(t, "Feature Request", template.Name) + assert.Equal(t, "Suggest an idea", template.About) + assert.Equal(t, "[FEATURE] ", template.Title) + assert.Equal(t, []string{"enhancement"}, template.Labels) + }) + + t.Run("Invalid YAML", func(t *testing.T) { + content := `name: : invalid +title: [unclosed` + _, err := service.parseTemplate(content, "test.yml") + assert.Error(t, err) + assert.Contains(t, err.Error(), "error parseando template YAML") + }) +} + +func TestIssueTemplateService_FilesystemOps(t *testing.T) { + tmpDir := t.TempDir() + + origCwd, _ := os.Getwd() + err := os.Chdir(tmpDir) + require.NoError(t, err) + defer func() { + if err := os.Chdir(origCwd); err != nil { + panic(err) + } + }() + + cfg := &config.Config{ActiveVCSProvider: "github"} + trans, err := i18n.NewTranslations("en", "") + require.NoError(t, err) + service := NewIssueTemplateService(cfg, trans) + + t.Run("InitializeTemplates", func(t *testing.T) { + err := service.InitializeTemplates(false) + assert.NoError(t, err) + + templatesDir := filepath.Join(tmpDir, ".github", "ISSUE_TEMPLATE") + assert.DirExists(t, templatesDir) + assert.FileExists(t, filepath.Join(templatesDir, "bug_report.yml")) + assert.FileExists(t, filepath.Join(templatesDir, "feature_request.yml")) + assert.FileExists(t, filepath.Join(templatesDir, "custom.yml")) + }) + + t.Run("InitializeTemplates - Already exists", func(t *testing.T) { + err := service.InitializeTemplates(false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "los templates ya existen") + + err = service.InitializeTemplates(true) + assert.NoError(t, err) + }) + + t.Run("ListTemplates", func(t *testing.T) { + templates, err := service.ListTemplates() + assert.NoError(t, err) + assert.Len(t, templates, 3) + + err = os.WriteFile(filepath.Join(tmpDir, ".github", "ISSUE_TEMPLATE", "test.txt"), []byte("..."), 0644) + require.NoError(t, err) + + templates, err = service.ListTemplates() + assert.NoError(t, err) + assert.Len(t, templates, 3) + }) + + t.Run("GetTemplateByName", func(t *testing.T) { + template, err := service.GetTemplateByName("bug_report") + assert.NoError(t, err) + assert.NotNil(t, template) + + template, err = service.GetTemplateByName("bug_report.yml") + assert.NoError(t, err) + assert.NotNil(t, template) + + _, err = service.GetTemplateByName("non_existent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no encontrado") + }) +} + +func TestIssueTemplateService_MergeWithGeneratedContent(t *testing.T) { + service := &IssueTemplateService{} + + t.Run("Merge with markdown template (legacy)", func(t *testing.T) { + template := &models.IssueTemplate{ + Title: "[TEMPLATE] ", + BodyContent: "Template body", + Labels: []string{"bug", "critical"}, + Assignees: []string{"dev1"}, + } + + generated := &models.IssueGenerationResult{ + Title: "Something failed", + Description: "Detailed error info", + Labels: []string{"fix", "critical"}, + Assignees: []string{"dev1", "dev2"}, + } + + result := service.MergeWithGeneratedContent(template, generated) + + assert.Equal(t, "[TEMPLATE] Something failed", result.Title) + assert.Contains(t, result.Description, "Detailed error info") + assert.Contains(t, result.Description, "Template body") + assert.Contains(t, result.Labels, "bug") + assert.Contains(t, result.Labels, "fix") + assert.Contains(t, result.Labels, "critical") + assert.Len(t, result.Labels, 3) + + assert.Contains(t, result.Assignees, "dev1") + assert.Contains(t, result.Assignees, "dev2") + assert.Len(t, result.Assignees, 2) + }) + + t.Run("Merge with GitHub Issue Form template", func(t *testing.T) { + template := &models.IssueTemplate{ + Title: "[BUG] ", + Labels: []string{"bug"}, + Body: []interface{}{ + map[string]interface{}{ + "type": "markdown", + "attributes": map[string]interface{}{ + "value": "Thanks for reporting!", + }, + }, + }, + } + + generated := &models.IssueGenerationResult{ + Title: "Application crashes", + Description: "The app crashes when clicking submit", + Labels: []string{"needs-triage"}, + } + + result := service.MergeWithGeneratedContent(template, generated) + + assert.Equal(t, "[BUG] Application crashes", result.Title) + assert.Contains(t, result.Description, "The app crashes when clicking submit") + assert.Contains(t, result.Labels, "bug") + assert.Contains(t, result.Labels, "needs-triage") + assert.Len(t, result.Labels, 2) + }) + + t.Run("Merge without template title", func(t *testing.T) { + template := &models.IssueTemplate{ + Title: "", + Labels: []string{"enhancement"}, + } + + generated := &models.IssueGenerationResult{ + Title: "Add dark mode", + Description: "Users want dark mode", + Labels: []string{"ui"}, + } + + result := service.MergeWithGeneratedContent(template, generated) + + assert.Equal(t, "Add dark mode", result.Title) + assert.Contains(t, result.Description, "Users want dark mode") + assert.Contains(t, result.Labels, "enhancement") + assert.Contains(t, result.Labels, "ui") + }) +} diff --git a/internal/services/mocks.go b/internal/services/mocks.go index 1ce9267..8698bb5 100644 --- a/internal/services/mocks.go +++ b/internal/services/mocks.go @@ -30,6 +30,14 @@ type ( MockReleaseNotesGenerator struct { mock.Mock } + + MockIssueContentGenerator struct { + mock.Mock + } + + MockIssueTemplateService struct { + mock.Mock + } ) func (m *MockJiraService) GetTicketInfo(ticketID string) (*models.TicketInfo, error) { @@ -213,6 +221,16 @@ func (m *MockVCSClient) UpdateIssueChecklist(ctx context.Context, issueNumber in return args.Error(0) } +func (m *MockVCSClient) CreateIssue(ctx context.Context, title string, body string, labels []string, assignees []string) (*models.Issue, error) { + args := m.Called(ctx, title, body, labels, assignees) + return args.Get(0).(*models.Issue), args.Error(1) +} + +func (m *MockVCSClient) GetAuthenticatedUser(ctx context.Context) (string, error) { + args := m.Called(ctx) + return args.String(0), 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) @@ -225,3 +243,47 @@ func (m *MockReleaseNotesGenerator) GenerateNotes(ctx context.Context, release * } return args.Get(0).(*models.ReleaseNotes), args.Error(1) } + +func (m *MockIssueContentGenerator) GenerateIssueContent(ctx context.Context, request models.IssueGenerationRequest) (*models.IssueGenerationResult, error) { + args := m.Called(ctx, request) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.IssueGenerationResult), args.Error(1) +} + +func (m *MockIssueTemplateService) GetTemplatesDir() (string, error) { + args := m.Called() + return args.String(0), args.Error(1) +} + +func (m *MockIssueTemplateService) ListTemplates() ([]models.TemplateMetadata, error) { + args := m.Called() + return args.Get(0).([]models.TemplateMetadata), args.Error(1) +} + +func (m *MockIssueTemplateService) LoadTemplate(filePath string) (*models.IssueTemplate, error) { + args := m.Called(filePath) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.IssueTemplate), args.Error(1) +} + +func (m *MockIssueTemplateService) GetTemplateByName(name string) (*models.IssueTemplate, error) { + args := m.Called(name) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.IssueTemplate), args.Error(1) +} + +func (m *MockIssueTemplateService) InitializeTemplates(force bool) error { + args := m.Called(force) + return args.Error(0) +} + +func (m *MockIssueTemplateService) MergeWithGeneratedContent(template *models.IssueTemplate, generated *models.IssueGenerationResult) *models.IssueGenerationResult { + args := m.Called(template, generated) + return args.Get(0).(*models.IssueGenerationResult) +} diff --git a/internal/services/pull_request_service.go b/internal/services/pull_request_service.go index 9d18a7c..99fbb71 100644 --- a/internal/services/pull_request_service.go +++ b/internal/services/pull_request_service.go @@ -50,7 +50,7 @@ func (s *PRService) SummarizePR(ctx context.Context, prNumber int, progress func commitMessages = append(commitMessages, commit.Message) } - issues, err := s.vcsClient.GetPRIssues(ctx, prData.BranchName, commitMessages, prData.PRDescription) + issues, err := s.vcsClient.GetPRIssues(ctx, prData.BranchName, commitMessages, prData.Description) if err == nil && len(issues) > 0 { prData.RelatedIssues = issues diff --git a/internal/services/pull_request_service_test.go b/internal/services/pull_request_service_test.go index 67b6ae3..a518ee0 100644 --- a/internal/services/pull_request_service_test.go +++ b/internal/services/pull_request_service_test.go @@ -186,10 +186,11 @@ func TestPRService_SummarizePR_WithRelatedIssues(t *testing.T) { cfg := &config.Config{Language: "es"} prData := models.PRData{ - ID: prNumber, - BranchName: "fix/123-bug", - PRDescription: "Closes #456", - Commits: []models.Commit{{Message: "Fix #789"}}, + ID: prNumber, + Title: "fix/123-bug", + BranchName: "fix/123-bug", + Description: "Closes #456", + Commits: []models.Commit{{Message: "Fix #789"}}, } relatedIssues := []models.Issue{ @@ -204,7 +205,7 @@ func TestPRService_SummarizePR_WithRelatedIssues(t *testing.T) { } mockVCS.On("GetPR", ctx, prNumber).Return(prData, nil) - mockVCS.On("GetPRIssues", ctx, prData.BranchName, []string{"Fix #789"}, prData.PRDescription). + mockVCS.On("GetPRIssues", ctx, prData.BranchName, []string{"Fix #789"}, prData.Description). Return(relatedIssues, nil) mockAI.On("GeneratePRSummary", ctx, mock.MatchedBy(func(prompt string) bool { From 4590840f4a6e6d260c1065ca3c09a36d468f163a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Thu, 18 Dec 2025 12:41:21 -0300 Subject: [PATCH 2/4] refactor(issues): implemento carga perezosa del servicio de issues --- cmd/main.go | 12 +++--- internal/cli/command/issues/issues.go | 36 +++++++++++------- internal/cli/command/issues/issues_test.go | 38 +++++++++++-------- internal/cli/command/issues/templates_test.go | 16 ++++---- 4 files changed, 58 insertions(+), 44 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 87ea925..ccb0138 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,6 +16,7 @@ import ( "github.com/Tomas-vilte/MateCommit/internal/cli/command/update" "github.com/Tomas-vilte/MateCommit/internal/cli/registry" cfg "github.com/Tomas-vilte/MateCommit/internal/config" + "github.com/Tomas-vilte/MateCommit/internal/domain/ports" "github.com/Tomas-vilte/MateCommit/internal/i18n" "github.com/Tomas-vilte/MateCommit/internal/infrastructure/ai/gemini" "github.com/Tomas-vilte/MateCommit/internal/infrastructure/di" @@ -91,18 +92,17 @@ func initializeApp() (*cli.Command, error) { prServiceFactory := factory.NewPrServiceFactory(cfgApp, translations, nil, gitService) prCommand := pull_requests.NewSummarizeCommand(prServiceFactory) - issueGeneratorService, err := container.GetIssueGeneratorService(ctx) - if err != nil { - log.Printf("Warning: no se pudo inicializar el servicio de generación de issues: %v", err) - } - registerCommand := registry.NewRegistry(cfgApp, translations) if err := registerCommand.Register("suggest", suggests_commits.NewSuggestCommandFactory(commitService, commitHandler)); err != nil { log.Fatalf("Error al registrar el comando 'suggest': %v", err) } - if err := registerCommand.Register("issue", issues.NewIssuesCommandFactory(issueGeneratorService, container.GetIssueTemplateService())); err != nil { + issueServiceProvider := func(ctx context.Context) (ports.IssueGeneratorService, error) { + return container.GetIssueGeneratorService(ctx) + } + + if err := registerCommand.Register("issue", issues.NewIssuesCommandFactory(issueServiceProvider, container.GetIssueTemplateService())); err != nil { log.Fatalf("Error al registrar el comando 'issue': %v", err) } diff --git a/internal/cli/command/issues/issues.go b/internal/cli/command/issues/issues.go index c3b38cb..8cfe82a 100644 --- a/internal/cli/command/issues/issues.go +++ b/internal/cli/command/issues/issues.go @@ -17,17 +17,20 @@ import ( "github.com/urfave/cli/v3" ) +// IssueServiceProvider es una interfaz para obtener el servicio de issues de manera lazy +type IssueServiceProvider func(ctx context.Context) (ports.IssueGeneratorService, error) + // IssuesCommandFactory es el factory para crear el comando de issues. type IssuesCommandFactory struct { - issueService ports.IssueGeneratorService - templateService ports.IssueTemplateService + issueServiceProvider IssueServiceProvider + templateService ports.IssueTemplateService } // NewIssuesCommandFactory crea una nueva instancia del factory. -func NewIssuesCommandFactory(issueService ports.IssueGeneratorService, templateService ports.IssueTemplateService) *IssuesCommandFactory { +func NewIssuesCommandFactory(issueServiceProvider IssueServiceProvider, templateService ports.IssueTemplateService) *IssuesCommandFactory { return &IssuesCommandFactory{ - issueService: issueService, - templateService: templateService, + issueServiceProvider: issueServiceProvider, + templateService: templateService, } } @@ -141,6 +144,12 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c ui.PrintSectionBanner(t.GetMessage("issue.banner", 0, nil)) + issueService, err := f.issueServiceProvider(ctx) + if err != nil { + ui.PrintError(fmt.Sprintf("%s: %v", t.GetMessage("issue.error_generating", 0, nil), err)) + return err + } + var spinnerMsg string if fromPR > 0 { spinnerMsg = t.GetMessage("issue.analyzing_pr", 0, map[string]interface{}{ @@ -154,16 +163,15 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c spinner.Start() var result *models.IssueGenerationResult - var err error if templateName != "" { - result, err = f.issueService.GenerateWithTemplate(ctx, templateName, hint, fromDiff, description, noLabels) + result, err = issueService.GenerateWithTemplate(ctx, templateName, hint, fromDiff, description, noLabels) } else if fromDiff { - result, err = f.issueService.GenerateFromDiff(ctx, hint, noLabels) + result, err = issueService.GenerateFromDiff(ctx, hint, noLabels) } else if fromPR > 0 { - result, err = f.issueService.GenerateFromPR(ctx, fromPR, hint, noLabels) + result, err = issueService.GenerateFromPR(ctx, fromPR, hint, noLabels) } else { - result, err = f.issueService.GenerateFromDescription(ctx, description, noLabels) + result, err = issueService.GenerateFromDescription(ctx, description, noLabels) } spinner.Stop() @@ -191,7 +199,7 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c spinner = ui.NewSmartSpinner(t.GetMessage("issue.getting_user", 0, nil)) spinner.Start() - username, err := f.issueService.GetAuthenticatedUser(ctx) + username, err := issueService.GetAuthenticatedUser(ctx) spinner.Stop() if err != nil { @@ -207,7 +215,7 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c spinner = ui.NewSmartSpinner(t.GetMessage("issue.creating", 0, nil)) spinner.Start() - issue, err := f.issueService.CreateIssue(ctx, result, assignees) + issue, err := issueService.CreateIssue(ctx, result, assignees) spinner.Stop() if err != nil { @@ -221,7 +229,7 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c })) if fromPR > 0 { - if err := f.issueService.LinkIssueToPR(ctx, fromPR, issue.Number); err != nil { + if err := issueService.LinkIssueToPR(ctx, fromPR, issue.Number); err != nil { ui.PrintWarning(t.GetMessage("issue.link_error", 0, map[string]interface{}{ "PR": fromPR, "Error": err, @@ -235,7 +243,7 @@ func (f *IssuesCommandFactory) createGenerateAction(t *i18n.Translations, cfg *c } if checkoutBranch { - branchName := f.issueService.InferBranchName(issue.Number, result.Labels) + branchName := issueService.InferBranchName(issue.Number, result.Labels) ui.PrintInfo(t.GetMessage("issue.creating_branch", 0, map[string]interface{}{ "Branch": branchName, diff --git a/internal/cli/command/issues/issues_test.go b/internal/cli/command/issues/issues_test.go index 6eb6f7b..c77a5b6 100644 --- a/internal/cli/command/issues/issues_test.go +++ b/internal/cli/command/issues/issues_test.go @@ -7,6 +7,7 @@ import ( "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/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -14,13 +15,18 @@ import ( "github.com/urfave/cli/v3" ) -func setupIssuesTest(t *testing.T) (*MockIssueGeneratorService, *MockIssueTemplateService, *i18n.Translations, *config.Config) { +func setupIssuesTest(t *testing.T) (*MockIssueGeneratorService, *MockIssueTemplateService, IssueServiceProvider, *i18n.Translations, *config.Config) { mockGen := &MockIssueGeneratorService{} mockTemp := &MockIssueTemplateService{} + + provider := func(ctx context.Context) (ports.IssueGeneratorService, error) { + return mockGen, nil + } + trans, err := i18n.NewTranslations("en", "../../../../internal/i18n/locales") require.NoError(t, err) cfg := &config.Config{Language: "en"} - return mockGen, mockTemp, trans, cfg + return mockGen, mockTemp, provider, trans, cfg } func withStdin(input string, f func()) { @@ -41,8 +47,8 @@ func withStdin(input string, f func()) { func TestIssueGenerateAction(t *testing.T) { t.Run("should fail if no input provided", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + _, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} @@ -53,8 +59,8 @@ func TestIssueGenerateAction(t *testing.T) { }) t.Run("should fail if multiple sources provided", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + _, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) app := &cli.Command{Name: "test", Commands: []*cli.Command{cmd}} @@ -65,8 +71,8 @@ func TestIssueGenerateAction(t *testing.T) { }) t.Run("should generate from diff successfully", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + mockGen, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) expectedResult := &models.IssueGenerationResult{ @@ -88,8 +94,8 @@ func TestIssueGenerateAction(t *testing.T) { }) t.Run("should handle dry-run correctly", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + mockGen, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) expectedResult := &models.IssueGenerationResult{ @@ -108,8 +114,8 @@ func TestIssueGenerateAction(t *testing.T) { }) t.Run("should handle assign-me flag", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + mockGen, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) expectedResult := &models.IssueGenerationResult{ @@ -130,8 +136,8 @@ func TestIssueGenerateAction(t *testing.T) { }) t.Run("should cancel if user chooses no", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + mockGen, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) mockGen.On("GenerateFromDiff", mock.Anything, "", false).Return(&models.IssueGenerationResult{Title: "T"}, nil) @@ -146,8 +152,8 @@ func TestIssueGenerateAction(t *testing.T) { }) t.Run("should use template if specified", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + mockGen, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) expectedResult := &models.IssueGenerationResult{Title: "Template Issue"} diff --git a/internal/cli/command/issues/templates_test.go b/internal/cli/command/issues/templates_test.go index 9cd4099..c71cd12 100644 --- a/internal/cli/command/issues/templates_test.go +++ b/internal/cli/command/issues/templates_test.go @@ -11,8 +11,8 @@ import ( func TestIssueTemplateAction(t *testing.T) { t.Run("should init templates successfully", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + _, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) mockTemp.On("InitializeTemplates", false).Return(nil) @@ -26,8 +26,8 @@ func TestIssueTemplateAction(t *testing.T) { }) t.Run("should init templates with force flag", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + _, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) mockTemp.On("InitializeTemplates", true).Return(nil) @@ -41,8 +41,8 @@ func TestIssueTemplateAction(t *testing.T) { }) t.Run("should list templates successfully", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + _, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) templates := []models.TemplateMetadata{ @@ -59,8 +59,8 @@ func TestIssueTemplateAction(t *testing.T) { }) t.Run("should handle empty template list", func(t *testing.T) { - mockGen, mockTemp, trans, cfg := setupIssuesTest(t) - factory := NewIssuesCommandFactory(mockGen, mockTemp) + _, mockTemp, provider, trans, cfg := setupIssuesTest(t) + factory := NewIssuesCommandFactory(provider, mockTemp) cmd := factory.CreateCommand(trans, cfg) mockTemp.On("ListTemplates").Return([]models.TemplateMetadata{}, nil) From bd8bf8f8767447e86bf99e21b36645bfebc230a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Thu, 18 Dec 2025 13:14:00 -0300 Subject: [PATCH 3/4] feat(issues): agrega comando para vincular PRs a issues --- internal/cli/command/issues/issues.go | 72 +++++++++++++++++++++++++++ internal/i18n/locales/active.en.toml | 12 +++++ internal/i18n/locales/active.es.toml | 12 +++++ 3 files changed, 96 insertions(+) diff --git a/internal/cli/command/issues/issues.go b/internal/cli/command/issues/issues.go index 8cfe82a..743920e 100644 --- a/internal/cli/command/issues/issues.go +++ b/internal/cli/command/issues/issues.go @@ -42,6 +42,7 @@ func (f *IssuesCommandFactory) CreateCommand(t *i18n.Translations, cfg *config.C Usage: t.GetMessage("issue.command_usage", 0, nil), Commands: []*cli.Command{ f.newGenerateCommand(t, cfg), + f.newLinkCommand(t, cfg), f.newTemplateCommand(t, cfg), }, } @@ -346,3 +347,74 @@ func (f *IssuesCommandFactory) checkoutBranch(branchName string) error { } return nil } + +// newLinkCommand crea el subcomando 'link'. +func (f *IssuesCommandFactory) newLinkCommand(t *i18n.Translations, cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "link", + Aliases: []string{"l"}, + Usage: t.GetMessage("issue.link_usage", 0, nil), + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "pr", + Aliases: []string{"p"}, + Usage: t.GetMessage("issue.flag_pr_number", 0, nil), + Required: true, + }, + &cli.IntFlag{ + Name: "issue", + Aliases: []string{"i"}, + Usage: t.GetMessage("issue.flag_issue_number", 0, nil), + Required: true, + }, + }, + Action: f.createLinkAction(t, cfg), + } +} + +// createLinkAction crea la acción para linkear un PR a una issue. +func (f *IssuesCommandFactory) createLinkAction(t *i18n.Translations, cfg *config.Config) cli.ActionFunc { + return func(ctx context.Context, command *cli.Command) error { + prNumber := command.Int("pr") + issueNumber := command.Int("issue") + + if prNumber <= 0 { + ui.PrintError(t.GetMessage("issue.error_invalid_pr", 0, nil)) + return fmt.Errorf("invalid PR number") + } + + if issueNumber <= 0 { + ui.PrintError(t.GetMessage("issue.error_invalid_issue", 0, nil)) + return fmt.Errorf("invalid issue number") + } + + ui.PrintSectionBanner(t.GetMessage("issue.link_banner", 0, nil)) + + issueService, err := f.issueServiceProvider(ctx) + if err != nil { + ui.PrintError(fmt.Sprintf("%s: %v", t.GetMessage("issue.error_linking", 0, nil), err)) + return err + } + + spinner := ui.NewSmartSpinner(t.GetMessage("issue.linking", 0, map[string]interface{}{ + "PR": prNumber, + "Issue": issueNumber, + })) + spinner.Start() + + err = issueService.LinkIssueToPR(ctx, prNumber, issueNumber) + spinner.Stop() + + if err != nil { + ui.PrintError(fmt.Sprintf("%s: %v", t.GetMessage("issue.error_linking", 0, nil), err)) + return err + } + + ui.PrintSuccess(t.GetMessage("issue.link_success", 0, map[string]interface{}{ + "PR": prNumber, + "Issue": issueNumber, + })) + + return nil + } +} diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index 35f93ae..9ecfd47 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -796,6 +796,18 @@ analyzing_pr = "Analyzing Pull Request #{{.Number}}..." error_multiple_sources = "You can only specify one of: --from-diff, --from-pr, or --description" link_success = "✅ Link successful: PR #{{.PR}} updated to close #{{.Issue}}" link_error = "Could not link issue to PR #{{.PR}} automatically: {{.Error}}" +# Link Command +link_usage = "Link a PR to an existing issue" +link_banner = "Issue Linker" +flag_pr_number = "Pull Request number" +flag_issue_number = "Issue number" +error_pr_required = "You must specify the PR number with --pr" +error_issue_required = "You must specify the issue number with --issue" +error_invalid_pr = "Invalid PR number" +error_invalid_issue = "Invalid issue number" +error_linking = "Error linking PR to issue" +linking = "Linking PR #{{.PR}} with issue #{{.Issue}}..." +link_updated = "✅ PR #{{.PR}} updated successfully. Now closes #{{.Issue}}" # Template Management flag_template = "Use a specific issue template" template_usage = "Manage issue templates" diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index e05ce24..2135a4b 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -827,6 +827,18 @@ analyzing_pr = "Analizando Pull Request #{{.Number}}..." error_multiple_sources = "Solo podés especificar una opción: --from-diff, --from-pr, o --description" link_success = "Vinculación exitosa: PR #{{.PR}} actualizado para cerrar #{{.Issue}}" link_error = "No se pudo vincular la issue al PR #{{.PR}} automáticamente: {{.Error}}" +# Link Command +link_usage = "Vincular un PR con una issue existente" +link_banner = "Vinculador de Issues" +flag_pr_number = "Número del Pull Request" +flag_issue_number = "Número de la issue" +error_pr_required = "Debés especificar el número del PR con --pr" +error_issue_required = "Debés especificar el número de la issue con --issue" +error_invalid_pr = "El número de PR es inválido" +error_invalid_issue = "El número de issue es inválido" +error_linking = "Error vinculando PR con issue" +linking = "Vinculando PR #{{.PR}} con issue #{{.Issue}}..." +link_updated = "✅ PR #{{.PR}} actualizado exitosamente. Ahora cierra #{{.Issue}}" # Gestión de Templates flag_template = "Usar un template específico de issue" template_usage = "Gestionar templates de issues" From bb5c1a33fd327d0913b169f6516d73c44c74c480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Thu, 18 Dec 2025 13:15:55 -0300 Subject: [PATCH 4/4] delete comment --- internal/cli/command/issues/issues.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/cli/command/issues/issues.go b/internal/cli/command/issues/issues.go index 743920e..41fee67 100644 --- a/internal/cli/command/issues/issues.go +++ b/internal/cli/command/issues/issues.go @@ -17,7 +17,6 @@ import ( "github.com/urfave/cli/v3" ) -// IssueServiceProvider es una interfaz para obtener el servicio de issues de manera lazy type IssueServiceProvider func(ctx context.Context) (ports.IssueGeneratorService, error) // IssuesCommandFactory es el factory para crear el comando de issues. @@ -348,7 +347,6 @@ func (f *IssuesCommandFactory) checkoutBranch(branchName string) error { return nil } -// newLinkCommand crea el subcomando 'link'. func (f *IssuesCommandFactory) newLinkCommand(t *i18n.Translations, cfg *config.Config) *cli.Command { return &cli.Command{ Name: "link", @@ -373,7 +371,7 @@ func (f *IssuesCommandFactory) newLinkCommand(t *i18n.Translations, cfg *config. } // createLinkAction crea la acción para linkear un PR a una issue. -func (f *IssuesCommandFactory) createLinkAction(t *i18n.Translations, cfg *config.Config) cli.ActionFunc { +func (f *IssuesCommandFactory) createLinkAction(t *i18n.Translations, _ *config.Config) cli.ActionFunc { return func(ctx context.Context, command *cli.Command) error { prNumber := command.Int("pr") issueNumber := command.Int("issue")