From d5e8a6aad5dfe0564b18b0e724120d5107ab1259 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Wed, 10 Dec 2025 23:12:51 -0300 Subject: [PATCH 1/4] feat: Enrich release context for smarter AI & markdown --- internal/cli/command/release/create.go | 6 + internal/cli/command/release/create_test.go | 10 ++ internal/cli/command/release/edit.go | 6 + internal/cli/command/release/formatter.go | 46 ++++- internal/cli/command/release/generate.go | 6 + internal/cli/command/release/generate_test.go | 4 + internal/cli/command/release/mocks.go | 5 + internal/cli/command/release/preview.go | 6 + internal/cli/command/release/preview_test.go | 3 + internal/cli/command/release/publish.go | 6 + internal/cli/command/release/publish_test.go | 5 + internal/domain/models/release.go | 43 +++++ internal/domain/ports/release_service.go | 3 + internal/domain/ports/vcs_client.go | 4 + internal/i18n/locales/active.en.toml | 8 + internal/i18n/locales/active.es.toml | 9 +- .../ai/gemini/release_generator.go | 65 ++++++- internal/infrastructure/ai/prompts.go | 165 ++++++++++-------- .../vcs/github/github_service.go | 165 +++++++++++++++++- internal/infrastructure/vcs/github/mocks.go | 15 ++ internal/services/pr_service_test.go | 20 +++ internal/services/release_service.go | 69 ++++++-- 22 files changed, 571 insertions(+), 98 deletions(-) diff --git a/internal/cli/command/release/create.go b/internal/cli/command/release/create.go index 1923e3b..2756c73 100644 --- a/internal/cli/command/release/create.go +++ b/internal/cli/command/release/create.go @@ -64,6 +64,12 @@ func createReleaseAction(releaseService ports.ReleaseService, trans *i18n.Transl release.Version = version } + if err := releaseService.EnrichReleaseContext(ctx, release); err != nil { + fmt.Printf("⚠️ %s\n", trans.GetMessage("release.warning_enrich_context", 0, map[string]interface{}{ + "Error": err.Error(), + })) + } + notes, err := releaseService.GenerateReleaseNotes(ctx, release) if err != nil { return fmt.Errorf("%s", trans.GetMessage("release.error_generating_notes", 0, diff --git a/internal/cli/command/release/create_test.go b/internal/cli/command/release/create_test.go index 73fb9c4..8bd9685 100644 --- a/internal/cli/command/release/create_test.go +++ b/internal/cli/command/release/create_test.go @@ -61,6 +61,7 @@ func TestCreateCommand_Success(t *testing.T) { } mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.1.0", "Release v1.1.0\n\nSummary of release").Return(nil) @@ -83,6 +84,7 @@ func TestCreateCommand_WithVersionOverride(t *testing.T) { } mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, mock.MatchedBy(func(r *models.Release) bool { return r.Version == "v2.0.0" })).Return(notes, nil) @@ -100,6 +102,7 @@ func TestCreateCommand_AutoConfirm(t *testing.T) { notes := &models.ReleaseNotes{Title: "Fix"} mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.1", mock.Anything).Return(nil) @@ -113,6 +116,7 @@ func TestCreateCommand_Cancelled(t *testing.T) { notes := &models.ReleaseNotes{Title: "Fix"} mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) err := runCreateTest(t, "no\n", []string{}, mockService) assert.NoError(t, err) @@ -123,6 +127,7 @@ func TestCreateCommand_Cancelled(t *testing.T) { func TestCreateCommand_AnalyzeError(t *testing.T) { mockService := new(MockReleaseService) mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("git error")) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) err := runCreateTest(t, "", []string{}, mockService) assert.Error(t, err) @@ -134,6 +139,7 @@ func TestCreateCommand_GenerateNotesError(t *testing.T) { mockService := new(MockReleaseService) release := &models.Release{Version: "v1.0.0"} mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("ai error")) err := runCreateTest(t, "", []string{}, mockService) @@ -149,6 +155,7 @@ func TestCreateCommand_CreateTagError(t *testing.T) { notes := &models.ReleaseNotes{Title: "Title"} mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(errors.New("tag error")) @@ -165,6 +172,7 @@ func TestCreateCommand_WithPublish(t *testing.T) { notes := &models.ReleaseNotes{Title: "Title"} mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil) @@ -183,6 +191,7 @@ func TestCreateCommand_WithPublishDraft(t *testing.T) { notes := &models.ReleaseNotes{Title: "Title"} mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil) @@ -201,6 +210,7 @@ func TestCreateCommand_PublishError(t *testing.T) { notes := &models.ReleaseNotes{Title: "Title"} mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("CreateTag", mock.Anything, "v1.0.0", mock.Anything).Return(nil) diff --git a/internal/cli/command/release/edit.go b/internal/cli/command/release/edit.go index bf58cad..5695d80 100644 --- a/internal/cli/command/release/edit.go +++ b/internal/cli/command/release/edit.go @@ -97,6 +97,12 @@ func editReleaseAction(releaseService ports.ReleaseService, gitService ports.Git AllCommits: commits, } + if err := releaseService.EnrichReleaseContext(ctx, release); err != nil { + fmt.Printf("⚠️ %s\n", trans.GetMessage("release.warning_enrich_context", 0, map[string]interface{}{ + "Error": err.Error(), + })) + } + notes, err := releaseService.GenerateReleaseNotes(ctx, release) if err != nil { fmt.Printf("⚠️ %s\n", trans.GetMessage("release.error_generating_for_regen", 0, map[string]interface{}{ diff --git a/internal/cli/command/release/formatter.go b/internal/cli/command/release/formatter.go index b0537e3..5a06d0b 100644 --- a/internal/cli/command/release/formatter.go +++ b/internal/cli/command/release/formatter.go @@ -2,6 +2,7 @@ package release import ( "fmt" + "strings" "github.com/Tomas-vilte/MateCommit/internal/domain/models" "github.com/Tomas-vilte/MateCommit/internal/i18n" @@ -10,6 +11,7 @@ import ( // FormatReleaseMarkdown genera el markdown completo de una release con todas las secciones func FormatReleaseMarkdown(release *models.Release, notes *models.ReleaseNotes, trans *i18n.Translations) string { content := fmt.Sprintf("# %s\n\n", notes.Title) + var md strings.Builder content += "![Version](https://img.shields.io/badge/version-" + release.Version + "-blue)\n" content += "![Status](https://img.shields.io/badge/status-released-success)\n\n" @@ -94,5 +96,47 @@ func FormatReleaseMarkdown(release *models.Release, notes *models.ReleaseNotes, } } - return content + if len(release.Contributors) > 0 { + md.WriteString("## ") + md.WriteString(trans.GetMessage("release.md_contributors", 0, nil)) + md.WriteString("\n\n") + + if len(release.NewContributors) > 0 { + md.WriteString(trans.GetMessage("release.new_contributors", 0, map[string]interface{}{ + "Count": len(release.NewContributors), + })) + md.WriteString(" ") + for i, contributor := range release.NewContributors { + md.WriteString(fmt.Sprintf("@%s", contributor)) + if i < len(release.NewContributors)-1 { + md.WriteString(", ") + } + } + md.WriteString("\n\n") + } + + md.WriteString(trans.GetMessage("release.all_contributors", 0, nil)) + md.WriteString("\n") + for _, contributor := range release.Contributors { + md.WriteString(fmt.Sprintf("- @%s\n", contributor)) + } + md.WriteString("\n") + } + + if release.FileStats.FilesChanged > 0 { + md.WriteString("## ") + md.WriteString(trans.GetMessage("release.md_stats", 0, nil)) + md.WriteString("\n\n") + md.WriteString(fmt.Sprintf("- %s: **%d**\n", + trans.GetMessage("release.files_changed", 0, nil), + release.FileStats.FilesChanged)) + md.WriteString(fmt.Sprintf("- %s: **+%d**\n", + trans.GetMessage("release.insertions", 0, nil), + release.FileStats.Insertions)) + md.WriteString(fmt.Sprintf("- %s: **-%d**\n", + trans.GetMessage("release.deletions", 0, nil), + release.FileStats.Deletions)) + md.WriteString("\n") + } + return content + md.String() } diff --git a/internal/cli/command/release/generate.go b/internal/cli/command/release/generate.go index bb176be..f19af65 100644 --- a/internal/cli/command/release/generate.go +++ b/internal/cli/command/release/generate.go @@ -45,6 +45,12 @@ func generateReleaseAction(releaseService ports.ReleaseService, trans *i18n.Tran })) } + if err := releaseService.EnrichReleaseContext(ctx, release); err != nil { + fmt.Printf("⚠️ %s\n", trans.GetMessage("release.warning_enrich_context", 0, map[string]interface{}{ + "Error": err.Error(), + })) + } + notes, err := releaseService.GenerateReleaseNotes(ctx, release) if err != nil { return fmt.Errorf("%s", trans.GetMessage("release.error_generating_notes", 0, diff --git a/internal/cli/command/release/generate_test.go b/internal/cli/command/release/generate_test.go index 510eecb..cc45581 100644 --- a/internal/cli/command/release/generate_test.go +++ b/internal/cli/command/release/generate_test.go @@ -56,6 +56,7 @@ func TestGenerateCommand_Success(t *testing.T) { } mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) err := runGenerateTest(t, []string{"--output", outputFile}, mockService) @@ -75,6 +76,7 @@ func TestGenerateCommand_Success(t *testing.T) { func TestGenerateCommand_AnalyzeError(t *testing.T) { mockService := new(MockReleaseService) mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("git error")) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) err := runGenerateTest(t, []string{}, mockService) assert.Error(t, err) @@ -87,6 +89,7 @@ func TestGenerateCommand_GenerateError(t *testing.T) { mockService := new(MockReleaseService) release := &models.Release{Version: "v1.0.0"} mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("ai error")) err := runGenerateTest(t, []string{}, mockService) @@ -102,6 +105,7 @@ func TestGenerateCommand_WriteError(t *testing.T) { notes := &models.ReleaseNotes{Title: "Title"} mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) invalidPath := "/path/to/non/existent/dir/file.md" diff --git a/internal/cli/command/release/mocks.go b/internal/cli/command/release/mocks.go index 7970c84..26823a6 100644 --- a/internal/cli/command/release/mocks.go +++ b/internal/cli/command/release/mocks.go @@ -55,6 +55,11 @@ func (m *MockReleaseService) UpdateRelease(ctx context.Context, version, body st return args.Error(0) } +func (m *MockReleaseService) EnrichReleaseContext(ctx context.Context, release *models.Release) error { + args := m.Called(ctx, release) + return args.Error(0) +} + type MockGitService struct { mock.Mock } diff --git a/internal/cli/command/release/preview.go b/internal/cli/command/release/preview.go index c000019..89e444a 100644 --- a/internal/cli/command/release/preview.go +++ b/internal/cli/command/release/preview.go @@ -68,6 +68,12 @@ func previewReleaseAction(releaseService ports.ReleaseService, trans *i18n.Trans })) fmt.Println() + if err := releaseService.EnrichReleaseContext(ctx, release); err != nil { + fmt.Printf("⚠️ %s\n", trans.GetMessage("release.warning_enrich_context", 0, map[string]interface{}{ + "Error": err.Error(), + })) + } + notes, err := releaseService.GenerateReleaseNotes(ctx, release) if err != nil { return fmt.Errorf("%s", trans.GetMessage("release.error_generating_notes", 0, diff --git a/internal/cli/command/release/preview_test.go b/internal/cli/command/release/preview_test.go index 0ebe0c4..e062edd 100644 --- a/internal/cli/command/release/preview_test.go +++ b/internal/cli/command/release/preview_test.go @@ -51,6 +51,7 @@ func TestPreviewCommand_Success(t *testing.T) { } mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) err := runPreviewTest(t, []string{}, mockService) @@ -62,6 +63,7 @@ func TestPreviewCommand_Success(t *testing.T) { func TestPreviewCommand_AnalyzeError(t *testing.T) { mockService := new(MockReleaseService) mockService.On("AnalyzeNextRelease", mock.Anything).Return((*models.Release)(nil), errors.New("git error")) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) err := runPreviewTest(t, []string{}, mockService) assert.Error(t, err) @@ -74,6 +76,7 @@ func TestPreviewCommand_GenerateError(t *testing.T) { mockService := new(MockReleaseService) release := &models.Release{Version: "v1.0.0"} mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("ai error")) err := runPreviewTest(t, []string{}, mockService) diff --git a/internal/cli/command/release/publish.go b/internal/cli/command/release/publish.go index 485e71f..ded242c 100644 --- a/internal/cli/command/release/publish.go +++ b/internal/cli/command/release/publish.go @@ -49,6 +49,12 @@ func publishReleaseAction(releaseService ports.ReleaseService, trans *i18n.Trans release.Version = version } + if err := releaseService.EnrichReleaseContext(ctx, release); err != nil { + fmt.Printf("⚠️ %s\n", trans.GetMessage("release.warning_enrich_context", 0, map[string]interface{}{ + "Error": err.Error(), + })) + } + notes, err := releaseService.GenerateReleaseNotes(ctx, release) if err != nil { return fmt.Errorf("%s", trans.GetMessage("release.error_generating_notes", 0, map[string]interface{}{ diff --git a/internal/cli/command/release/publish_test.go b/internal/cli/command/release/publish_test.go index 91eaf95..eb55f44 100644 --- a/internal/cli/command/release/publish_test.go +++ b/internal/cli/command/release/publish_test.go @@ -53,6 +53,7 @@ func TestPublishCommand_Success(t *testing.T) { } mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("PublishRelease", mock.Anything, release, notes, false).Return(nil) @@ -77,6 +78,7 @@ func TestPublishCommand_WithDraftFlag(t *testing.T) { } mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("PublishRelease", mock.Anything, release, notes, true).Return(nil) @@ -101,6 +103,7 @@ func TestPublishCommand_WithVersionOverride(t *testing.T) { } mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, mock.MatchedBy(func(r *models.Release) bool { return r.Version == "v2.0.0" })).Return(notes, nil) @@ -136,6 +139,7 @@ func TestPublishCommand_GenerateNotesError(t *testing.T) { } mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return((*models.ReleaseNotes)(nil), errors.New("generate failed")) err := runPublishTest(t, []string{}, mockService) @@ -160,6 +164,7 @@ func TestPublishCommand_PublishError(t *testing.T) { } mockService.On("AnalyzeNextRelease", mock.Anything).Return(release, nil) + mockService.On("EnrichReleaseContext", mock.Anything, mock.Anything).Return(nil) mockService.On("GenerateReleaseNotes", mock.Anything, release).Return(notes, nil) mockService.On("PublishRelease", mock.Anything, release, notes, false).Return(errors.New("publish failed")) diff --git a/internal/domain/models/release.go b/internal/domain/models/release.go index 97645c0..f899636 100644 --- a/internal/domain/models/release.go +++ b/internal/domain/models/release.go @@ -18,6 +18,49 @@ type ( Other []ReleaseItem AllCommits []Commit VersionBump VersionBump + ClosedIssues []Issue + MergedPRs []PullRequest + Contributors []string + NewContributors []string + Dependencies []DependencyChange + FileStats FileStatistics + } + + Issue struct { + Number int + Title string + Labels []string + Author string + URL string + } + + PullRequest struct { + Number int + Title string + Description string + Author string + Labels []string + URL string + } + + DependencyChange struct { + Name string + OldVersion string + NewVersion string + Type string + } + + FileStatistics struct { + FilesChanged int + Insertions int + Deletions int + TopFiles []FileChange + } + + FileChange struct { + Path string + Additions int + Deletions int } // ReleaseItem representa un item en el changelog diff --git a/internal/domain/ports/release_service.go b/internal/domain/ports/release_service.go index 2d4cd21..32b28ce 100644 --- a/internal/domain/ports/release_service.go +++ b/internal/domain/ports/release_service.go @@ -27,6 +27,9 @@ type ReleaseService interface { // UpdateRelease actualiza una release del repositorio UpdateRelease(ctx context.Context, version, body string) error + + // EnrichReleaseContext enriquece el release con información adicional de GitHub (issues, PRs, contributors, etc.) + EnrichReleaseContext(ctx context.Context, release *models.Release) error } type ReleaseNotesGenerator interface { diff --git a/internal/domain/ports/vcs_client.go b/internal/domain/ports/vcs_client.go index b7ca43d..13663cb 100644 --- a/internal/domain/ports/vcs_client.go +++ b/internal/domain/ports/vcs_client.go @@ -24,4 +24,8 @@ type VCSClient interface { GetRelease(ctx context.Context, version string) (*models.VCSRelease, error) // UpdateRelease actualiza una release del repositorio UpdateRelease(ctx context.Context, version, body string) error + GetClosedIssuesBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.Issue, error) + GetMergedPRsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.PullRequest, error) + GetContributorsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]string, error) + GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) } diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index e47dd6e..307d1a8 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -360,6 +360,14 @@ empty_body_warning = "⚠️ This release has no description. Using a basic tem template_warning = "This release has no description in GitHub" template_tip = "Tip: You can regenerate notes with 'matecommit release generate'" template_changes = "Changes" +md_contributors = "👥 Contributors" +new_contributors = "Welcome {Count} new contributors!" +all_contributors = "All contributors in this release:" +md_stats = "📊 Statistics" +files_changed = "Files changed" +insertions = "Lines added" +deletions = "Lines deleted" +warning_enrich_context = "Could not enrich release context: {Error}" # Preview diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index 26fde3e..a0a5138 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -368,7 +368,14 @@ empty_body_warning = "⚠️ Esta release no tiene descripción. Se usará un t template_warning = "Esta release no tiene descripción en GitHub" template_tip = "Tip: Podés regenerar las notas con 'matecommit release generate'" template_changes = "Cambios" - +md_contributors = "👥 Contributors" +new_contributors = "¡Damos la bienvenida a {Count} nuevos contributors!" +all_contributors = "Todos los contributors de este release:" +md_stats = "📊 Estadísticas" +files_changed = "Archivos modificados" +insertions = "Líneas agregadas" +deletions = "Líneas eliminadas" +warning_enrich_context = "No se pudo enriquecer el contexto del release: {Error}" # Preview preview_usage = "Ver vista previa del siguiente release sin crearlo" diff --git a/internal/infrastructure/ai/gemini/release_generator.go b/internal/infrastructure/ai/gemini/release_generator.go index ab2a779..ef63783 100644 --- a/internal/infrastructure/ai/gemini/release_generator.go +++ b/internal/infrastructure/ai/gemini/release_generator.go @@ -127,6 +127,67 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) sb.WriteString("\n") } + if len(release.ClosedIssues) > 0 { + sb.WriteString("CLOSED ISSUES:\n") + for _, issue := range release.ClosedIssues { + sb.WriteString(fmt.Sprintf("- #%d: %s (by @%s)\n", issue.Number, issue.Title, issue.Author)) + } + sb.WriteString("\n") + } + + if len(release.MergedPRs) > 0 { + sb.WriteString("MERGED PULL REQUESTS:\n") + for _, pr := range release.MergedPRs { + sb.WriteString(fmt.Sprintf("- #%d: %s (by @%s)\n", pr.Number, pr.Title, pr.Author)) + if pr.Description != "" { + lines := strings.Split(pr.Description, "\n") + if len(lines) > 0 && lines[0] != "" { + sb.WriteString(fmt.Sprintf(" Description: %s\n", lines[0])) + } + } + } + sb.WriteString("\n") + } + + if len(release.Contributors) > 0 { + sb.WriteString(fmt.Sprintf("CONTRIBUTORS (%d total):\n", len(release.Contributors))) + for _, contributor := range release.Contributors { + sb.WriteString(fmt.Sprintf("- @%s\n", contributor)) + } + if len(release.NewContributors) > 0 { + sb.WriteString(fmt.Sprintf("New contributors: %s\n", strings.Join(release.NewContributors, ", "))) + } + sb.WriteString("\n") + } + + if release.FileStats.FilesChanged > 0 { + sb.WriteString("FILE STATISTICS:\n") + sb.WriteString(fmt.Sprintf("- Files changed: %d\n", release.FileStats.FilesChanged)) + sb.WriteString(fmt.Sprintf("- Insertions: +%d\n", release.FileStats.Insertions)) + sb.WriteString(fmt.Sprintf("- Deletions: -%d\n", release.FileStats.Deletions)) + if len(release.FileStats.TopFiles) > 0 { + sb.WriteString("Top modified files:\n") + for _, file := range release.FileStats.TopFiles { + sb.WriteString(fmt.Sprintf(" - %s (+%d/-%d)\n", file.Path, file.Additions, file.Deletions)) + } + } + sb.WriteString("\n") + } + + if len(release.Dependencies) > 0 { + sb.WriteString("DEPENDENCY UPDATES:\n") + for _, dep := range release.Dependencies { + if dep.Type == "updated" { + sb.WriteString(fmt.Sprintf("- %s: %s → %s\n", dep.Name, dep.OldVersion, dep.NewVersion)) + } else if dep.Type == "added" { + sb.WriteString(fmt.Sprintf("- Added: %s %s\n", dep.Name, dep.NewVersion)) + } else if dep.Type == "removed" { + sb.WriteString(fmt.Sprintf("- Removed: %s %s\n", dep.Name, dep.OldVersion)) + } + } + sb.WriteString("\n") + } + return sb.String() } @@ -213,17 +274,13 @@ func (g *ReleaseNotesGenerator) parseResponse(content string, release *models.Re } else if strings.HasPrefix(trimmed, "LANGUAGE:") { currentExample.Language = strings.TrimSpace(strings.TrimPrefix(trimmed, "LANGUAGE:")) } else if strings.HasPrefix(trimmed, "CODE:") { - // Capturar el contenido después de CODE: (puede estar vacío) codeContent := strings.TrimSpace(strings.TrimPrefix(trimmed, "CODE:")) - // Limpiar backticks de markdown si los hay if strings.HasPrefix(codeContent, "```") { codeContent = "" } currentExample.Code = codeContent } else if trimmed != "" && !strings.HasPrefix(trimmed, "EXAMPLE_") && !strings.HasPrefix(trimmed, "BREAKING_") && !strings.HasPrefix(trimmed, "COMPARISONS:") && !strings.HasPrefix(trimmed, "LINKS:") { - // Capturar líneas adicionales de código (saltar backticks de markdown) if trimmed == "```" || strings.HasPrefix(trimmed, "```") { - // Ignorar líneas de backticks } else if currentExample.Code == "" { currentExample.Code = line } else { diff --git a/internal/infrastructure/ai/prompts.go b/internal/infrastructure/ai/prompts.go index d3d060a..5ca8d31 100644 --- a/internal/infrastructure/ai/prompts.go +++ b/internal/infrastructure/ai/prompts.go @@ -258,84 +258,101 @@ const ( const ( releasePromptTemplateES = ` Sos un desarrollador escribiendo las release notes de tu proyecto en primera persona. - Usá un tono técnico pero cercano, explicando qué hiciste en esta versión. + Usá un tono técnico pero cercano, explicando qué hiciste en esta versión. - Repositorio: %s/%s - Versión anterior: %s - Nueva versión: %s - Tipo de bump: %s + Repositorio: %s/%s + Versión anterior: %s + Nueva versión: %s + Tipo de bump: %s - Cambios en este release: + Cambios en este release: - %s + %s - REGLAS DE ESTILO: - - Primera persona: "Implementé", "Mejoré", "Arreglé", "Agregué" - - Voseo natural: "podés", "tenés", "querés" (en vez de "puedes", "tienes", "quieres") - - Expresiones naturales: "mucho más simple", "ahora funciona mejor", "sin vueltas" - - Tono profesional pero directo, como si le explicaras a un colega - - Sé técnico y preciso, pero accesible - - NO uses emojis en el contenido de las release notes - - REGLAS CRÍTICAS - PREVENCIÓN DE ALUCINACIONES: - 1. Basate EXCLUSIVAMENTE en los commits listados arriba en "Cambios en este release" - 2. Si la sección de cambios está vacía o solo tiene cambios menores (ej: bump de versión), escribí un resumen breve y honesto - 3. NO inventes features, comandos, flags, o funcionalidades que no aparezcan explícitamente en los commits - 4. NO menciones "validadores", "linters", "nuevas opciones" u otras features a menos que estén en los commits - 5. Si no hay suficiente información para un ejemplo específico, usá ejemplos genéricos del uso básico del proyecto - 6. Para EXAMPLES, solo mostrá comandos que realmente existan según los commits. Si no hay cambios significativos, mostrá el uso básico existente - 7. Para COMPARISONS, solo incluí comparaciones si hay cambios concretos que comparar. Si no hay, usá "N/A" o una comparación genérica de versiones - 8. Si los cambios son principalmente internos o de mantenimiento, decilo claramente en vez de inventar features visibles al usuario - - VALIDACIÓN DE CONTENIDO: - Antes de escribir cada sección, preguntate: "¿Este detalle específico está en los commits que me pasaron?" - Si la respuesta es NO, no lo incluyas. - - Formato de respuesta (IMPORTANTE: Incluí TODAS las secciones): - - TÍTULO: - - RESUMEN: <2-3 oraciones en primera persona contando los cambios más importantes. Si no hay cambios significativos, sé honesto al respecto> - - HIGHLIGHTS: - - - - - - - (Si no hay suficientes highlights reales, enfocate en mantenimiento, estabilidad o preparación para futuras features) - - QUICK_START: - - IMPORTANTE: Este proyecto es un CLI de Go. Usá "go install github.com/%s/%s@" para instalación. - No inventes flags o comandos que no existan. - - EXAMPLES: - EXAMPLE_1: - TITLE: - DESCRIPTION: - LANGUAGE: bash - CODE: - - EXAMPLE_2: - TITLE: - DESCRIPTION: - LANGUAGE: bash - CODE: - (Solo incluí ejemplos de funcionalidad que realmente exista. Si no hay nuevas features, mostrá el uso básico existente) - - BREAKING_CHANGES: - - - (Solo listá breaking changes si están explícitamente mencionados en los commits) - - COMPARISONS: - COMPARISON_1: - FEATURE: - BEFORE: - AFTER: - (Si no hay comparaciones concretas basadas en los commits, usá "N/A" o una comparación genérica de versiones) - - LINKS: - (Solo incluí links si son relevantes para esta release específica, como issues cerrados o PRs relacionados. Si no hay links relevantes, poné "N/A") - ` + IMPORTANTE - CONTEXTO ADICIONAL: + El listado anterior incluye no solo commits, sino también: + - Issues cerrados: Problemas reportados por usuarios que fueron resueltos + - Pull Requests mergeados: Contribuciones de la comunidad o del equipo + - Contributors: Personas que participaron en este release + - Estadísticas de archivos: Magnitud de los cambios + - Actualizaciones de dependencias: Librerías actualizadas + + Usá esta información para: + 1. Dar crédito a contributors mencionándolos por username (@usuario) + 2. Referenciar issues/PRs específicos cuando sean relevantes (#123) + 3. Mencionar áreas del código más afectadas según las estadísticas + 4. Destacar contribuciones de la comunidad si hay nuevos contributors + 5. Mencionar upgrades importantes de dependencias si afectan al usuario + + REGLAS DE ESTILO: + - Primera persona: "Implementé", "Mejoré", "Arreglé", "Agregué" + - Voseo natural: "podés", "tenés", "querés" (en vez de "puedes", "tienes", "quieres") + - Expresiones naturales: "mucho más simple", "ahora funciona mejor", "sin vueltas" + - Tono profesional pero directo, como si le explicaras a un colega + - Sé técnico y preciso, pero accesible + - NO uses emojis en el contenido de las release notes + - Dá crédito: "Gracias a @usuario por reportar/contribuir" + + REGLAS CRÍTICAS - PREVENCIÓN DE ALUCINACIONES: + 1. Basate EXCLUSIVAMENTE en los commits, issues, PRs listados arriba + 2. Si la sección de cambios está vacía o solo tiene cambios menores, escribí un resumen breve y honesto + 3. NO inventes features, comandos, flags, o funcionalidades que no aparezcan explícitamente + 4. Solo mencioná issues/PRs que estén en el listado + 5. Solo mencioná contributors que estén en el listado + 6. Si no hay suficiente información, sé honesto y simple + 7. Para EXAMPLES, solo mostrá comandos que realmente existan + 8. Si los cambios son principalmente internos, decilo claramente + + VALIDACIÓN DE CONTENIDO: + Antes de escribir cada sección, preguntate: "¿Este detalle específico está en la información que me pasaron?" + Si la respuesta es NO, no lo incluyas. + + Formato de respuesta (IMPORTANTE: Incluí TODAS las secciones): + + TÍTULO: + + RESUMEN: <2-3 oraciones en primera persona contando los cambios más importantes. Mencioná contributors clave si corresponde> + + HIGHLIGHTS: + - + - + - + (Si hay nuevos contributors o muchos issues cerrados, podés incluirlo como highlight) + + QUICK_START: + + IMPORTANTE: Este proyecto es un CLI de Go. Usá "go install github.com/%s/%s@" para instalación. + + EXAMPLES: + EXAMPLE_1: + TITLE: + DESCRIPTION: + LANGUAGE: bash + CODE: + + EXAMPLE_2: + TITLE: + DESCRIPTION: + LANGUAGE: bash + CODE: + + BREAKING_CHANGES: + - + + COMPARISONS: + COMPARISON_1: + FEATURE: + BEFORE: + AFTER: + + CONTRIBUTORS: + + (Si hay contributors listados arriba, incluí esta sección. Si no, poné "N/A") + + LINKS: + - Closed Issues: + - Merged PRs: + ` releasePromptTemplateEN = ` You are a developer writing release notes for your project in first person. diff --git a/internal/infrastructure/vcs/github/github_service.go b/internal/infrastructure/vcs/github/github_service.go index ce3d880..ba2cc13 100644 --- a/internal/infrastructure/vcs/github/github_service.go +++ b/internal/infrastructure/vcs/github/github_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "sort" "strings" "github.com/Tomas-vilte/MateCommit/internal/domain/models" @@ -17,6 +18,7 @@ var _ ports.VCSClient = (*GitHubClient)(nil) type PullRequestsService interface { Edit(ctx context.Context, owner, repo string, number int, pr *github.PullRequest) (*github.PullRequest, *github.Response, error) + List(ctx context.Context, owner, repo string, opts *github.PullRequestListOptions) ([]*github.PullRequest, *github.Response, error) Get(ctx context.Context, owner, repo string, number int) (*github.PullRequest, *github.Response, error) ListCommits(ctx context.Context, owner, repo string, number int, opts *github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) GetRaw(ctx context.Context, owner, repo string, number int, opts github.RawOptions) (string, *github.Response, error) @@ -26,10 +28,12 @@ type IssuesService interface { ListLabels(ctx context.Context, owner, repo string, opts *github.ListOptions) ([]*github.Label, *github.Response, error) CreateLabel(ctx context.Context, owner, repo string, label *github.Label) (*github.Label, *github.Response, error) AddLabelsToIssue(ctx context.Context, owner, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) + ListByRepo(ctx context.Context, owner, repo string, opts *github.IssueListByRepoOptions) ([]*github.Issue, *github.Response, error) } type RepositoriesService interface { GetCommit(ctx context.Context, owner, repo, sha string, opts *github.ListOptions) (*github.RepositoryCommit, *github.Response, error) + CompareCommits(ctx context.Context, owner, repo, base, head string, opts *github.ListOptions) (*github.CommitsComparison, *github.Response, error) } type ReleasesService interface { @@ -290,13 +294,170 @@ func (ghc *GitHubClient) UpdateRelease(ctx context.Context, version, body string _, _, err = ghc.releaseService.EditRelease(ctx, ghc.owner, ghc.repo, release.GetID(), releaseUpdate) if err != nil { - fmt.Printf("DEBUG: Error updating release: %v\n", err) return err } - fmt.Printf("DEBUG: Release updated successfully\n") return nil } +func (ghc *GitHubClient) GetClosedIssuesBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.Issue, error) { + prevRelease, _, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, previousTag) + if err != nil { + return nil, err + } + + opts := &github.IssueListByRepoOptions{ + State: "closed", + Since: prevRelease.GetCreatedAt().Time, + Sort: "updated", + Direction: "desc", + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + + var allIssues []models.Issue + for { + issues, resp, err := ghc.issuesService.ListByRepo(ctx, ghc.owner, ghc.repo, opts) + if err != nil { + return nil, err + } + + for _, issue := range issues { + if issue.PullRequestLinks == nil { + labels := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + labels = append(labels, label.GetName()) + } + + allIssues = append(allIssues, models.Issue{ + Number: issue.GetNumber(), + Title: issue.GetTitle(), + Labels: labels, + Author: issue.GetUser().GetLogin(), + URL: issue.GetHTMLURL(), + }) + } + } + + if resp.NextPage == 0 { + break + } + opts.ListOptions.Page = resp.NextPage + } + + return allIssues, nil +} + +func (ghc *GitHubClient) GetMergedPRsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.PullRequest, error) { + prevRelease, _, err := ghc.releaseService.GetReleaseByTag(ctx, ghc.owner, ghc.repo, previousTag) + if err != nil { + return nil, err + } + + opts := &github.PullRequestListOptions{ + State: "closed", + Sort: "updated", + Direction: "desc", + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + var allPRs []models.PullRequest + for { + prs, resp, err := ghc.prService.List(ctx, ghc.owner, ghc.repo, opts) + if err != nil { + return nil, err + } + + for _, pr := range prs { + if pr.GetMerged() && pr.GetMergedAt().After(prevRelease.GetCreatedAt().Time) { + labels := make([]string, 0, len(pr.Labels)) + for _, label := range pr.Labels { + labels = append(labels, label.GetName()) + } + + allPRs = append(allPRs, models.PullRequest{ + Number: pr.GetNumber(), + Title: pr.GetTitle(), + Description: pr.GetBody(), + Author: pr.GetUser().GetLogin(), + Labels: labels, + URL: pr.GetHTMLURL(), + }) + } + } + + if resp.NextPage == 0 { + break + } + opts.ListOptions.Page = resp.NextPage + } + return allPRs, nil +} + +func (ghc *GitHubClient) GetContributorsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]string, error) { + comparison, _, err := ghc.repoService.CompareCommits(ctx, ghc.owner, ghc.repo, previousTag, currentTag, &github.ListOptions{ + PerPage: 100, + }) + if err != nil { + return nil, err + } + + contributorsMap := make(map[string]struct{}) + for _, commit := range comparison.Commits { + if author := commit.GetAuthor(); author != nil { + contributorsMap[author.GetLogin()] = struct{}{} + } + } + + contributors := make([]string, 0, len(contributorsMap)) + for contributor := range contributorsMap { + contributors = append(contributors, contributor) + } + return contributors, nil +} + +func (ghc *GitHubClient) GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) { + comparison, _, err := ghc.repoService.CompareCommits(ctx, ghc.owner, ghc.repo, previousTag, currentTag, &github.ListOptions{ + PerPage: 100, + }) + if err != nil { + return nil, err + } + + stats := &models.FileStatistics{ + FilesChanged: len(comparison.Files), + Insertions: 0, + Deletions: 0, + TopFiles: make([]models.FileChange, 0), + } + + fileChanges := make([]models.FileChange, 0, len(comparison.Files)) + for _, file := range comparison.Files { + stats.Insertions += file.GetAdditions() + stats.Deletions += file.GetDeletions() + + fileChanges = append(fileChanges, models.FileChange{ + Path: file.GetFilename(), + Additions: file.GetAdditions(), + Deletions: file.GetDeletions(), + }) + } + + sort.Slice(fileChanges, func(i, j int) bool { + totalI := fileChanges[i].Additions + fileChanges[i].Deletions + totalJ := fileChanges[j].Additions + fileChanges[j].Deletions + return totalI > totalJ + }) + + if len(fileChanges) > 5 { + stats.TopFiles = fileChanges[:5] + } else { + stats.TopFiles = fileChanges + } + return stats, nil +} + func (ghc *GitHubClient) labelExists(existingLabels []string, target string) bool { for _, l := range existingLabels { if strings.EqualFold(l, target) { diff --git a/internal/infrastructure/vcs/github/mocks.go b/internal/infrastructure/vcs/github/mocks.go index 025aeb5..834f258 100644 --- a/internal/infrastructure/vcs/github/mocks.go +++ b/internal/infrastructure/vcs/github/mocks.go @@ -34,6 +34,11 @@ func (m *MockPRService) GetRaw(ctx context.Context, owner, repo string, number i return args.String(0), args.Get(1).(*github.Response), args.Error(2) } +func (m *MockPRService) List(ctx context.Context, owner, repo string, opts *github.PullRequestListOptions) ([]*github.PullRequest, *github.Response, error) { + args := m.Called(ctx, owner, repo, opts) + return args.Get(0).([]*github.PullRequest), args.Get(1).(*github.Response), args.Error(2) +} + type MockIssuesService struct { mock.Mock } @@ -53,6 +58,11 @@ func (m *MockIssuesService) AddLabelsToIssue(ctx context.Context, owner, repo st return args.Get(0).([]*github.Label), args.Get(1).(*github.Response), args.Error(2) } +func (m *MockIssuesService) ListByRepo(ctx context.Context, owner, repo string, opts *github.IssueListByRepoOptions) ([]*github.Issue, *github.Response, error) { + args := m.Called(ctx, owner, repo, opts) + return args.Get(0).([]*github.Issue), args.Get(1).(*github.Response), args.Error(2) +} + type MockRepoService struct { mock.Mock } @@ -62,6 +72,11 @@ func (m *MockRepoService) GetCommit(ctx context.Context, owner, repo, sha string return args.Get(0).(*github.RepositoryCommit), args.Get(1).(*github.Response), args.Error(2) } +func (m *MockRepoService) CompareCommits(ctx context.Context, owner, repo, base, head string, opts *github.ListOptions) (*github.CommitsComparison, *github.Response, error) { + args := m.Called(ctx, owner, repo, base, head, opts) + return args.Get(0).(*github.CommitsComparison), args.Get(1).(*github.Response), args.Error(2) +} + type MockReleaseService struct { mock.Mock } diff --git a/internal/services/pr_service_test.go b/internal/services/pr_service_test.go index 032d5bd..09d0017 100644 --- a/internal/services/pr_service_test.go +++ b/internal/services/pr_service_test.go @@ -60,6 +60,26 @@ func (m *MockVCSClient) UpdateRelease(ctx context.Context, version, body string) return args.Error(0) } +func (m *MockVCSClient) GetClosedIssuesBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.Issue, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).([]models.Issue), args.Error(1) +} + +func (m *MockVCSClient) GetMergedPRsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.PullRequest, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).([]models.PullRequest), args.Error(1) +} + +func (m *MockVCSClient) GetContributorsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]string, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockVCSClient) GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).(*models.FileStatistics), args.Error(1) +} + type MockPRSummarizer struct { mock.Mock } diff --git a/internal/services/release_service.go b/internal/services/release_service.go index 0080064..c44cf50 100644 --- a/internal/services/release_service.go +++ b/internal/services/release_service.go @@ -15,23 +15,23 @@ import ( var _ ports.ReleaseService = (*ReleaseService)(nil) type ReleaseService struct { - git ports.GitService - vcs ports.VCSClient - notesGen ports.ReleaseNotesGenerator - trans *i18n.Translations + git ports.GitService + vcsClient ports.VCSClient + notesGen ports.ReleaseNotesGenerator + trans *i18n.Translations } func NewReleaseService( git ports.GitService, - vcs ports.VCSClient, + vcsClient ports.VCSClient, notesGen ports.ReleaseNotesGenerator, trans *i18n.Translations, ) *ReleaseService { return &ReleaseService{ - git: git, - vcs: vcs, - notesGen: notesGen, - trans: trans, + git: git, + vcsClient: vcsClient, + notesGen: notesGen, + trans: trans, } } @@ -81,10 +81,10 @@ func (s *ReleaseService) GenerateReleaseNotes(ctx context.Context, release *mode } func (s *ReleaseService) PublishRelease(ctx context.Context, release *models.Release, notes *models.ReleaseNotes, draft bool) error { - if s.vcs == nil { - return fmt.Errorf("cliente VCS no configurado. Configura un proveedor VCS con 'matecommit config set-vcs'") + if s.vcsClient == nil { + return fmt.Errorf("cliente VCS no configurado. Configura un proveedor VCS con 'matecommit config set-vcsClient'") } - return s.vcs.CreateRelease(ctx, release, notes, draft) + return s.vcsClient.CreateRelease(ctx, release, notes, draft) } func (s *ReleaseService) CreateTag(ctx context.Context, version, message string) error { @@ -96,21 +96,58 @@ func (s *ReleaseService) PushTag(ctx context.Context, version string) error { } func (s *ReleaseService) GetRelease(ctx context.Context, version string) (*models.VCSRelease, error) { - if s.vcs == nil { + if s.vcsClient == nil { return nil, fmt.Errorf("%s", s.trans.GetMessage("error.vcs_provider_not_configured", 0, map[string]interface{}{ "Provider": "VCS", })) } - return s.vcs.GetRelease(ctx, version) + return s.vcsClient.GetRelease(ctx, version) } func (s *ReleaseService) UpdateRelease(ctx context.Context, version, body string) error { - if s.vcs == nil { + if s.vcsClient == nil { return fmt.Errorf("%s", s.trans.GetMessage("error.vcs_provider_not_configured", 0, map[string]interface{}{ "Provider": "VCS", })) } - return s.vcs.UpdateRelease(ctx, version, body) + return s.vcsClient.UpdateRelease(ctx, version, body) +} + +func (s *ReleaseService) EnrichReleaseContext(ctx context.Context, release *models.Release) error { + if s.vcsClient == nil { + return fmt.Errorf("%s", s.trans.GetMessage("error.vcs_provider_not_configured", 0, map[string]interface{}{ + "Provider": "VCS", + })) + } + + if issues, err := s.vcsClient.GetClosedIssuesBetweenTags(ctx, release.PreviousVersion, release.Version); err == nil { + release.ClosedIssues = issues + } + + if prs, err := s.vcsClient.GetMergedPRsBetweenTags(ctx, release.PreviousVersion, release.Version); err == nil { + release.MergedPRs = prs + } + + if contributors, err := s.vcsClient.GetContributorsBetweenTags(ctx, release.PreviousVersion, release.Version); err == nil { + release.Contributors = contributors + release.NewContributors = contributors + } + + if stats, err := s.vcsClient.GetFileStatsBetweenTags(ctx, release.PreviousVersion, release.Version); err == nil { + release.FileStats = *stats + } + + if deps, err := s.analyzeDependencyChanges(ctx, release); err == nil { + release.Dependencies = deps + } + + return nil +} + +func (s *ReleaseService) analyzeDependencyChanges(ctx context.Context, release *models.Release) ([]models.DependencyChange, error) { + // Esta función analizaría cambios en go.mod entre versiones + // Por ahora retornar vacío + return []models.DependencyChange{}, nil } // categorizeCommits categoriza los commits según conventional commits From 14eeb28f3fb28b0ba0a65cc39de57ec7e8210c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Thu, 11 Dec 2025 18:09:05 -0300 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20Implementa=20an=C3=A1lisis=20de=20c?= =?UTF-8?q?ambios=20de=20dependencia=20para=20releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/domain/models/release.go | 29 ++- .../ai/gemini/release_generator.go | 7 +- internal/services/release_service.go | 26 +-- internal/services/release_service_test.go | 175 ++++++++++++++++++ 4 files changed, 219 insertions(+), 18 deletions(-) diff --git a/internal/domain/models/release.go b/internal/domain/models/release.go index f899636..6e4c23b 100644 --- a/internal/domain/models/release.go +++ b/internal/domain/models/release.go @@ -43,13 +43,21 @@ type ( URL string } + // DependencyChange representa un cambio en una dependencia del proyecto DependencyChange struct { - Name string - OldVersion string - NewVersion string - Type string + Name string // Nombre del paquete (ej: "github.com/user/repo", "react") + OldVersion string // Versión anterior (vacío si Type == DependencyAdded) + NewVersion string // Versión nueva (vacío si Type == DependencyRemoved) + Type DependencyChangeType // Tipo de cambio + Manager string // Gestor: "go.mod", "package.json", "Cargo.toml", etc. + Severity ChangeSeverity // Severidad: major/minor/patch para detectar breaking changes + IsDirect bool // true = dependencia directa, false = dev/indirect } + DependencyChangeType string + + ChangeSeverity string + FileStatistics struct { FilesChanged int Insertions int @@ -120,3 +128,16 @@ const ( PatchBump VersionBump = "patch" NoBump VersionBump = "none" ) + +const ( + DependencyAdded DependencyChangeType = "added" + DependencyUpdated DependencyChangeType = "updated" + DependencyRemoved DependencyChangeType = "removed" +) + +const ( + MajorChange ChangeSeverity = "major" + MinorChange ChangeSeverity = "minor" + PatchChange ChangeSeverity = "patch" + UnknownChange ChangeSeverity = "unknown" +) diff --git a/internal/infrastructure/ai/gemini/release_generator.go b/internal/infrastructure/ai/gemini/release_generator.go index ef63783..de458d7 100644 --- a/internal/infrastructure/ai/gemini/release_generator.go +++ b/internal/infrastructure/ai/gemini/release_generator.go @@ -177,11 +177,12 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) if len(release.Dependencies) > 0 { sb.WriteString("DEPENDENCY UPDATES:\n") for _, dep := range release.Dependencies { - if dep.Type == "updated" { + switch dep.Type { + case "updated": sb.WriteString(fmt.Sprintf("- %s: %s → %s\n", dep.Name, dep.OldVersion, dep.NewVersion)) - } else if dep.Type == "added" { + case "added": sb.WriteString(fmt.Sprintf("- Added: %s %s\n", dep.Name, dep.NewVersion)) - } else if dep.Type == "removed" { + case "removed": sb.WriteString(fmt.Sprintf("- Removed: %s %s\n", dep.Name, dep.OldVersion)) } } diff --git a/internal/services/release_service.go b/internal/services/release_service.go index c44cf50..aa934cc 100644 --- a/internal/services/release_service.go +++ b/internal/services/release_service.go @@ -10,15 +10,17 @@ import ( "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/dependency" ) var _ ports.ReleaseService = (*ReleaseService)(nil) type ReleaseService struct { - git ports.GitService - vcsClient ports.VCSClient - notesGen ports.ReleaseNotesGenerator - trans *i18n.Translations + git ports.GitService + vcsClient ports.VCSClient + notesGen ports.ReleaseNotesGenerator + trans *i18n.Translations + depAnalyzer *dependency.AnalyzerRegistry } func NewReleaseService( @@ -28,10 +30,11 @@ func NewReleaseService( trans *i18n.Translations, ) *ReleaseService { return &ReleaseService{ - git: git, - vcsClient: vcsClient, - notesGen: notesGen, - trans: trans, + git: git, + vcsClient: vcsClient, + notesGen: notesGen, + trans: trans, + depAnalyzer: dependency.NewAnalyzerRegistry(), } } @@ -145,9 +148,10 @@ func (s *ReleaseService) EnrichReleaseContext(ctx context.Context, release *mode } func (s *ReleaseService) analyzeDependencyChanges(ctx context.Context, release *models.Release) ([]models.DependencyChange, error) { - // Esta función analizaría cambios en go.mod entre versiones - // Por ahora retornar vacío - return []models.DependencyChange{}, nil + if s.vcsClient == nil { + return []models.DependencyChange{}, nil + } + return s.depAnalyzer.AnalyzeAll(ctx, s.vcsClient, release.PreviousVersion, release.Version) } // categorizeCommits categoriza los commits según conventional commits diff --git a/internal/services/release_service_test.go b/internal/services/release_service_test.go index 5d6032e..384a028 100644 --- a/internal/services/release_service_test.go +++ b/internal/services/release_service_test.go @@ -300,3 +300,178 @@ func TestReleaseService_UpdateRelease(t *testing.T) { mockVCS.AssertExpectations(t) }) } + +func TestReleaseService_PublishRelease(t *testing.T) { + t.Run("should publish release successfully", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := NewReleaseService(nil, mockVCS, nil, nil) + + release := &models.Release{Version: "v1.0.0"} + notes := &models.ReleaseNotes{Title: "Release v1.0.0"} + + mockVCS.On("CreateRelease", mock.Anything, release, notes, false).Return(nil) + + err := service.PublishRelease(context.Background(), release, notes, false) + + assert.NoError(t, err) + mockVCS.AssertExpectations(t) + }) + + t.Run("should return error if VCS client not configured", func(t *testing.T) { + service := NewReleaseService(nil, nil, nil, nil) + + release := &models.Release{Version: "v1.0.0"} + notes := &models.ReleaseNotes{} + + err := service.PublishRelease(context.Background(), release, notes, false) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "cliente VCS no configurado") + }) + + t.Run("should propagate VCS client error", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := NewReleaseService(nil, mockVCS, nil, nil) + + release := &models.Release{Version: "v1.0.0"} + notes := &models.ReleaseNotes{} + + mockVCS.On("CreateRelease", mock.Anything, release, notes, true).Return(errors.New("publish failed")) + + err := service.PublishRelease(context.Background(), release, notes, true) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "publish failed") + mockVCS.AssertExpectations(t) + }) +} + +func TestReleaseService_CreateTag(t *testing.T) { + t.Run("should create tag successfully", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit, nil, nil, nil) + + mockGit.On("CreateTag", mock.Anything, "v1.0.0", "Release v1.0.0").Return(nil) + + err := service.CreateTag(context.Background(), "v1.0.0", "Release v1.0.0") + + assert.NoError(t, err) + mockGit.AssertExpectations(t) + }) + + t.Run("should propagate git error", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit, nil, nil, nil) + + mockGit.On("CreateTag", mock.Anything, "v1.0.0", "Release v1.0.0").Return(errors.New("tag already exists")) + + err := service.CreateTag(context.Background(), "v1.0.0", "Release v1.0.0") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "tag already exists") + mockGit.AssertExpectations(t) + }) +} + +func TestReleaseService_PushTag(t *testing.T) { + t.Run("should push tag successfully", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit, nil, nil, nil) + + mockGit.On("PushTag", mock.Anything, "v1.0.0").Return(nil) + + err := service.PushTag(context.Background(), "v1.0.0") + + assert.NoError(t, err) + mockGit.AssertExpectations(t) + }) + + t.Run("should propagate git error", func(t *testing.T) { + mockGit := new(MockGitService) + service := NewReleaseService(mockGit, nil, nil, nil) + + mockGit.On("PushTag", mock.Anything, "v1.0.0").Return(errors.New("push failed")) + + err := service.PushTag(context.Background(), "v1.0.0") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "push failed") + mockGit.AssertExpectations(t) + }) +} + +func TestReleaseService_EnrichReleaseContext(t *testing.T) { + t.Run("should enrich release context successfully", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := NewReleaseService(nil, mockVCS, nil, nil) + + release := &models.Release{ + PreviousVersion: "v1.0.0", + Version: "v1.1.0", + } + + mockVCS.On("GetClosedIssuesBetweenTags", mock.Anything, "v1.0.0", "v1.1.0"). + Return([]models.Issue{{Number: 1, Title: "Issue 1"}}, nil) + mockVCS.On("GetMergedPRsBetweenTags", mock.Anything, "v1.0.0", "v1.1.0"). + Return([]models.PullRequest{{Number: 2, Title: "PR 1"}}, nil) + mockVCS.On("GetContributorsBetweenTags", mock.Anything, "v1.0.0", "v1.1.0"). + Return([]string{"user1", "user2"}, nil) + mockVCS.On("GetFileStatsBetweenTags", mock.Anything, "v1.0.0", "v1.1.0"). + Return(&models.FileStatistics{FilesChanged: 5}, nil) + mockVCS.On("GetFileAtTag", mock.Anything, mock.Anything, mock.Anything). + Return("", errors.New("not found")) + + err := service.EnrichReleaseContext(context.Background(), release) + + assert.NoError(t, err) + assert.Len(t, release.ClosedIssues, 1) + assert.Len(t, release.MergedPRs, 1) + assert.Len(t, release.Contributors, 2) + assert.Equal(t, 5, release.FileStats.FilesChanged) + mockVCS.AssertExpectations(t) + }) + + t.Run("should return error if VCS client not configured", func(t *testing.T) { + trans, err := i18n.NewTranslations("en", "../i18n/locales") + if err != nil { + trans, _ = i18n.NewTranslations("en", "../../i18n/locales") + } + service := NewReleaseService(nil, nil, nil, trans) + + release := &models.Release{} + + err = service.EnrichReleaseContext(context.Background(), release) + + assert.Error(t, err) + }) + + t.Run("should continue even if some enrichments fail", func(t *testing.T) { + mockVCS := new(MockVCSClient) + service := NewReleaseService(nil, mockVCS, nil, nil) + + release := &models.Release{ + PreviousVersion: "v1.0.0", + Version: "v1.1.0", + } + + mockVCS.On("GetClosedIssuesBetweenTags", mock.Anything, "v1.0.0", "v1.1.0"). + Return([]models.Issue{}, errors.New("api error")) + mockVCS.On("GetMergedPRsBetweenTags", mock.Anything, "v1.0.0", "v1.1.0"). + Return([]models.PullRequest{{Number: 1}}, nil) + mockVCS.On("GetContributorsBetweenTags", mock.Anything, "v1.0.0", "v1.1.0"). + Return([]string{}, errors.New("api error")) + mockVCS.On("GetFileStatsBetweenTags", mock.Anything, "v1.0.0", "v1.1.0"). + Return(&models.FileStatistics{FilesChanged: 3}, nil) + mockVCS.On("GetFileAtTag", mock.Anything, mock.Anything, mock.Anything). + Return("", errors.New("not found")) + + err := service.EnrichReleaseContext(context.Background(), release) + + assert.NoError(t, err) + assert.Len(t, release.ClosedIssues, 0) + assert.Len(t, release.MergedPRs, 1) + assert.Len(t, release.Contributors, 0) + assert.Equal(t, 3, release.FileStats.FilesChanged) + mockVCS.AssertExpectations(t) + }) +} From 686e1467f6974c4d5a85a77b85949127ef50f269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Thu, 11 Dec 2025 18:09:49 -0300 Subject: [PATCH 3/4] feat: Add VCSClient.GetFileAtTag to retrieve tagged file content --- internal/domain/ports/vcs_client.go | 6 ++ .../vcs/github/github_service.go | 23 ++++++ .../vcs/github/github_service_test.go | 73 +++++++++++++++++++ internal/infrastructure/vcs/github/mocks.go | 5 ++ internal/services/pr_service_test.go | 5 ++ 5 files changed, 112 insertions(+) diff --git a/internal/domain/ports/vcs_client.go b/internal/domain/ports/vcs_client.go index 13663cb..46c7e19 100644 --- a/internal/domain/ports/vcs_client.go +++ b/internal/domain/ports/vcs_client.go @@ -24,8 +24,14 @@ type VCSClient interface { GetRelease(ctx context.Context, version string) (*models.VCSRelease, error) // UpdateRelease actualiza una release del repositorio UpdateRelease(ctx context.Context, version, body string) error + // GetClosedIssuesBetweenTags obtiene issues cerrados entre dos tags GetClosedIssuesBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.Issue, error) + // GetMergedPRsBetweenTags obtiene PRs mergeados entre dos tags GetMergedPRsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.PullRequest, error) + // GetContributorsBetweenTags obtiene contributors entre dos tags GetContributorsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]string, error) + // GetFileStatsBetweenTags obtiene estadísticas de archivos entre dos tags GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) + // GetFileAtTag obtiene el contenido de un archivo en un tag específico + GetFileAtTag(ctx context.Context, tag, filepath string) (string, error) } diff --git a/internal/infrastructure/vcs/github/github_service.go b/internal/infrastructure/vcs/github/github_service.go index ba2cc13..e194eed 100644 --- a/internal/infrastructure/vcs/github/github_service.go +++ b/internal/infrastructure/vcs/github/github_service.go @@ -34,6 +34,7 @@ type IssuesService interface { type RepositoriesService interface { GetCommit(ctx context.Context, owner, repo, sha string, opts *github.ListOptions) (*github.RepositoryCommit, *github.Response, error) CompareCommits(ctx context.Context, owner, repo, base, head string, opts *github.ListOptions) (*github.CommitsComparison, *github.Response, error) + GetContents(ctx context.Context, owner, repo, path string, opts *github.RepositoryContentGetOptions) (*github.RepositoryContent, []*github.RepositoryContent, *github.Response, error) } type ReleasesService interface { @@ -458,6 +459,28 @@ func (ghc *GitHubClient) GetFileStatsBetweenTags(ctx context.Context, previousTa return stats, nil } +func (ghc *GitHubClient) GetFileAtTag(ctx context.Context, tag, filepath string) (string, error) { + opts := &github.RepositoryContentGetOptions{ + Ref: tag, + } + + fileContent, _, _, err := ghc.repoService.GetContents(ctx, ghc.owner, ghc.repo, tag, opts) + if err != nil { + return "", err + } + + if fileContent == nil { + return "", fmt.Errorf("archivo no encontrado: %s en %s", filepath, tag) + } + + content, err := fileContent.GetContent() + if err != nil { + return "", fmt.Errorf("error decodificando contenido del archivo: %w", err) + } + + return content, nil +} + func (ghc *GitHubClient) labelExists(existingLabels []string, target string) bool { for _, l := range existingLabels { if strings.EqualFold(l, target) { diff --git a/internal/infrastructure/vcs/github/github_service_test.go b/internal/infrastructure/vcs/github/github_service_test.go index 46082c9..bbfc6dc 100644 --- a/internal/infrastructure/vcs/github/github_service_test.go +++ b/internal/infrastructure/vcs/github/github_service_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "errors" "net/http" "testing" @@ -27,6 +28,19 @@ func newTestClient(pr *MockPRService, issues *MockIssuesService, release *MockRe ) } +func newTestClientWithRepo(pr *MockPRService, issues *MockIssuesService, repo *MockRepoService, release *MockReleaseService) *GitHubClient { + trans, _ := i18n.NewTranslations("es", "../../../i18n/locales/") + return NewGitHubClientWithServices( + pr, + issues, + repo, + release, + "test-owner", + "test-repo", + trans, + ) +} + func TestGitHubClient_UpdatePR(t *testing.T) { t.Run("should update PR successfully", func(t *testing.T) { mockPR := &MockPRService{} @@ -571,3 +585,62 @@ func TestGitHubClient_UpdateRelease(t *testing.T) { mockRelease.AssertExpectations(t) }) } + +func TestGitHubClient_GetFileAtTag(t *testing.T) { + t.Run("should get file content successfully", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRepo := &MockRepoService{} + mockRelease := &MockReleaseService{} + client := newTestClientWithRepo(mockPR, mockIssues, mockRepo, mockRelease) + + fileContent := &github.RepositoryContent{ + Content: github.Ptr("ZmlsZSBjb250ZW50IGhlcmU="), // base64 encoded + } + + mockRepo.On("GetContents", mock.Anything, "test-owner", "test-repo", "v1.0.0", mock.MatchedBy(func(opts *github.RepositoryContentGetOptions) bool { + return opts.Ref == "v1.0.0" + })).Return(fileContent, []*github.RepositoryContent{}, &github.Response{}, nil) + + content, err := client.GetFileAtTag(context.Background(), "v1.0.0", "go.mod") + + assert.NoError(t, err) + assert.NotEmpty(t, content) + mockRepo.AssertExpectations(t) + }) + + t.Run("should return error if file not found", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRepo := &MockRepoService{} + mockRelease := &MockReleaseService{} + client := newTestClientWithRepo(mockPR, mockIssues, mockRepo, mockRelease) + + mockRepo.On("GetContents", mock.Anything, "test-owner", "test-repo", "v1.0.0", mock.Anything). + Return((*github.RepositoryContent)(nil), []*github.RepositoryContent{}, &github.Response{}, errors.New("file not found")) + + content, err := client.GetFileAtTag(context.Background(), "v1.0.0", "go.mod") + + assert.Error(t, err) + assert.Empty(t, content) + mockRepo.AssertExpectations(t) + }) + + t.Run("should return error if file content is nil", func(t *testing.T) { + mockPR := &MockPRService{} + mockIssues := &MockIssuesService{} + mockRepo := &MockRepoService{} + mockRelease := &MockReleaseService{} + client := newTestClientWithRepo(mockPR, mockIssues, mockRepo, mockRelease) + + mockRepo.On("GetContents", mock.Anything, "test-owner", "test-repo", "v1.0.0", mock.Anything). + Return((*github.RepositoryContent)(nil), []*github.RepositoryContent{}, &github.Response{}, nil) + + content, err := client.GetFileAtTag(context.Background(), "v1.0.0", "go.mod") + + assert.Error(t, err) + assert.Empty(t, content) + assert.Contains(t, err.Error(), "archivo no encontrado") + mockRepo.AssertExpectations(t) + }) +} diff --git a/internal/infrastructure/vcs/github/mocks.go b/internal/infrastructure/vcs/github/mocks.go index 834f258..098c259 100644 --- a/internal/infrastructure/vcs/github/mocks.go +++ b/internal/infrastructure/vcs/github/mocks.go @@ -77,6 +77,11 @@ func (m *MockRepoService) CompareCommits(ctx context.Context, owner, repo, base, return args.Get(0).(*github.CommitsComparison), args.Get(1).(*github.Response), args.Error(2) } +func (m *MockRepoService) GetContents(ctx context.Context, owner, repo, path string, opts *github.RepositoryContentGetOptions) (*github.RepositoryContent, []*github.RepositoryContent, *github.Response, error) { + args := m.Called(ctx, owner, repo, path, opts) + return args.Get(0).(*github.RepositoryContent), args.Get(1).([]*github.RepositoryContent), args.Get(2).(*github.Response), args.Error(3) +} + type MockReleaseService struct { mock.Mock } diff --git a/internal/services/pr_service_test.go b/internal/services/pr_service_test.go index 09d0017..d0cb172 100644 --- a/internal/services/pr_service_test.go +++ b/internal/services/pr_service_test.go @@ -80,6 +80,11 @@ func (m *MockVCSClient) GetFileStatsBetweenTags(ctx context.Context, previousTag return args.Get(0).(*models.FileStatistics), args.Error(1) } +func (m *MockVCSClient) GetFileAtTag(ctx context.Context, tag, filepath string) (string, error) { + args := m.Called(ctx, tag, filepath) + return args.String(0), args.Error(1) +} + type MockPRSummarizer struct { mock.Mock } From 2250d4f1d7706044ac1e6c7007e998eada199a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Thu, 11 Dec 2025 18:10:06 -0300 Subject: [PATCH 4/4] feat: Add multi-language dependency analysis capability --- internal/domain/ports/dependency_analyzer.go | 19 + .../dependency/gomod_analyzer.go | 205 +++++++++ .../dependency/gomod_analyzer_test.go | 405 ++++++++++++++++++ .../dependency/package_json_analyzer.go | 200 +++++++++ .../dependency/package_json_analyzer_test.go | 378 ++++++++++++++++ .../infrastructure/dependency/registry.go | 54 +++ .../dependency/registry_test.go | 261 +++++++++++ 7 files changed, 1522 insertions(+) create mode 100644 internal/domain/ports/dependency_analyzer.go create mode 100644 internal/infrastructure/dependency/gomod_analyzer.go create mode 100644 internal/infrastructure/dependency/gomod_analyzer_test.go create mode 100644 internal/infrastructure/dependency/package_json_analyzer.go create mode 100644 internal/infrastructure/dependency/package_json_analyzer_test.go create mode 100644 internal/infrastructure/dependency/registry.go create mode 100644 internal/infrastructure/dependency/registry_test.go diff --git a/internal/domain/ports/dependency_analyzer.go b/internal/domain/ports/dependency_analyzer.go new file mode 100644 index 0000000..b2ce2ee --- /dev/null +++ b/internal/domain/ports/dependency_analyzer.go @@ -0,0 +1,19 @@ +package ports + +import ( + "context" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" +) + +// DependencyAnalyzer define la interfaz para analizar dependencias de diferentes lenguajes +type DependencyAnalyzer interface { + // CanHandle detecta si este analyzer puede manejar el proyecto + CanHandle(ctx context.Context, vcsClient VCSClient, previousTag, currentTag string) bool + + // AnalyzeChanges analiza cambios de dependencias entre dos versiones + AnalyzeChanges(ctx context.Context, vcsClient VCSClient, previousTag, currentTag string) ([]models.DependencyChange, error) + + // Name retorna el nombre del gestor de dependencias + Name() string +} diff --git a/internal/infrastructure/dependency/gomod_analyzer.go b/internal/infrastructure/dependency/gomod_analyzer.go new file mode 100644 index 0000000..26c7871 --- /dev/null +++ b/internal/infrastructure/dependency/gomod_analyzer.go @@ -0,0 +1,205 @@ +package dependency + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/domain/ports" +) + +var _ ports.DependencyAnalyzer = (*GoModAnalyzer)(nil) + +type GoModAnalyzer struct{} + +func NewGoModAnalyzer() *GoModAnalyzer { + return &GoModAnalyzer{} +} + +func (g *GoModAnalyzer) Name() string { + return "go.mod" +} + +func (g *GoModAnalyzer) CanHandle(ctx context.Context, vcsClient ports.VCSClient, _, currentTag string) bool { + content, err := g.getFileContent(ctx, vcsClient, currentTag, "go.mod") + return err == nil && content != "" +} + +func (g *GoModAnalyzer) AnalyzeChanges(ctx context.Context, vcsClient ports.VCSClient, previousTag, currentTag string) ([]models.DependencyChange, error) { + oldContent, err := g.getFileContent(ctx, vcsClient, previousTag, "go.mod") + if err != nil { + return nil, fmt.Errorf("error leyendo go.mod viejo: %w", err) + } + + newContent, err := g.getFileContent(ctx, vcsClient, currentTag, "go.mod") + if err != nil { + return nil, fmt.Errorf("error leyendo go.mod nuevo: %w", err) + } + + oldDeps := g.parseGoMod(oldContent) + newDeps := g.parseGoMod(newContent) + + return g.computeChanges(oldDeps, newDeps), nil +} + +func (g *GoModAnalyzer) getFileContent(ctx context.Context, vcsClient ports.VCSClient, tag, filepath string) (string, error) { + if vcsClient == nil { + return "", fmt.Errorf("vcsClient es nil") + } + return vcsClient.GetFileAtTag(ctx, tag, filepath) +} + +type goDep struct { + version string + indirect bool +} + +func (g *GoModAnalyzer) parseGoMod(content string) map[string]goDep { + deps := make(map[string]goDep) + + // Regex para líneas dentro de require() + requireRegex := regexp.MustCompile(`^\s+(\S+)\s+v?(\S+)(\s+//\s*indirect)?`) + // Regex para líneas require individuales + singleRequireRegex := regexp.MustCompile(`^require\s+(\S+)\s+v?(\S+)(\s+//\s*indirect)?`) + + inRequire := false + lines := strings.Split(content, "\n") + + for _, line := range lines { + trimmedLine := strings.TrimSpace(line) + + if strings.HasPrefix(trimmedLine, "require (") { + inRequire = true + continue + } + + if trimmedLine == ")" { + inRequire = false + continue + } + + if inRequire { + matches := requireRegex.FindStringSubmatch(line) + if len(matches) >= 3 { + module := matches[1] + version := matches[2] + indirect := len(matches) > 3 && matches[3] != "" + + deps[module] = goDep{ + version: version, + indirect: indirect, + } + } + } else if strings.HasPrefix(trimmedLine, "require ") { + matches := singleRequireRegex.FindStringSubmatch(trimmedLine) + if len(matches) >= 3 { + module := matches[1] + version := matches[2] + indirect := len(matches) > 3 && matches[3] != "" + + deps[module] = goDep{ + version: version, + indirect: indirect, + } + } + } + } + return deps +} + +func (g *GoModAnalyzer) computeChanges(oldDeps, newDeps map[string]goDep) []models.DependencyChange { + var changes []models.DependencyChange + + for module, newDep := range newDeps { + if oldDep, exists := oldDeps[module]; exists { + if oldDep.version != newDep.version { + changes = append(changes, models.DependencyChange{ + Name: module, + OldVersion: oldDep.version, + NewVersion: newDep.version, + Type: models.DependencyUpdated, + Manager: "go.mod", + Severity: g.calculateSeverity(oldDep.version, newDep.version), + IsDirect: !newDep.indirect, + }) + } + } else { + changes = append(changes, models.DependencyChange{ + Name: module, + NewVersion: newDep.version, + Type: models.DependencyAdded, + Manager: "go.mod", + Severity: models.UnknownChange, + IsDirect: !newDep.indirect, + }) + } + } + + for module, oldDep := range oldDeps { + if _, exists := newDeps[module]; !exists { + changes = append(changes, models.DependencyChange{ + Name: module, + OldVersion: oldDep.version, + Type: models.DependencyRemoved, + Manager: "go.mod", + Severity: models.UnknownChange, + IsDirect: !oldDep.indirect, + }) + } + } + return changes +} + +// calculateSeverity determina la severidad del cambio basado en semver +func (g *GoModAnalyzer) calculateSeverity(oldVersion, newVersion string) models.ChangeSeverity { + oldParts := g.parseVersion(oldVersion) + newParts := g.parseVersion(newVersion) + + if len(oldParts) < 3 || len(newParts) < 3 { + return models.UnknownChange + } + + if newParts[0] > oldParts[0] { + return models.MajorChange + } + + if newParts[1] > oldParts[1] { + return models.MinorChange + } + + if newParts[2] > oldParts[2] { + return models.PatchChange + } + + return models.UnknownChange +} + +// parseVersion extrae [major, minor, patch] de una version string +func (g *GoModAnalyzer) parseVersion(version string) []int { + version = strings.TrimPrefix(version, "v") + + // Remover pre-release tag (después de -) + if idx := strings.Index(version, "-"); idx != -1 { + version = version[:idx] + } + + // Remover build metadata (después de +) + if idx := strings.Index(version, "+"); idx != -1 { + version = version[:idx] + } + + parts := strings.Split(version, ".") + result := make([]int, 0, 3) + + for i := 0; i < 3 && i < len(parts); i++ { + num, err := strconv.Atoi(parts[i]) + if err != nil { + return []int{} + } + result = append(result, num) + } + return result +} diff --git a/internal/infrastructure/dependency/gomod_analyzer_test.go b/internal/infrastructure/dependency/gomod_analyzer_test.go new file mode 100644 index 0000000..11e5ea8 --- /dev/null +++ b/internal/infrastructure/dependency/gomod_analyzer_test.go @@ -0,0 +1,405 @@ +package dependency + +import ( + "context" + "errors" + "testing" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockVCSClient es un mock del VCSClient +type MockVCSClient struct { + mock.Mock +} + +func (m *MockVCSClient) GetFileAtTag(ctx context.Context, tag, filepath string) (string, error) { + args := m.Called(ctx, tag, filepath) + return args.String(0), args.Error(1) +} + +func (m *MockVCSClient) UpdatePR(ctx context.Context, prNumber int, summary models.PRSummary) error { + args := m.Called(ctx, prNumber, summary) + return args.Error(0) +} + +func (m *MockVCSClient) GetPR(ctx context.Context, prNumber int) (models.PRData, error) { + args := m.Called(ctx, prNumber) + return args.Get(0).(models.PRData), args.Error(1) +} + +func (m *MockVCSClient) GetRepoLabels(ctx context.Context) ([]string, error) { + args := m.Called(ctx) + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockVCSClient) CreateLabel(ctx context.Context, name string, color string, description string) error { + args := m.Called(ctx, name, color, description) + return args.Error(0) +} + +func (m *MockVCSClient) AddLabelsToPR(ctx context.Context, prNumber int, labels []string) error { + args := m.Called(ctx, prNumber, labels) + return args.Error(0) +} + +func (m *MockVCSClient) CreateRelease(ctx context.Context, release *models.Release, notes *models.ReleaseNotes, draft bool) error { + args := m.Called(ctx, release, notes, draft) + return args.Error(0) +} + +func (m *MockVCSClient) GetRelease(ctx context.Context, version string) (*models.VCSRelease, error) { + args := m.Called(ctx, version) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.VCSRelease), args.Error(1) +} + +func (m *MockVCSClient) UpdateRelease(ctx context.Context, version, body string) error { + args := m.Called(ctx, version, body) + return args.Error(0) +} + +func (m *MockVCSClient) GetClosedIssuesBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.Issue, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).([]models.Issue), args.Error(1) +} + +func (m *MockVCSClient) GetMergedPRsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]models.PullRequest, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).([]models.PullRequest), args.Error(1) +} + +func (m *MockVCSClient) GetContributorsBetweenTags(ctx context.Context, previousTag, currentTag string) ([]string, error) { + args := m.Called(ctx, previousTag, currentTag) + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockVCSClient) GetFileStatsBetweenTags(ctx context.Context, previousTag, currentTag string) (*models.FileStatistics, error) { + args := m.Called(ctx, previousTag, currentTag) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.FileStatistics), args.Error(1) +} + +func TestGoModAnalyzer_Name(t *testing.T) { + analyzer := NewGoModAnalyzer() + assert.Equal(t, "go.mod", analyzer.Name()) +} + +func TestGoModAnalyzer_CanHandle(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockVCSClient) + expected bool + description string + }{ + { + name: "go.mod exists with content", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "go.mod"). + Return("module github.com/test/project\n", nil) + }, + expected: true, + description: "should return true when go.mod exists and has content", + }, + { + name: "go.mod doesn't exist", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "go.mod"). + Return("", errors.New("file not found")) + }, + expected: false, + description: "should return false when go.mod doesn't exist", + }, + { + name: "go.mod exists but is empty", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "go.mod"). + Return("", nil) + }, + expected: false, + description: "should return false when go.mod is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockVCSClient) + tt.setupMock(mockClient) + + analyzer := NewGoModAnalyzer() + result := analyzer.CanHandle(context.Background(), mockClient, "v0.9.0", "v1.0.0") + + assert.Equal(t, tt.expected, result, tt.description) + mockClient.AssertExpectations(t) + }) + } +} + +func TestGoModAnalyzer_AnalyzeChanges(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockVCSClient) + expectError bool + errorMsg string + validate func(*testing.T, []models.DependencyChange) + }{ + { + name: "successful analysis with changes", + setupMock: func(m *MockVCSClient) { + oldContent := `module test +require ( + github.com/foo/bar v1.0.0 +)` + newContent := `module test +require ( + github.com/foo/bar v2.0.0 + github.com/new/dep v1.0.0 +)` + m.On("GetFileAtTag", mock.Anything, "v0.9.0", "go.mod").Return(oldContent, nil) + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "go.mod").Return(newContent, nil) + }, + expectError: false, + validate: func(t *testing.T, changes []models.DependencyChange) { + assert.Len(t, changes, 2) + }, + }, + { + name: "error reading old go.mod", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v0.9.0", "go.mod"). + Return("", errors.New("tag not found")) + }, + expectError: true, + errorMsg: "error leyendo go.mod viejo", + }, + { + name: "error reading new go.mod", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v0.9.0", "go.mod"). + Return("module test\n", nil) + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "go.mod"). + Return("", errors.New("tag not found")) + }, + expectError: true, + errorMsg: "error leyendo go.mod nuevo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockVCSClient) + tt.setupMock(mockClient) + + analyzer := NewGoModAnalyzer() + changes, err := analyzer.AnalyzeChanges(context.Background(), mockClient, "v0.9.0", "v1.0.0") + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + assert.NoError(t, err) + if tt.validate != nil { + tt.validate(t, changes) + } + } + mockClient.AssertExpectations(t) + }) + } +} + +func TestGoModAnalyzer_ParseGoMod(t *testing.T) { + analyzer := NewGoModAnalyzer() + + t.Run("complete go.mod with multiple formats", func(t *testing.T) { + content := `module github.com/user/project + +go 1.21 + +require ( + github.com/stretchr/testify v1.8.4 + github.com/urfave/cli/v3 v3.0.0 + golang.org/x/oauth2 v0.15.0 // indirect +) + +require github.com/google/uuid v1.5.0 +` + + deps := analyzer.parseGoMod(content) + + assert.Equal(t, "1.8.4", deps["github.com/stretchr/testify"].version) + assert.False(t, deps["github.com/stretchr/testify"].indirect) + + assert.Equal(t, "3.0.0", deps["github.com/urfave/cli/v3"].version) + assert.False(t, deps["github.com/urfave/cli/v3"].indirect) + + assert.Equal(t, "0.15.0", deps["golang.org/x/oauth2"].version) + assert.True(t, deps["golang.org/x/oauth2"].indirect) + + assert.Equal(t, "1.5.0", deps["github.com/google/uuid"].version) + assert.False(t, deps["github.com/google/uuid"].indirect) + }) + + t.Run("empty content", func(t *testing.T) { + deps := analyzer.parseGoMod("") + assert.Empty(t, deps) + }) + + t.Run("only direct dependencies", func(t *testing.T) { + content := `module test +require ( + github.com/foo/bar v1.0.0 + github.com/baz/qux v2.0.0 +)` + deps := analyzer.parseGoMod(content) + assert.Len(t, deps, 2) + assert.False(t, deps["github.com/foo/bar"].indirect) + assert.False(t, deps["github.com/baz/qux"].indirect) + }) + + t.Run("only indirect dependencies", func(t *testing.T) { + content := `module test +require ( + github.com/foo/bar v1.0.0 // indirect + github.com/baz/qux v2.0.0 // indirect +)` + deps := analyzer.parseGoMod(content) + assert.Len(t, deps, 2) + assert.True(t, deps["github.com/foo/bar"].indirect) + assert.True(t, deps["github.com/baz/qux"].indirect) + }) + + t.Run("malformed content", func(t *testing.T) { + content := `this is not a valid go.mod file +random text here +no dependencies` + deps := analyzer.parseGoMod(content) + assert.Empty(t, deps) + }) + + t.Run("multiple single-line requires", func(t *testing.T) { + content := `module test +require github.com/foo/bar v1.0.0 +require github.com/baz/qux v2.0.0 // indirect` + deps := analyzer.parseGoMod(content) + assert.Len(t, deps, 2) + assert.Equal(t, "1.0.0", deps["github.com/foo/bar"].version) + assert.False(t, deps["github.com/foo/bar"].indirect) + assert.Equal(t, "2.0.0", deps["github.com/baz/qux"].version) + assert.True(t, deps["github.com/baz/qux"].indirect) + }) +} + +func TestGoModAnalyzer_ComputeChanges(t *testing.T) { + analyzer := NewGoModAnalyzer() + + oldDeps := map[string]goDep{ + "github.com/foo/bar": {version: "1.0.0", indirect: false}, + "github.com/old/dep": {version: "1.0.0", indirect: true}, + } + + newDeps := map[string]goDep{ + "github.com/foo/bar": {version: "2.0.0", indirect: false}, + "github.com/new/dep": {version: "1.0.0", indirect: false}, + } + + changes := analyzer.computeChanges(oldDeps, newDeps) + + assert.Len(t, changes, 3) + + // Verificar que contiene los cambios esperados + var updated, added, removed *models.DependencyChange + for i := range changes { + switch changes[i].Type { + case models.DependencyUpdated: + if changes[i].Name == "github.com/foo/bar" { + updated = &changes[i] + } + case models.DependencyAdded: + if changes[i].Name == "github.com/new/dep" { + added = &changes[i] + } + case models.DependencyRemoved: + if changes[i].Name == "github.com/old/dep" { + removed = &changes[i] + } + } + } + + if assert.NotNil(t, updated, "updated change should exist") { + assert.Equal(t, "1.0.0", updated.OldVersion) + assert.Equal(t, "2.0.0", updated.NewVersion) + assert.Equal(t, models.MajorChange, updated.Severity) + assert.True(t, updated.IsDirect) + } + + if assert.NotNil(t, added, "added change should exist") { + assert.Equal(t, "1.0.0", added.NewVersion) + assert.True(t, added.IsDirect) + } + + if assert.NotNil(t, removed, "removed change should exist") { + assert.Equal(t, "1.0.0", removed.OldVersion) + assert.False(t, removed.IsDirect) // era indirect + } +} + +func TestGoModAnalyzer_CalculateSeverity(t *testing.T) { + analyzer := NewGoModAnalyzer() + + tests := []struct { + name string + oldVersion string + newVersion string + expected models.ChangeSeverity + }{ + {"major bump", "v1.2.3", "v2.0.0", models.MajorChange}, + {"minor bump", "v1.2.3", "v1.3.0", models.MinorChange}, + {"patch bump", "v1.2.3", "v1.2.4", models.PatchChange}, + {"with prefix", "1.2.3", "2.0.0", models.MajorChange}, + {"invalid", "abc", "def", models.UnknownChange}, + {"downgrade major", "v2.0.0", "v1.0.0", models.UnknownChange}, + {"same version", "v1.2.3", "v1.2.3", models.UnknownChange}, + {"pre-release", "v1.2.3-beta", "v1.3.0", models.MinorChange}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.calculateSeverity(tt.oldVersion, tt.newVersion) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGoModAnalyzer_ParseVersion(t *testing.T) { + analyzer := NewGoModAnalyzer() + + tests := []struct { + name string + version string + expected []int + }{ + {"valid semver", "1.2.3", []int{1, 2, 3}}, + {"with v prefix", "v1.2.3", []int{1, 2, 3}}, + {"with pre-release", "v1.2.3-beta.1", []int{1, 2, 3}}, + {"with build metadata", "v1.2.3+build.123", []int{1, 2, 3}}, + {"invalid version", "abc", []int{}}, + {"incomplete version", "1.2", []int{1, 2}}, + {"single number", "1", []int{1}}, + {"empty string", "", []int{}}, + {"only major", "v2", []int{2}}, + {"with dash", "1.2.3-rc1", []int{1, 2, 3}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseVersion(tt.version) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/infrastructure/dependency/package_json_analyzer.go b/internal/infrastructure/dependency/package_json_analyzer.go new file mode 100644 index 0000000..35a2b96 --- /dev/null +++ b/internal/infrastructure/dependency/package_json_analyzer.go @@ -0,0 +1,200 @@ +package dependency + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/domain/ports" +) + +var _ ports.DependencyAnalyzer = (*PackageJsonAnalyzer)(nil) + +type PackageJsonAnalyzer struct{} + +func NewPackageJsonAnalyzer() *PackageJsonAnalyzer { + return &PackageJsonAnalyzer{} +} + +func (p *PackageJsonAnalyzer) CanHandle(ctx context.Context, vcsClient ports.VCSClient, previousTag, currentTag string) bool { + content, err := p.getFileContent(ctx, vcsClient, currentTag, "package.json") + return err == nil && content != "" +} + +func (p *PackageJsonAnalyzer) AnalyzeChanges(ctx context.Context, vcsClient ports.VCSClient, previousTag, currentTag string) ([]models.DependencyChange, error) { + oldContent, err := p.getFileContent(ctx, vcsClient, previousTag, "package.json") + if err != nil { + return nil, fmt.Errorf("error al leer el package.json antiguo: %w", err) + } + + newContent, err := p.getFileContent(ctx, vcsClient, currentTag, "package.json") + if err != nil { + return nil, fmt.Errorf("error al leer el nuevo package.json: %w", err) + } + + oldDeps, err := p.parsePackageJson(oldContent) + if err != nil { + return nil, fmt.Errorf("error al parsear viejo package.json: %w", err) + } + + newDeps, err := p.parsePackageJson(newContent) + if err != nil { + return nil, fmt.Errorf("error al parsear nuevo package.json: %w", err) + } + + return p.computeChanges(oldDeps, newDeps), nil + +} + +func (p *PackageJsonAnalyzer) Name() string { + return "package.json" +} + +func (p *PackageJsonAnalyzer) getFileContent(ctx context.Context, vcsClient ports.VCSClient, tag, filepath string) (string, error) { + if vcsClient == nil { + return "", fmt.Errorf("vcsClient is nil") + } + return vcsClient.GetFileAtTag(ctx, tag, filepath) +} + +type packageJson struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` +} + +type npmDep struct { + version string + isDev bool +} + +func (p *PackageJsonAnalyzer) parsePackageJson(content string) (map[string]npmDep, error) { + var pkg packageJson + if err := json.Unmarshal([]byte(content), &pkg); err != nil { + return nil, err + } + + deps := make(map[string]npmDep) + + for name, version := range pkg.Dependencies { + deps[name] = npmDep{ + version: version, + isDev: false, + } + } + + for name, version := range pkg.DevDependencies { + deps[name] = npmDep{ + version: version, + isDev: true, + } + } + + return deps, nil +} + +func (p *PackageJsonAnalyzer) computeChanges(oldDeps, newDeps map[string]npmDep) []models.DependencyChange { + var changes []models.DependencyChange + + for name, newDep := range newDeps { + if oldDep, exists := oldDeps[name]; exists { + if oldDep.version != newDep.version { + changes = append(changes, models.DependencyChange{ + Name: name, + OldVersion: p.cleanVersion(oldDep.version), + NewVersion: p.cleanVersion(newDep.version), + Type: models.DependencyUpdated, + Manager: "package.json", + Severity: p.calculateSeverity(oldDep.version, newDep.version), + IsDirect: !newDep.isDev, + }) + } + } else { + changes = append(changes, models.DependencyChange{ + Name: name, + NewVersion: p.cleanVersion(newDep.version), + Type: models.DependencyAdded, + Manager: "package.json", + Severity: models.UnknownChange, + IsDirect: !newDep.isDev, + }) + } + } + + for name, oldDep := range oldDeps { + if _, exists := newDeps[name]; !exists { + changes = append(changes, models.DependencyChange{ + Name: name, + OldVersion: p.cleanVersion(oldDep.version), + Type: models.DependencyRemoved, + Manager: "package.json", + Severity: models.UnknownChange, + IsDirect: !oldDep.isDev, + }) + } + } + + return changes +} + +// cleanVersion remueve prefijos como ^, ~, >=, etc de versiones npm +func (p *PackageJsonAnalyzer) cleanVersion(version string) string { + version = strings.TrimPrefix(version, "^") + version = strings.TrimPrefix(version, "~") + version = strings.TrimPrefix(version, ">=") + version = strings.TrimPrefix(version, "<=") + version = strings.TrimPrefix(version, ">") + version = strings.TrimPrefix(version, "<") + version = strings.TrimPrefix(version, "=") + return strings.TrimSpace(version) +} + +// calculateSeverity determina la severidad del cambio basado en semver +func (p *PackageJsonAnalyzer) calculateSeverity(oldVersion, newVersion string) models.ChangeSeverity { + oldClean := p.cleanVersion(oldVersion) + newClean := p.cleanVersion(newVersion) + + oldParts := p.parseVersion(oldClean) + newParts := p.parseVersion(newClean) + + if len(oldParts) < 3 || len(newParts) < 3 { + return models.UnknownChange + } + + if newParts[0] > oldParts[0] { + return models.MajorChange + } + + if newParts[1] > oldParts[1] { + return models.MinorChange + } + + if newParts[2] > oldParts[2] { + return models.PatchChange + } + + return models.UnknownChange +} + +// parseVersion extrae [major, minor, patch] de una version string +func (p *PackageJsonAnalyzer) parseVersion(version string) []int { + parts := strings.Split(version, ".") + result := make([]int, 0, 3) + + for i := 0; i < 3 && i < len(parts); i++ { + numStr := parts[i] + if idx := strings.IndexAny(numStr, "-+"); idx != -1 { + numStr = numStr[:idx] + } + + num, err := strconv.Atoi(numStr) + if err != nil { + return []int{} + } + result = append(result, num) + } + + return result +} diff --git a/internal/infrastructure/dependency/package_json_analyzer_test.go b/internal/infrastructure/dependency/package_json_analyzer_test.go new file mode 100644 index 0000000..ae48de7 --- /dev/null +++ b/internal/infrastructure/dependency/package_json_analyzer_test.go @@ -0,0 +1,378 @@ +package dependency + +import ( + "context" + "errors" + "testing" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestPackageJsonAnalyzer_Name(t *testing.T) { + analyzer := NewPackageJsonAnalyzer() + assert.Equal(t, "package.json", analyzer.Name()) +} + +func TestPackageJsonAnalyzer_CanHandle(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockVCSClient) + expected bool + description string + }{ + { + name: "package.json exists with content", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "package.json"). + Return(`{"name": "test-project"}`, nil) + }, + expected: true, + description: "should return true when package.json exists and has content", + }, + { + name: "package.json doesn't exist", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "package.json"). + Return("", errors.New("file not found")) + }, + expected: false, + description: "should return false when package.json doesn't exist", + }, + { + name: "package.json exists but is empty", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "package.json"). + Return("", nil) + }, + expected: false, + description: "should return false when package.json is empty", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockVCSClient) + tt.setupMock(mockClient) + + analyzer := NewPackageJsonAnalyzer() + result := analyzer.CanHandle(context.Background(), mockClient, "v0.9.0", "v1.0.0") + + assert.Equal(t, tt.expected, result, tt.description) + mockClient.AssertExpectations(t) + }) + } +} + +func TestPackageJsonAnalyzer_AnalyzeChanges(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockVCSClient) + expectError bool + errorMsg string + validate func(*testing.T, []models.DependencyChange) + }{ + { + name: "successful analysis with changes", + setupMock: func(m *MockVCSClient) { + oldContent := `{ + "dependencies": { + "express": "^4.17.1" + } + }` + newContent := `{ + "dependencies": { + "express": "^5.0.0", + "lodash": "^4.17.21" + } + }` + m.On("GetFileAtTag", mock.Anything, "v0.9.0", "package.json").Return(oldContent, nil) + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "package.json").Return(newContent, nil) + }, + expectError: false, + validate: func(t *testing.T, changes []models.DependencyChange) { + assert.Len(t, changes, 2) + }, + }, + { + name: "error reading old package.json", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v0.9.0", "package.json"). + Return("", errors.New("tag not found")) + }, + expectError: true, + errorMsg: "error al leer el package.json antiguo", + }, + { + name: "error reading new package.json", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v0.9.0", "package.json"). + Return(`{"dependencies": {}}`, nil) + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "package.json"). + Return("", errors.New("tag not found")) + }, + expectError: true, + errorMsg: "error al leer el nuevo package.json", + }, + { + name: "error parsing old package.json", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v0.9.0", "package.json"). + Return("invalid json", nil) + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "package.json"). + Return(`{"dependencies": {}}`, nil) + }, + expectError: true, + errorMsg: "error al parsear viejo package.json", + }, + { + name: "error parsing new package.json", + setupMock: func(m *MockVCSClient) { + m.On("GetFileAtTag", mock.Anything, "v0.9.0", "package.json"). + Return(`{"dependencies": {}}`, nil) + m.On("GetFileAtTag", mock.Anything, "v1.0.0", "package.json"). + Return("invalid json", nil) + }, + expectError: true, + errorMsg: "error al parsear nuevo package.json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := new(MockVCSClient) + tt.setupMock(mockClient) + + analyzer := NewPackageJsonAnalyzer() + changes, err := analyzer.AnalyzeChanges(context.Background(), mockClient, "v0.9.0", "v1.0.0") + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.errorMsg) + } else { + assert.NoError(t, err) + if tt.validate != nil { + tt.validate(t, changes) + } + } + mockClient.AssertExpectations(t) + }) + } +} + +func TestPackageJsonAnalyzer_ParsePackageJson(t *testing.T) { + analyzer := NewPackageJsonAnalyzer() + + t.Run("complete package.json with dependencies and devDependencies", func(t *testing.T) { + content := `{ + "name": "test-project", + "version": "1.0.0", + "dependencies": { + "express": "^4.17.1", + "lodash": "~4.17.21" + }, + "devDependencies": { + "jest": "^27.0.0", + "eslint": "^8.0.0" + } + }` + + deps, err := analyzer.parsePackageJson(content) + assert.NoError(t, err) + assert.Len(t, deps, 4) + + assert.Equal(t, "^4.17.1", deps["express"].version) + assert.False(t, deps["express"].isDev) + + assert.Equal(t, "~4.17.21", deps["lodash"].version) + assert.False(t, deps["lodash"].isDev) + + assert.Equal(t, "^27.0.0", deps["jest"].version) + assert.True(t, deps["jest"].isDev) + + assert.Equal(t, "^8.0.0", deps["eslint"].version) + assert.True(t, deps["eslint"].isDev) + }) + + t.Run("only dependencies", func(t *testing.T) { + content := `{ + "dependencies": { + "express": "^4.17.1" + } + }` + + deps, err := analyzer.parsePackageJson(content) + assert.NoError(t, err) + assert.Len(t, deps, 1) + assert.False(t, deps["express"].isDev) + }) + + t.Run("only devDependencies", func(t *testing.T) { + content := `{ + "devDependencies": { + "jest": "^27.0.0" + } + }` + + deps, err := analyzer.parsePackageJson(content) + assert.NoError(t, err) + assert.Len(t, deps, 1) + assert.True(t, deps["jest"].isDev) + }) + + t.Run("empty package.json", func(t *testing.T) { + content := `{}` + + deps, err := analyzer.parsePackageJson(content) + assert.NoError(t, err) + assert.Empty(t, deps) + }) + + t.Run("invalid JSON", func(t *testing.T) { + content := `{invalid json}` + + deps, err := analyzer.parsePackageJson(content) + assert.Error(t, err) + assert.Nil(t, deps) + }) +} + +func TestPackageJsonAnalyzer_ComputeChanges(t *testing.T) { + analyzer := NewPackageJsonAnalyzer() + + oldDeps := map[string]npmDep{ + "express": {version: "^4.17.1", isDev: false}, + "old-pkg": {version: "^1.0.0", isDev: true}, + } + + newDeps := map[string]npmDep{ + "express": {version: "^5.0.0", isDev: false}, + "new-pkg": {version: "^1.0.0", isDev: false}, + } + + changes := analyzer.computeChanges(oldDeps, newDeps) + + assert.Len(t, changes, 3) + + var updated, added, removed *models.DependencyChange + for i := range changes { + switch changes[i].Type { + case models.DependencyUpdated: + if changes[i].Name == "express" { + updated = &changes[i] + } + case models.DependencyAdded: + if changes[i].Name == "new-pkg" { + added = &changes[i] + } + case models.DependencyRemoved: + if changes[i].Name == "old-pkg" { + removed = &changes[i] + } + } + } + + if assert.NotNil(t, updated, "updated change should exist") { + assert.Equal(t, "4.17.1", updated.OldVersion) + assert.Equal(t, "5.0.0", updated.NewVersion) + assert.Equal(t, models.MajorChange, updated.Severity) + assert.True(t, updated.IsDirect) + } + + if assert.NotNil(t, added, "added change should exist") { + assert.Equal(t, "1.0.0", added.NewVersion) + assert.True(t, added.IsDirect) + } + + if assert.NotNil(t, removed, "removed change should exist") { + assert.Equal(t, "1.0.0", removed.OldVersion) + assert.False(t, removed.IsDirect) // era devDependency + } +} + +func TestPackageJsonAnalyzer_CleanVersion(t *testing.T) { + analyzer := NewPackageJsonAnalyzer() + + tests := []struct { + name string + input string + expected string + }{ + {"caret version", "^4.17.1", "4.17.1"}, + {"tilde version", "~4.17.1", "4.17.1"}, + {"greater than or equal", ">=4.17.1", "4.17.1"}, + {"less than or equal", "<=4.17.1", "4.17.1"}, + {"greater than", ">4.17.1", "4.17.1"}, + {"less than", "<4.17.1", "4.17.1"}, + {"exact version", "=4.17.1", "4.17.1"}, + {"plain version", "4.17.1", "4.17.1"}, + {"version with spaces", " 4.17.1 ", "4.17.1"}, + {"caret with spaces", "^ 4.17.1", "4.17.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.cleanVersion(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPackageJsonAnalyzer_CalculateSeverity(t *testing.T) { + analyzer := NewPackageJsonAnalyzer() + + tests := []struct { + name string + oldVersion string + newVersion string + expected models.ChangeSeverity + }{ + {"major bump with caret", "^1.2.3", "^2.0.0", models.MajorChange}, + {"minor bump with tilde", "~1.2.3", "~1.3.0", models.MinorChange}, + {"patch bump", "1.2.3", "1.2.4", models.PatchChange}, + {"major bump plain", "1.2.3", "2.0.0", models.MajorChange}, + {"invalid old version", "abc", "2.0.0", models.UnknownChange}, + {"invalid new version", "1.2.3", "xyz", models.UnknownChange}, + {"same version", "^1.2.3", "^1.2.3", models.UnknownChange}, + {"downgrade", "^2.0.0", "^1.0.0", models.UnknownChange}, + {"with pre-release", "1.2.3-beta", "1.3.0", models.MinorChange}, + {"with build metadata", "1.2.3+build", "1.2.4", models.PatchChange}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.calculateSeverity(tt.oldVersion, tt.newVersion) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPackageJsonAnalyzer_ParseVersion(t *testing.T) { + analyzer := NewPackageJsonAnalyzer() + + tests := []struct { + name string + version string + expected []int + }{ + {"valid semver", "1.2.3", []int{1, 2, 3}}, + {"with pre-release", "1.2.3-beta.1", []int{1, 2, 3}}, + {"with build metadata", "1.2.3+build.123", []int{1, 2, 3}}, + {"with both pre-release and build", "1.2.3-rc1+build", []int{1, 2, 3}}, + {"invalid version", "abc.def.ghi", []int{}}, + {"incomplete version", "1.2", []int{1, 2}}, + {"single number", "1", []int{1}}, + {"empty string", "", []int{}}, + {"only major", "2", []int{2}}, + {"with dash in patch", "1.2.3-rc1", []int{1, 2, 3}}, + {"with plus in patch", "1.2.3+build", []int{1, 2, 3}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := analyzer.parseVersion(tt.version) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/infrastructure/dependency/registry.go b/internal/infrastructure/dependency/registry.go new file mode 100644 index 0000000..82bd82e --- /dev/null +++ b/internal/infrastructure/dependency/registry.go @@ -0,0 +1,54 @@ +package dependency + +import ( + "context" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/domain/ports" +) + +type AnalyzerRegistry struct { + analyzers []ports.DependencyAnalyzer +} + +func NewAnalyzerRegistry() *AnalyzerRegistry { + return &AnalyzerRegistry{ + analyzers: []ports.DependencyAnalyzer{ + NewGoModAnalyzer(), + NewPackageJsonAnalyzer(), + }, + } +} + +// RegisterAnalyzer agrega un analyzer personalizado +func (r *AnalyzerRegistry) RegisterAnalyzer(analyzer ports.DependencyAnalyzer) { + r.analyzers = append(r.analyzers, analyzer) +} + +// AnalyzeAll ejecuta todos los analyzers aplicables y combina los resultados +func (r *AnalyzerRegistry) AnalyzeAll(ctx context.Context, vcsClient ports.VCSClient, previousTag, currentTag string) ([]models.DependencyChange, error) { + var allChanges []models.DependencyChange + + for _, analyzer := range r.analyzers { + if analyzer.CanHandle(ctx, vcsClient, previousTag, currentTag) { + changes, err := analyzer.AnalyzeChanges(ctx, vcsClient, previousTag, currentTag) + if err != nil { + continue + } + allChanges = append(allChanges, changes...) + } + } + return allChanges, nil +} + +// GetSupportedAnalyzers retorna lista de analyzers detectados +func (r *AnalyzerRegistry) GetSupportedAnalyzers(ctx context.Context, vcsClient ports.VCSClient, previousTag, currentTag string) []string { + var supported []string + + for _, analyzer := range r.analyzers { + if analyzer.CanHandle(ctx, vcsClient, previousTag, currentTag) { + supported = append(supported, analyzer.Name()) + } + } + return supported +} diff --git a/internal/infrastructure/dependency/registry_test.go b/internal/infrastructure/dependency/registry_test.go new file mode 100644 index 0000000..b467fce --- /dev/null +++ b/internal/infrastructure/dependency/registry_test.go @@ -0,0 +1,261 @@ +package dependency + +import ( + "context" + "errors" + "testing" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/domain/ports" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockAnalyzer es un mock de DependencyAnalyzer +type MockAnalyzer struct { + mock.Mock +} + +func (m *MockAnalyzer) Name() string { + args := m.Called() + return args.String(0) +} + +func (m *MockAnalyzer) CanHandle(ctx context.Context, vcsClient ports.VCSClient, previousTag, currentTag string) bool { + args := m.Called(ctx, vcsClient, previousTag, currentTag) + return args.Bool(0) +} + +func (m *MockAnalyzer) AnalyzeChanges(ctx context.Context, vcsClient ports.VCSClient, previousTag, currentTag string) ([]models.DependencyChange, error) { + args := m.Called(ctx, vcsClient, previousTag, currentTag) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]models.DependencyChange), args.Error(1) +} + +func TestNewAnalyzerRegistry(t *testing.T) { + registry := NewAnalyzerRegistry() + + assert.NotNil(t, registry) + assert.NotNil(t, registry.analyzers) + assert.Len(t, registry.analyzers, 2, "should have 2 default analyzers (GoMod and PackageJson)") +} + +func TestAnalyzerRegistry_RegisterAnalyzer(t *testing.T) { + registry := NewAnalyzerRegistry() + initialCount := len(registry.analyzers) + + mockAnalyzer := new(MockAnalyzer) + registry.RegisterAnalyzer(mockAnalyzer) + + assert.Len(t, registry.analyzers, initialCount+1, "should have one more analyzer after registration") +} + +func TestAnalyzerRegistry_AnalyzeAll(t *testing.T) { + tests := []struct { + name string + setupMocks func(*MockVCSClient, *MockAnalyzer, *MockAnalyzer) + expectedCount int + description string + }{ + { + name: "both analyzers can handle", + setupMocks: func(vcs *MockVCSClient, a1 *MockAnalyzer, a2 *MockAnalyzer) { + a1.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(true) + a1.On("AnalyzeChanges", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return([]models.DependencyChange{ + {Name: "dep1", Type: models.DependencyAdded}, + }, nil) + + a2.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(true) + a2.On("AnalyzeChanges", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return([]models.DependencyChange{ + {Name: "dep2", Type: models.DependencyAdded}, + }, nil) + }, + expectedCount: 2, + description: "should combine changes from both analyzers", + }, + { + name: "only one analyzer can handle", + setupMocks: func(vcs *MockVCSClient, a1 *MockAnalyzer, a2 *MockAnalyzer) { + a1.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(true) + a1.On("AnalyzeChanges", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return([]models.DependencyChange{ + {Name: "dep1", Type: models.DependencyAdded}, + }, nil) + + a2.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(false) + }, + expectedCount: 1, + description: "should only use analyzer that can handle", + }, + { + name: "no analyzers can handle", + setupMocks: func(vcs *MockVCSClient, a1 *MockAnalyzer, a2 *MockAnalyzer) { + a1.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(false) + a2.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(false) + }, + expectedCount: 0, + description: "should return empty changes when no analyzer can handle", + }, + { + name: "analyzer returns error", + setupMocks: func(vcs *MockVCSClient, a1 *MockAnalyzer, a2 *MockAnalyzer) { + a1.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(true) + a1.On("AnalyzeChanges", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(nil, errors.New("analysis error")) + + a2.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(true) + a2.On("AnalyzeChanges", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return([]models.DependencyChange{ + {Name: "dep2", Type: models.DependencyAdded}, + }, nil) + }, + expectedCount: 1, + description: "should continue with other analyzers when one fails", + }, + { + name: "analyzer returns empty changes", + setupMocks: func(vcs *MockVCSClient, a1 *MockAnalyzer, a2 *MockAnalyzer) { + a1.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(true) + a1.On("AnalyzeChanges", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return([]models.DependencyChange{}, nil) + + a2.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(true) + a2.On("AnalyzeChanges", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return([]models.DependencyChange{ + {Name: "dep2", Type: models.DependencyAdded}, + }, nil) + }, + expectedCount: 1, + description: "should handle empty changes from one analyzer", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a registry with only mock analyzers + registry := &AnalyzerRegistry{ + analyzers: []ports.DependencyAnalyzer{}, + } + + mockVCS := new(MockVCSClient) + mockAnalyzer1 := new(MockAnalyzer) + mockAnalyzer2 := new(MockAnalyzer) + + registry.RegisterAnalyzer(mockAnalyzer1) + registry.RegisterAnalyzer(mockAnalyzer2) + + tt.setupMocks(mockVCS, mockAnalyzer1, mockAnalyzer2) + + changes, err := registry.AnalyzeAll(context.Background(), mockVCS, "v1.0.0", "v2.0.0") + + assert.NoError(t, err, "AnalyzeAll should not return error") + assert.Len(t, changes, tt.expectedCount, tt.description) + + mockAnalyzer1.AssertExpectations(t) + mockAnalyzer2.AssertExpectations(t) + }) + } +} + +func TestAnalyzerRegistry_GetSupportedAnalyzers(t *testing.T) { + tests := []struct { + name string + setupMocks func(*MockVCSClient, *MockAnalyzer, *MockAnalyzer) + expectedNames []string + expectedCount int + description string + }{ + { + name: "both analyzers supported", + setupMocks: func(vcs *MockVCSClient, a1 *MockAnalyzer, a2 *MockAnalyzer) { + a1.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(true) + a1.On("Name").Return("analyzer1") + + a2.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(true) + a2.On("Name").Return("analyzer2") + }, + expectedNames: []string{"analyzer1", "analyzer2"}, + expectedCount: 2, + description: "should return both analyzer names", + }, + { + name: "only one analyzer supported", + setupMocks: func(vcs *MockVCSClient, a1 *MockAnalyzer, a2 *MockAnalyzer) { + a1.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(true) + a1.On("Name").Return("analyzer1") + + a2.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(false) + }, + expectedNames: []string{"analyzer1"}, + expectedCount: 1, + description: "should return only supported analyzer name", + }, + { + name: "no analyzers supported", + setupMocks: func(vcs *MockVCSClient, a1 *MockAnalyzer, a2 *MockAnalyzer) { + a1.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(false) + a2.On("CanHandle", mock.Anything, vcs, "v1.0.0", "v2.0.0").Return(false) + }, + expectedNames: []string{}, + expectedCount: 0, + description: "should return empty list when no analyzers are supported", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a registry with only mock analyzers + registry := &AnalyzerRegistry{ + analyzers: []ports.DependencyAnalyzer{}, + } + + mockVCS := new(MockVCSClient) + mockAnalyzer1 := new(MockAnalyzer) + mockAnalyzer2 := new(MockAnalyzer) + + registry.RegisterAnalyzer(mockAnalyzer1) + registry.RegisterAnalyzer(mockAnalyzer2) + + tt.setupMocks(mockVCS, mockAnalyzer1, mockAnalyzer2) + + supported := registry.GetSupportedAnalyzers(context.Background(), mockVCS, "v1.0.0", "v2.0.0") + + assert.Len(t, supported, tt.expectedCount, tt.description) + if tt.expectedCount > 0 { + assert.Equal(t, tt.expectedNames, supported) + } + + mockAnalyzer1.AssertExpectations(t) + mockAnalyzer2.AssertExpectations(t) + }) + } +} + +func TestAnalyzerRegistry_Integration(t *testing.T) { + t.Run("default registry has go.mod and package.json analyzers", func(t *testing.T) { + registry := NewAnalyzerRegistry() + + // Create a mock VCS that returns content for both files + mockVCS := new(MockVCSClient) + mockVCS.On("GetFileAtTag", mock.Anything, "v1.0.0", "go.mod"). + Return("module test\n", nil) + mockVCS.On("GetFileAtTag", mock.Anything, "v1.0.0", "package.json"). + Return(`{"name": "test"}`, nil) + + supported := registry.GetSupportedAnalyzers(context.Background(), mockVCS, "v0.9.0", "v1.0.0") + + assert.Contains(t, supported, "go.mod") + assert.Contains(t, supported, "package.json") + assert.Len(t, supported, 2) + + mockVCS.AssertExpectations(t) + }) + + t.Run("can add custom analyzer to default registry", func(t *testing.T) { + registry := NewAnalyzerRegistry() + + customAnalyzer := new(MockAnalyzer) + customAnalyzer.On("Name").Return("custom-analyzer") + + registry.RegisterAnalyzer(customAnalyzer) + + assert.Len(t, registry.analyzers, 3, "should have 3 analyzers (2 default + 1 custom)") + }) +}