Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/commands/release/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 11 additions & 13 deletions internal/commands/release/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions internal/commands/release/preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions internal/commands/release/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
240 changes: 238 additions & 2 deletions internal/services/release_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading