diff --git a/internal/commands/release/mocks.go b/internal/commands/release/mocks.go index b561c23..3e1a463 100644 --- a/internal/commands/release/mocks.go +++ b/internal/commands/release/mocks.go @@ -175,6 +175,11 @@ func (m *MockReleaseService) ValidateMainBranch(ctx context.Context) error { return args.Error(0) } +func (m *MockReleaseService) BuildChangelogPreview(ctx context.Context, release *models.Release, notes *models.ReleaseNotes) string { + args := m.Called(ctx, release, notes) + return args.String(0) +} + func (m *MockGitService) FetchTags(ctx context.Context) error { args := m.Called(ctx) return args.Error(0) diff --git a/internal/commands/release/preview.go b/internal/commands/release/preview.go index 8190068..255f8c0 100644 --- a/internal/commands/release/preview.go +++ b/internal/commands/release/preview.go @@ -59,6 +59,7 @@ func previewReleaseAction(releaseSvc releaseService, trans *i18n.Translations) c "bugfixes_count", len(release.BugFixes), "breaking_count", len(release.Breaking)) + ui.PrintSectionHeader("📊 Release Summary") fmt.Println(trans.GetMessage("release.previous_version", 0, struct{ Version string }{release.PreviousVersion})) fmt.Println(trans.GetMessage("release.next_version", 0, struct { Version string @@ -93,25 +94,22 @@ func previewReleaseAction(releaseSvc releaseService, trans *i18n.Translations) c log.Debug("release notes generated", "title", notes.Title, - "highlights_count", len(notes.Highlights)) + "highlights_count", len(notes.Highlights), + "sections_count", len(notes.Sections)) - fmt.Println(trans.GetMessage("release.separator", 0, nil)) - fmt.Printf("## %s\n\n", notes.Title) - fmt.Printf("%s\n\n", notes.Summary) + ui.PrintSectionHeader("📝 CHANGELOG.md Preview") + changelogContent := releaseSvc.BuildChangelogPreview(ctx, release, notes) + fmt.Println(changelogContent) + fmt.Println() - if len(notes.Highlights) > 0 { - fmt.Println(trans.GetMessage("release.highlights_section", 0, nil)) - for _, h := range notes.Highlights { - fmt.Printf("- %s\n", h) - } - fmt.Println() - } + ui.PrintSectionHeader("🚀 GitHub Release Notes Preview") + githubContent := FormatReleaseMarkdown(release, notes, trans) + fmt.Println(githubContent) - fmt.Println(notes.Changelog) fmt.Println(trans.GetMessage("release.separator", 0, nil)) fmt.Println() - fmt.Println(trans.GetMessage("release.next_steps", 0, nil)) + ui.PrintSectionHeader("📋 Next Steps") fmt.Println(trans.GetMessage("release.next_steps_cmd", 0, nil)) fmt.Println() diff --git a/internal/commands/release/preview_test.go b/internal/commands/release/preview_test.go index 94464a5..a9c1a3c 100644 --- a/internal/commands/release/preview_test.go +++ b/internal/commands/release/preview_test.go @@ -54,6 +54,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) + mockService.On("BuildChangelogPreview", mock.Anything, mock.Anything, mock.Anything).Return("## [v1.0.0] - 2026-01-05\n\nTest changelog") err := runPreviewTest(t, []string{}, mockService) assert.NoError(t, err) diff --git a/internal/commands/release/release.go b/internal/commands/release/release.go index 6f2cb37..c4701db 100644 --- a/internal/commands/release/release.go +++ b/internal/commands/release/release.go @@ -32,6 +32,7 @@ type releaseService interface { PushChanges(ctx context.Context) error UpdateAppVersion(ctx context.Context, version string) error ValidateMainBranch(ctx context.Context) error + BuildChangelogPreview(ctx context.Context, release *models.Release, notes *models.ReleaseNotes) string } // gitService is a minimal interface for testing purposes diff --git a/internal/services/release_service.go b/internal/services/release_service.go index 5672219..6968b94 100644 --- a/internal/services/release_service.go +++ b/internal/services/release_service.go @@ -355,13 +355,43 @@ func (s *ReleaseService) UpdateLocalChangelog(release *models.Release, notes *mo "version", release.Version, "file", changelogFile) + if _, err := os.Stat(changelogFile); err == nil { + content, readErr := os.ReadFile(changelogFile) + if readErr == nil && strings.Contains(string(content), "## [Unreleased]") { + if err := s.MoveUnreleasedToVersion(changelogFile, release, notes); err != nil { + log.Warn("failed to move Unreleased section", "error", err) + } else { + log.Info("moved Unreleased section to new version", "version", release.Version) + return nil + } + } + } + newContent := s.buildChangelogFromNotes(context.Background(), release, notes) if err := s.prependToChangelog(changelogFile, newContent); err != nil { log.Error("failed to update changelog", "error", err, "file", changelogFile) - return err + return domainErrors.NewAppError( + domainErrors.TypeInternal, + "Failed to update CHANGELOG.md", + err, + ).WithSuggestion( + "Make sure CHANGELOG.md is writable and not locked by another process.\n" + + "Try: chmod +w CHANGELOG.md", + ) + } + + if err := s.EnsureUnreleasedSection(changelogFile); err != nil { + log.Warn("failed to ensure Unreleased section", "error", err) + } + + if warnings, err := s.ValidateChangelog(changelogFile); err == nil && len(warnings) > 0 { + log.Warn("CHANGELOG validation warnings detected", "count", len(warnings)) + for _, warning := range warnings { + log.Warn("CHANGELOG warning", "type", warning.Type, "message", warning.Message) + } } log.Info("changelog updated successfully", @@ -378,7 +408,14 @@ func (s *ReleaseService) prependToChangelog(filename, newContent string) error { return os.WriteFile(filename, []byte(header+newContent), 0644) } if err != nil { - return err + return domainErrors.NewAppError( + domainErrors.TypeInternal, + "Failed to read CHANGELOG.md", + err, + ).WithSuggestion( + "Ensure CHANGELOG.md exists and is readable.\n" + + "Try: ls -la CHANGELOG.md", + ) } current := string(content) @@ -490,6 +527,200 @@ func (s *ReleaseService) consolidateLinkDefinitions(content string) string { return strings.Join(result, "\n") } +// EnsureUnreleasedSection ensures an Unreleased section exists in the CHANGELOG +func (s *ReleaseService) EnsureUnreleasedSection(filename string) error { + content, err := os.ReadFile(filename) + if os.IsNotExist(err) { + header := `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +` + return os.WriteFile(filename, []byte(header), 0644) + } + if err != nil { + return domainErrors.NewAppError( + domainErrors.TypeInternal, + "Failed to read CHANGELOG.md for Unreleased section", + err, + ).WithSuggestion( + "Check if CHANGELOG.md is readable and not corrupted.\n" + + "Try: cat CHANGELOG.md", + ) + } + + current := string(content) + + if strings.Contains(current, "## [Unreleased]") { + return nil + } + + idx := strings.Index(current, "\n## ") + if idx == -1 { + current = strings.TrimSpace(current) + "\n\n## [Unreleased]\n\n" + } else { + pre := current[:idx] + post := current[idx:] + current = strings.TrimSpace(pre) + "\n\n## [Unreleased]\n\n" + post + } + + return os.WriteFile(filename, []byte(current), 0644) +} + +// parseUnreleasedSection extracts the Unreleased section content +func (s *ReleaseService) parseUnreleasedSection(content string) string { + unreleasedPattern := regexp.MustCompile(`(?s)## \[Unreleased]\s*\n(.*?)(?:## \[|$)`) + matches := unreleasedPattern.FindStringSubmatch(content) + + if len(matches) < 2 { + return "" + } + + return strings.TrimSpace(matches[1]) +} + +// MoveUnreleasedToVersion moves Unreleased section content to a new version +func (s *ReleaseService) MoveUnreleasedToVersion(filename string, release *models.Release, notes *models.ReleaseNotes) error { + content, err := os.ReadFile(filename) + if err != nil { + return domainErrors.NewAppError( + domainErrors.TypeInternal, + "Failed to read CHANGELOG.md for Unreleased migration", + err, + ).WithSuggestion( + "Ensure CHANGELOG.md exists and is readable.", + ) + } + + current := string(content) + + unreleasedContent := s.parseUnreleasedSection(current) + + if unreleasedContent == "" { + return nil + } + + log := logger.FromContext(context.Background()) + log.Info("moving Unreleased section to new version", + "version", release.Version, + "unreleased_content_length", len(unreleasedContent)) + + versionEntry := s.buildChangelogFromNotes(context.Background(), release, notes) + + versionEntry = strings.TrimSpace(versionEntry) + "\n\n" + unreleasedContent + "\n" + + unreleasedPattern := regexp.MustCompile(`(?s)## \[Unreleased]\s*\n.*?(\n## \[|$)`) + current = unreleasedPattern.ReplaceAllString(current, "$1") + + if err := os.WriteFile(filename, []byte(current), 0644); err != nil { + return domainErrors.NewAppError( + domainErrors.TypeInternal, + "Failed to write CHANGELOG.md during Unreleased migration", + err, + ).WithSuggestion( + "Check file permissions and disk space.\n" + + "Try: df -h . && chmod +w CHANGELOG.md", + ) + } + + if err := s.prependToChangelog(filename, versionEntry); err != nil { + return domainErrors.NewAppError( + domainErrors.TypeInternal, + "Failed to prepend new version during Unreleased migration", + err, + ).WithSuggestion( + "The Unreleased section was removed but the new version couldn't be added.\n" + + "You may need to manually restore CHANGELOG.md from git.", + ) + } + + return s.EnsureUnreleasedSection(filename) +} + +// ChangelogWarning represents a validation warning +type ChangelogWarning struct { + Type string + Message string +} + +// validateChangelogEntry validates a CHANGELOG entry and returns warnings +func (s *ReleaseService) validateChangelogEntry(content string, version string) []ChangelogWarning { + var warnings []ChangelogWarning + + datePattern := regexp.MustCompile(`## \[` + regexp.QuoteMeta(version) + `\] - \d{4}-\d{2}-\d{2}`) + if !datePattern.MatchString(content) { + warnings = append(warnings, ChangelogWarning{ + Type: "missing_date", + Message: fmt.Sprintf("Version %s is missing a date or date is not in ISO 8601 format (YYYY-MM-DD)", version), + }) + } + + linkPattern := regexp.MustCompile(`\[` + regexp.QuoteMeta(version) + `\]:\s*https?://`) + if !linkPattern.MatchString(content) { + warnings = append(warnings, ChangelogWarning{ + Type: "missing_link", + Message: fmt.Sprintf("Version %s is missing a comparison link", version), + }) + } + + if !strings.Contains(content, "###") { + warnings = append(warnings, ChangelogWarning{ + Type: "no_sections", + Message: fmt.Sprintf("Version %s has no sections (###). Consider organizing changes into sections.", version), + }) + } + + versionHeaderPattern := regexp.MustCompile(`(?s)## \[` + regexp.QuoteMeta(version) + `\].*?\n\n(.*?)(?:## \[|$)`) + matches := versionHeaderPattern.FindStringSubmatch(content) + if len(matches) > 1 { + actualContent := strings.TrimSpace(matches[1]) + actualContent = regexp.MustCompile(`(?m)^\[.*?]:.*$`).ReplaceAllString(actualContent, "") + actualContent = strings.TrimSpace(actualContent) + + if len(actualContent) < 50 { + warnings = append(warnings, ChangelogWarning{ + Type: "short_content", + Message: fmt.Sprintf("Version %s has very little content. Consider adding more details.", version), + }) + } + } + + return warnings +} + +// ValidateChangelog validates the entire CHANGELOG file +func (s *ReleaseService) ValidateChangelog(filename string) ([]ChangelogWarning, error) { + content, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + var allWarnings []ChangelogWarning + current := string(content) + + versionPattern := regexp.MustCompile(`## \[([^]]+)]`) + matches := versionPattern.FindAllStringSubmatch(current, -1) + + for _, match := range matches { + if len(match) > 1 { + version := match[1] + if version == "Unreleased" { + continue + } + + warnings := s.validateChangelogEntry(current, version) + allWarnings = append(allWarnings, warnings...) + } + } + + return allWarnings, nil +} + func (s *ReleaseService) analyzeDependencyChanges(ctx context.Context, release *models.Release) ([]models.DependencyChange, error) { if s.vcsClient == nil { return []models.DependencyChange{}, nil @@ -671,6 +902,11 @@ func (s *ReleaseService) buildChangelogFromNotes(ctx context.Context, release *m return sb.String() } +// BuildChangelogPreview generates a preview of how the CHANGELOG entry will look +func (s *ReleaseService) BuildChangelogPreview(ctx context.Context, release *models.Release, notes *models.ReleaseNotes) string { + return s.buildChangelogFromNotes(ctx, release, notes) +} + // buildChangelog formats the changelog from raw commits (fallback when AI is not available) func (s *ReleaseService) buildChangelog(release *models.Release) string { var sb strings.Builder diff --git a/internal/services/release_service_improvements_test.go b/internal/services/release_service_improvements_test.go new file mode 100644 index 0000000..43e4e93 --- /dev/null +++ b/internal/services/release_service_improvements_test.go @@ -0,0 +1,413 @@ +package services + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/models" +) + +// TestBuildChangelogFromNotes_WithDate tests that dates are always included +func TestBuildChangelogFromNotes_WithDate(t *testing.T) { + mockGit := &mockGitService{ + owner: "test", + repo: "repo", + provider: "github", + } + + service := &ReleaseService{ + git: mockGit, + } + ctx := context.Background() + + release := &models.Release{ + Version: "v1.8.0", + PreviousVersion: "v1.7.0", + } + + notes := &models.ReleaseNotes{ + Summary: "Test release", + Highlights: []string{ + "Feature 1", + "Feature 2", + }, + } + + result := service.buildChangelogFromNotes(ctx, release, notes) + + // Should always include a date in ISO 8601 format + assert.Contains(t, result, "## [v1.8.0] - ") + // Date should be in YYYY-MM-DD format + assert.Regexp(t, `## \[v1\.8\.0\] - \d{4}-\d{2}-\d{2}`, result) +} + +// TestBuildChangelogFromNotes_WithSemanticSections tests semantic sections rendering +func TestBuildChangelogFromNotes_WithSemanticSections(t *testing.T) { + mockGit := &mockGitService{owner: "test", repo: "repo", provider: "github"} + service := &ReleaseService{git: mockGit} + ctx := context.Background() + + release := &models.Release{ + Version: "v1.8.0", + PreviousVersion: "v1.7.0", + } + + notes := &models.ReleaseNotes{ + Summary: "Test release with sections", + Sections: []models.ReleaseNotesSection{ + { + Title: "✨ AI & Generation Improvements", + Items: []string{ + "Improved AI accuracy", + "Added new models", + }, + }, + { + Title: "🛠️ Templates & Configuration", + Items: []string{ + "New template system", + }, + }, + }, + } + + result := service.buildChangelogFromNotes(ctx, release, notes) + + // Should render sections + assert.Contains(t, result, "### ✨ AI & Generation Improvements") + assert.Contains(t, result, "- Improved AI accuracy") + assert.Contains(t, result, "- Added new models") + assert.Contains(t, result, "### 🛠️ Templates & Configuration") + assert.Contains(t, result, "- New template system") + + // Should NOT render Highlights section + assert.NotContains(t, result, "### ✨ Highlights") +} + +// TestBuildChangelogFromNotes_FallbackToHighlights tests backward compatibility +func TestBuildChangelogFromNotes_FallbackToHighlights(t *testing.T) { + mockGit := &mockGitService{owner: "test", repo: "repo", provider: "github"} + service := &ReleaseService{git: mockGit} + ctx := context.Background() + + release := &models.Release{ + Version: "v1.8.0", + PreviousVersion: "v1.7.0", + } + + notes := &models.ReleaseNotes{ + Summary: "Test release", + Highlights: []string{ + "Feature 1", + "Feature 2", + }, + // No sections - should fall back to Highlights + } + + result := service.buildChangelogFromNotes(ctx, release, notes) + + // Should render Highlights when no sections + assert.Contains(t, result, "### ✨ Highlights") + assert.Contains(t, result, "- Feature 1") + assert.Contains(t, result, "- Feature 2") +} + +// TestBuildChangelogFromNotes_WithBreakingChanges tests breaking changes rendering +func TestBuildChangelogFromNotes_WithBreakingChanges(t *testing.T) { + mockGit := &mockGitService{owner: "test", repo: "repo", provider: "github"} + service := &ReleaseService{git: mockGit} + ctx := context.Background() + + release := &models.Release{ + Version: "v2.0.0", + PreviousVersion: "v1.7.0", + } + + notes := &models.ReleaseNotes{ + Summary: "Major release", + Highlights: []string{ + "New feature", + }, + BreakingChanges: []string{ + "Removed deprecated API", + "Changed configuration format", + }, + } + + result := service.buildChangelogFromNotes(ctx, release, notes) + + // Should render breaking changes + assert.Contains(t, result, "### ⚠️ Breaking Changes") + assert.Contains(t, result, "- Removed deprecated API") + assert.Contains(t, result, "- Changed configuration format") +} + +// TestPrependToChangelog_NewFile tests creating a new CHANGELOG +func TestPrependToChangelog_NewFile(t *testing.T) { + service := &ReleaseService{} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + newContent := "## [v1.0.0] - 2026-01-05\n\nFirst release" + + err := service.prependToChangelog(changelogPath, newContent) + require.NoError(t, err) + + content, err := os.ReadFile(changelogPath) + require.NoError(t, err) + + result := string(content) + assert.Contains(t, result, "# Changelog") + assert.Contains(t, result, "All notable changes") + assert.Contains(t, result, "## [v1.0.0] - 2026-01-05") +} + +// TestPrependToChangelog_PreventsDuplicates tests duplicate prevention +func TestPrependToChangelog_PreventsDuplicates(t *testing.T) { + service := &ReleaseService{} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + // Create initial CHANGELOG with v1.0.0 + initial := `# Changelog + +All notable changes to this project will be documented in this file. + +## [v1.0.0] - 2026-01-05 + +[v1.0.0]: https://github.com/test/repo/releases/tag/v1.0.0 + +Initial release + +### ✨ Highlights +- Feature 1 +` + + err := os.WriteFile(changelogPath, []byte(initial), 0644) + require.NoError(t, err) + + // Try to add v1.0.0 again (should replace, not duplicate) + newContent := `## [v1.0.0] - 2026-01-05 + +[v1.0.0]: https://github.com/test/repo/releases/tag/v1.0.0 + +Updated release notes + +### ✨ Highlights +- Feature 1 (updated) +- Feature 2 +` + + err = service.prependToChangelog(changelogPath, newContent) + require.NoError(t, err) + + content, err := os.ReadFile(changelogPath) + require.NoError(t, err) + + result := string(content) + + // Should only have ONE occurrence of v1.0.0 + count := strings.Count(result, "## [v1.0.0]") + assert.Equal(t, 1, count, "Should have exactly one v1.0.0 entry") + + // Should have the updated content + assert.Contains(t, result, "Feature 2") + assert.Contains(t, result, "Updated release notes") +} + +// TestPrependToChangelog_AddsNewVersion tests adding a new version +func TestPrependToChangelog_AddsNewVersion(t *testing.T) { + service := &ReleaseService{} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + // Create initial CHANGELOG with v1.0.0 + initial := `# Changelog + +All notable changes to this project will be documented in this file. + +## [v1.0.0] - 2026-01-04 + +Initial release +` + + err := os.WriteFile(changelogPath, []byte(initial), 0644) + require.NoError(t, err) + + // Add v1.1.0 + newContent := `## [v1.1.0] - 2026-01-05 + +New features + +### ✨ Highlights +- Feature A +` + + err = service.prependToChangelog(changelogPath, newContent) + require.NoError(t, err) + + content, err := os.ReadFile(changelogPath) + require.NoError(t, err) + + result := string(content) + + // Should have both versions + assert.Contains(t, result, "## [v1.1.0]") + assert.Contains(t, result, "## [v1.0.0]") + + // v1.1.0 should come before v1.0.0 + v110Pos := strings.Index(result, "## [v1.1.0]") + v100Pos := strings.Index(result, "## [v1.0.0]") + assert.Less(t, v110Pos, v100Pos, "v1.1.0 should appear before v1.0.0") +} + +// TestConsolidateLinkDefinitions tests link deduplication +func TestConsolidateLinkDefinitions(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +## [v1.1.0] - 2026-01-05 + +[v1.1.0]: https://github.com/test/repo/compare/v1.0.0...v1.1.0 + +New features + +## [v1.0.0] - 2026-01-04 + +[v1.0.0]: https://github.com/test/repo/releases/tag/v1.0.0 +[v1.1.0]: https://github.com/test/repo/compare/v1.0.0...v1.1.0 + +Initial release +` + + result := service.consolidateLinkDefinitions(content) + + // Should only have ONE link definition for v1.1.0 + count := strings.Count(result, "[v1.1.0]:") + assert.Equal(t, 1, count, "Should have exactly one v1.1.0 link definition") + + // Should still have the link + assert.Contains(t, result, "[v1.1.0]: https://github.com/test/repo/compare/v1.0.0...v1.1.0") +} + +// TestConsolidateLinkDefinitions_DifferentURLs tests warning for conflicting URLs +func TestConsolidateLinkDefinitions_DifferentURLs(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +[v1.0.0]: https://github.com/test/repo/releases/tag/v1.0.0 +[v1.0.0]: https://github.com/different/url + +Content +` + + result := service.consolidateLinkDefinitions(content) + + // Should only keep the first one + count := strings.Count(result, "[v1.0.0]:") + assert.Equal(t, 1, count, "Should have exactly one v1.0.0 link definition") +} + +// TestBuildChangelogPreview tests the public preview method +func TestBuildChangelogPreview(t *testing.T) { + mockGit := &mockGitService{owner: "test", repo: "repo", provider: "github"} + service := &ReleaseService{git: mockGit} + ctx := context.Background() + + release := &models.Release{ + Version: "v1.8.0", + PreviousVersion: "v1.7.0", + } + + notes := &models.ReleaseNotes{ + Summary: "Preview test", + Sections: []models.ReleaseNotesSection{ + { + Title: "✨ New Features", + Items: []string{"Feature X"}, + }, + }, + } + + result := service.BuildChangelogPreview(ctx, release, notes) + + // Should be the same as buildChangelogFromNotes + assert.Contains(t, result, "## [v1.8.0]") + assert.Contains(t, result, "Preview test") + assert.Contains(t, result, "### ✨ New Features") +} + +// TestBuildChangelogFromNotes_WithGitHubLinks tests GitHub comparison links +func TestBuildChangelogFromNotes_WithGitHubLinks(t *testing.T) { + // Create a mock git service + mockGit := &mockGitService{ + owner: "thomas-vilte", + repo: "matecommit", + provider: "github", + } + + service := &ReleaseService{ + git: mockGit, + } + ctx := context.Background() + + release := &models.Release{ + Version: "v1.8.0", + PreviousVersion: "v1.7.0", + } + + notes := &models.ReleaseNotes{ + Summary: "Test", + Highlights: []string{"Feature"}, + } + + result := service.buildChangelogFromNotes(ctx, release, notes) + + // Should include GitHub comparison link + assert.Contains(t, result, "[v1.8.0]: https://github.com/thomas-vilte/matecommit/compare/v1.7.0...v1.8.0") +} + +// mockGitService is a simple mock for testing +type mockGitService struct { + owner string + repo string + provider string + tagDate string +} + +func (m *mockGitService) GetTagDate(ctx context.Context, version string) (string, error) { + if m.tagDate != "" { + return m.tagDate, nil + } + return "", nil // Simulate tag not found +} + +func (m *mockGitService) GetRepoInfo(ctx context.Context) (string, string, string, error) { + return m.owner, m.repo, m.provider, nil +} + +// Implement other required methods as no-ops +func (m *mockGitService) GetLastTag(ctx context.Context) (string, error) { return "", nil } +func (m *mockGitService) GetCommitCount(ctx context.Context) (int, error) { return 0, nil } +func (m *mockGitService) GetCommitsSinceTag(ctx context.Context, tag string) ([]models.Commit, error) { + return nil, nil +} +func (m *mockGitService) CreateTag(ctx context.Context, version, message string) error { return nil } +func (m *mockGitService) PushTag(ctx context.Context, version string) error { return nil } +func (m *mockGitService) AddFileToStaging(ctx context.Context, file string) error { return nil } +func (m *mockGitService) HasStagedChanges(ctx context.Context) bool { return false } +func (m *mockGitService) CreateCommit(ctx context.Context, message string) error { return nil } +func (m *mockGitService) Push(ctx context.Context) error { return nil } +func (m *mockGitService) GetCurrentBranch(ctx context.Context) (string, error) { return "main", nil } +func (m *mockGitService) FetchTags(ctx context.Context) error { return nil } +func (m *mockGitService) ValidateTagExists(ctx context.Context, tag string) error { return nil } diff --git a/internal/services/release_service_unreleased_test.go b/internal/services/release_service_unreleased_test.go new file mode 100644 index 0000000..c9c4d11 --- /dev/null +++ b/internal/services/release_service_unreleased_test.go @@ -0,0 +1,316 @@ +package services + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thomas-vilte/matecommit/internal/models" +) + +// TestEnsureUnreleasedSection_NewFile tests creating CHANGELOG with Unreleased +func TestEnsureUnreleasedSection_NewFile(t *testing.T) { + service := &ReleaseService{} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + err := service.EnsureUnreleasedSection(changelogPath) + require.NoError(t, err) + + content, err := os.ReadFile(changelogPath) + require.NoError(t, err) + + result := string(content) + assert.Contains(t, result, "# Changelog") + assert.Contains(t, result, "## [Unreleased]") + assert.Contains(t, result, "Keep a Changelog") + assert.Contains(t, result, "Semantic Versioning") +} + +// TestEnsureUnreleasedSection_ExistingFile tests adding Unreleased to existing CHANGELOG +func TestEnsureUnreleasedSection_ExistingFile(t *testing.T) { + service := &ReleaseService{} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + // Create existing CHANGELOG without Unreleased + initial := `# Changelog + +All notable changes to this project will be documented in this file. + +## [v1.0.0] - 2026-01-05 + +Initial release +` + + err := os.WriteFile(changelogPath, []byte(initial), 0644) + require.NoError(t, err) + + err = service.EnsureUnreleasedSection(changelogPath) + require.NoError(t, err) + + content, err := os.ReadFile(changelogPath) + require.NoError(t, err) + + result := string(content) + assert.Contains(t, result, "## [Unreleased]") + + // Unreleased should come before v1.0.0 + unreleasedPos := strings.Index(result, "## [Unreleased]") + v100Pos := strings.Index(result, "## [v1.0.0]") + assert.Less(t, unreleasedPos, v100Pos, "Unreleased should appear before v1.0.0") +} + +// TestEnsureUnreleasedSection_AlreadyExists tests idempotency +func TestEnsureUnreleasedSection_AlreadyExists(t *testing.T) { + service := &ReleaseService{} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + // Create CHANGELOG with Unreleased + initial := `# Changelog + +## [Unreleased] + +### Added +- Pending feature + +## [v1.0.0] - 2026-01-05 + +Initial release +` + + err := os.WriteFile(changelogPath, []byte(initial), 0644) + require.NoError(t, err) + + err = service.EnsureUnreleasedSection(changelogPath) + require.NoError(t, err) + + content, err := os.ReadFile(changelogPath) + require.NoError(t, err) + + result := string(content) + + // Should only have ONE Unreleased section + count := strings.Count(result, "## [Unreleased]") + assert.Equal(t, 1, count, "Should have exactly one Unreleased section") + + // Content should be unchanged + assert.Contains(t, result, "Pending feature") +} + +// TestParseUnreleasedSection tests extracting Unreleased content +func TestParseUnreleasedSection(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +## [Unreleased] + +### Added +- New authentication system +- User profiles + +### Fixed +- Login bug + +## [v1.0.0] - 2026-01-05 + +Initial release +` + + result := service.parseUnreleasedSection(content) + + assert.Contains(t, result, "### Added") + assert.Contains(t, result, "New authentication system") + assert.Contains(t, result, "User profiles") + assert.Contains(t, result, "### Fixed") + assert.Contains(t, result, "Login bug") + + // Should NOT contain the version section + assert.NotContains(t, result, "## [v1.0.0]") + assert.NotContains(t, result, "Initial release") +} + +// TestParseUnreleasedSection_Empty tests with no Unreleased content +func TestParseUnreleasedSection_Empty(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +## [v1.0.0] - 2026-01-05 + +Initial release +` + + result := service.parseUnreleasedSection(content) + assert.Empty(t, result, "Should return empty string when no Unreleased section") +} + +// TestMoveUnreleasedToVersion tests moving Unreleased to new version +func TestMoveUnreleasedToVersion(t *testing.T) { + mockGit := &mockGitService{owner: "test", repo: "repo", provider: "github"} + service := &ReleaseService{git: mockGit} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + // Create CHANGELOG with Unreleased content + initial := `# Changelog + +## [Unreleased] + +### Added +- New feature from Unreleased +- Another feature + +## [v1.0.0] - 2026-01-04 + +Initial release +` + + err := os.WriteFile(changelogPath, []byte(initial), 0644) + require.NoError(t, err) + + release := &models.Release{ + Version: "v1.1.0", + PreviousVersion: "v1.0.0", + } + + notes := &models.ReleaseNotes{ + Summary: "New release", + Highlights: []string{ + "Feature from AI", + }, + } + + err = service.MoveUnreleasedToVersion(changelogPath, release, notes) + require.NoError(t, err) + + content, err := os.ReadFile(changelogPath) + require.NoError(t, err) + + result := string(content) + + // Should have new version + assert.Contains(t, result, "## [v1.1.0]") + + // New version should contain Unreleased content + assert.Contains(t, result, "New feature from Unreleased") + assert.Contains(t, result, "Another feature") + + // Should also contain AI-generated content + assert.Contains(t, result, "Feature from AI") + + // Should have a NEW empty Unreleased section + assert.Contains(t, result, "## [Unreleased]") + + // Unreleased should come before v1.1.0 + unreleasedPos := strings.Index(result, "## [Unreleased]") + v110Pos := strings.Index(result, "## [v1.1.0]") + assert.Less(t, unreleasedPos, v110Pos, "Unreleased should appear before v1.1.0") + + // v1.1.0 should come before v1.0.0 + v100Pos := strings.Index(result, "## [v1.0.0]") + assert.Less(t, v110Pos, v100Pos, "v1.1.0 should appear before v1.0.0") +} + +// TestMoveUnreleasedToVersion_NoUnreleased tests when no Unreleased content exists +func TestMoveUnreleasedToVersion_NoUnreleased(t *testing.T) { + mockGit := &mockGitService{owner: "test", repo: "repo", provider: "github"} + service := &ReleaseService{git: mockGit} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + // Create CHANGELOG without Unreleased content + initial := `# Changelog + +## [v1.0.0] - 2026-01-04 + +Initial release +` + + err := os.WriteFile(changelogPath, []byte(initial), 0644) + require.NoError(t, err) + + release := &models.Release{ + Version: "v1.1.0", + PreviousVersion: "v1.0.0", + } + + notes := &models.ReleaseNotes{ + Summary: "New release", + Highlights: []string{"Feature"}, + } + + err = service.MoveUnreleasedToVersion(changelogPath, release, notes) + require.NoError(t, err) + + // Should succeed without error (no-op when no Unreleased content) +} + +// TestUpdateLocalChangelog_WithUnreleased tests the integration +func TestUpdateLocalChangelog_WithUnreleased(t *testing.T) { + mockGit := &mockGitService{owner: "test", repo: "repo", provider: "github"} + service := &ReleaseService{git: mockGit} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + // Create CHANGELOG with Unreleased + initial := `# Changelog + +## [Unreleased] + +### Added +- Feature from Unreleased + +## [v1.0.0] - 2026-01-04 + +Initial release +` + + err := os.WriteFile(changelogPath, []byte(initial), 0644) + require.NoError(t, err) + + // Change to temp dir so UpdateLocalChangelog finds the file + oldWd, _ := os.Getwd() + defer func() { + if err := os.Chdir(oldWd); err != nil { + t.Fatal(err) + } + }() + _ = os.Chdir(tmpDir) + + release := &models.Release{ + Version: "v1.1.0", + PreviousVersion: "v1.0.0", + } + + notes := &models.ReleaseNotes{ + Summary: "New release", + Highlights: []string{"AI feature"}, + } + + err = service.UpdateLocalChangelog(release, notes) + require.NoError(t, err) + + content, err := os.ReadFile("CHANGELOG.md") + require.NoError(t, err) + + result := string(content) + + // Should have moved Unreleased to v1.1.0 + assert.Contains(t, result, "## [v1.1.0]") + assert.Contains(t, result, "Feature from Unreleased") + + // Should have new empty Unreleased section + assert.Contains(t, result, "## [Unreleased]") +} diff --git a/internal/services/release_service_validation_test.go b/internal/services/release_service_validation_test.go new file mode 100644 index 0000000..4bc806e --- /dev/null +++ b/internal/services/release_service_validation_test.go @@ -0,0 +1,277 @@ +package services + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestValidateChangelogEntry_Valid tests validation with valid entry +func TestValidateChangelogEntry_Valid(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +## [v1.0.0] - 2026-01-05 + +[v1.0.0]: https://github.com/test/repo/releases/tag/v1.0.0 + +Summary text + +### ✨ Highlights +- Feature 1 +- Feature 2 +` + + warnings := service.validateChangelogEntry(content, "v1.0.0") + assert.Empty(t, warnings, "Valid entry should have no warnings") +} + +// TestValidateChangelogEntry_MissingDate tests missing date detection +func TestValidateChangelogEntry_MissingDate(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +## [v1.0.0] + +[v1.0.0]: https://github.com/test/repo/releases/tag/v1.0.0 + +### ✨ Highlights +- Feature 1 +` + + warnings := service.validateChangelogEntry(content, "v1.0.0") + assert.NotEmpty(t, warnings) + + // Check for missing_date warning + var hasMissingDate bool + for _, w := range warnings { + if w.Type == "missing_date" { + hasMissingDate = true + assert.Contains(t, w.Message, "missing a date") + break + } + } + assert.True(t, hasMissingDate, "Should have missing_date warning") +} + +// TestValidateChangelogEntry_InvalidDateFormat tests invalid date format +func TestValidateChangelogEntry_InvalidDateFormat(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +## [v1.0.0] - 01/05/2026 + +[v1.0.0]: https://github.com/test/repo/releases/tag/v1.0.0 + +### ✨ Highlights +- Feature 1 +` + + warnings := service.validateChangelogEntry(content, "v1.0.0") + assert.NotEmpty(t, warnings) + + // Check for missing_date warning with ISO 8601 message + var hasMissingDate bool + for _, w := range warnings { + if w.Type == "missing_date" { + hasMissingDate = true + assert.Contains(t, w.Message, "ISO 8601") + break + } + } + assert.True(t, hasMissingDate, "Should have missing_date warning") +} + +// TestValidateChangelogEntry_MissingLink tests missing comparison link +func TestValidateChangelogEntry_MissingLink(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +## [v1.0.0] - 2026-01-05 + +### ✨ Highlights +- Feature 1 +` + + warnings := service.validateChangelogEntry(content, "v1.0.0") + assert.NotEmpty(t, warnings) + + // Check for missing_link warning + var hasMissingLink bool + for _, w := range warnings { + if w.Type == "missing_link" { + hasMissingLink = true + assert.Contains(t, w.Message, "missing a comparison link") + break + } + } + assert.True(t, hasMissingLink, "Should have missing_link warning") +} + +// TestValidateChangelogEntry_NoSections tests missing sections +func TestValidateChangelogEntry_NoSections(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +## [v1.0.0] - 2026-01-05 + +[v1.0.0]: https://github.com/test/repo/releases/tag/v1.0.0 + +Just some text without sections +` + + warnings := service.validateChangelogEntry(content, "v1.0.0") + assert.NotEmpty(t, warnings) + + // Check for no_sections warning + var hasNoSections bool + for _, w := range warnings { + if w.Type == "no_sections" { + hasNoSections = true + assert.Contains(t, w.Message, "no sections") + break + } + } + assert.True(t, hasNoSections, "Should have no_sections warning") +} + +// TestValidateChangelogEntry_ShortContent tests short content detection +func TestValidateChangelogEntry_ShortContent(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +## [v1.0.0] - 2026-01-05 + +[v1.0.0]: https://github.com/test/repo/releases/tag/v1.0.0 + +### ✨ Highlights +- X +` + + warnings := service.validateChangelogEntry(content, "v1.0.0") + + // Should have warning about short content + var hasShortContent bool + for _, w := range warnings { + if w.Type == "short_content" { + hasShortContent = true + break + } + } + assert.True(t, hasShortContent, "Should warn about short content") +} + +// TestValidateChangelogEntry_MultipleIssues tests multiple warnings +func TestValidateChangelogEntry_MultipleIssues(t *testing.T) { + service := &ReleaseService{} + + content := `# Changelog + +## [v1.0.0] + +Short +` + + warnings := service.validateChangelogEntry(content, "v1.0.0") + + // Should have multiple warnings + assert.GreaterOrEqual(t, len(warnings), 3, "Should have at least 3 warnings") + + // Check for specific warning types + types := make(map[string]bool) + for _, w := range warnings { + types[w.Type] = true + } + + assert.True(t, types["missing_date"], "Should warn about missing date") + assert.True(t, types["missing_link"], "Should warn about missing link") + assert.True(t, types["no_sections"], "Should warn about no sections") +} + +// TestValidateChangelog tests validating entire CHANGELOG +func TestValidateChangelog(t *testing.T) { + service := &ReleaseService{} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + content := `# Changelog + +## [Unreleased] + +## [v1.1.0] - 2026-01-05 + +[v1.1.0]: https://github.com/test/repo/compare/v1.0.0...v1.1.0 + +### ✨ Features +- New feature with detailed description +- Another feature with more details +- Third feature to ensure content is long enough + +### 🐛 Bug Fixes +- Fixed important bug +- Fixed another bug + +## [v1.0.0] + +Initial release +` + + err := os.WriteFile(changelogPath, []byte(content), 0644) + require.NoError(t, err) + + warnings, err := service.ValidateChangelog(changelogPath) + require.NoError(t, err) + + // v1.1.0 should be valid, v1.0.0 should have warnings + assert.NotEmpty(t, warnings, "Should have warnings for v1.0.0") + + // Check that warnings are for v1.0.0, not v1.1.0 or Unreleased + for _, w := range warnings { + assert.Contains(t, w.Message, "v1.0.0") + assert.NotContains(t, w.Message, "v1.1.0") + assert.NotContains(t, w.Message, "Unreleased") + } +} + +// TestValidateChangelog_SkipsUnreleased tests that Unreleased is skipped +func TestValidateChangelog_SkipsUnreleased(t *testing.T) { + service := &ReleaseService{} + + tmpDir := t.TempDir() + changelogPath := filepath.Join(tmpDir, "CHANGELOG.md") + + content := `# Changelog + +## [Unreleased] + +Some pending changes + +## [v1.0.0] - 2026-01-05 + +[v1.0.0]: https://github.com/test/repo/releases/tag/v1.0.0 + +### ✨ Features +- Feature +` + + err := os.WriteFile(changelogPath, []byte(content), 0644) + require.NoError(t, err) + + warnings, err := service.ValidateChangelog(changelogPath) + require.NoError(t, err) + + // Should not have warnings about Unreleased + for _, w := range warnings { + assert.NotContains(t, w.Message, "Unreleased") + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index a3ba59f..06a64b9 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -583,3 +583,9 @@ func parseInt(s string) int { _, _ = fmt.Sscanf(s, "%d", &result) return result } + +// PrintSectionHeader prints a visually distinct section header +func PrintSectionHeader(title string) { + separator := strings.Repeat("─", 60) + fmt.Printf("\n%s\n%s\n%s\n\n", separator, title, separator) +}