| | | 1 | | package changelog |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "bytes" |
| | | 5 | | "fmt" |
| | | 6 | | "strings" |
| | | 7 | | "text/template" |
| | | 8 | | "time" |
| | | 9 | | |
| | | 10 | | "github.com/jedi-knights/go-semantic-release/internal/domain" |
| | | 11 | | "github.com/jedi-knights/go-semantic-release/internal/ports" |
| | | 12 | | ) |
| | | 13 | | |
| | | 14 | | // Compile-time interface compliance check. |
| | | 15 | | var _ ports.ChangelogGenerator = (*TemplateGenerator)(nil) |
| | | 16 | | |
| | | 17 | | const defaultTemplate = `## {{if .Project}}[{{.Project}}] {{end}}{{.Version}} ({{.Date}}) |
| | | 18 | | {{range .Sections}} |
| | | 19 | | ### {{.Title}} |
| | | 20 | | |
| | | 21 | | {{range .Commits}}- {{if .Scope}}**{{.Scope}}:** {{end}}{{.Description}} ({{.ShortHash}}) |
| | | 22 | | {{end}}{{end}}` |
| | | 23 | | |
| | | 24 | | // TemplateGenerator implements ports.ChangelogGenerator using Go templates. |
| | | 25 | | type TemplateGenerator struct { |
| | | 26 | | customTemplate string |
| | | 27 | | } |
| | | 28 | | |
| | | 29 | | // NewTemplateGenerator creates a changelog generator with an optional custom template. |
| | 10 | 30 | | func NewTemplateGenerator(customTemplate string) *TemplateGenerator { |
| | 10 | 31 | | return &TemplateGenerator{customTemplate: customTemplate} |
| | 10 | 32 | | } |
| | | 33 | | |
| | | 34 | | type templateData struct { |
| | | 35 | | Version string |
| | | 36 | | Project string |
| | | 37 | | Date string |
| | | 38 | | Sections []sectionData |
| | | 39 | | } |
| | | 40 | | |
| | | 41 | | type sectionData struct { |
| | | 42 | | Title string |
| | | 43 | | Commits []commitData |
| | | 44 | | } |
| | | 45 | | |
| | | 46 | | type commitData struct { |
| | | 47 | | Hash string |
| | | 48 | | ShortHash string |
| | | 49 | | Type string |
| | | 50 | | Scope string |
| | | 51 | | Description string |
| | | 52 | | Author string |
| | | 53 | | Breaking bool |
| | | 54 | | } |
| | | 55 | | |
| | | 56 | | func (g *TemplateGenerator) Generate( |
| | | 57 | | version domain.Version, |
| | | 58 | | project string, |
| | | 59 | | commits []domain.Commit, |
| | | 60 | | sections []domain.ChangelogSectionConfig, |
| | | 61 | | ) (string, error) { |
| | | 62 | | data := g.buildTemplateData(version, project, commits, sections) |
| | | 63 | | |
| | | 64 | | tmplStr := defaultTemplate |
| | | 65 | | if g.customTemplate != "" { |
| | | 66 | | tmplStr = g.customTemplate |
| | | 67 | | } |
| | | 68 | | |
| | | 69 | | tmpl, err := template.New("changelog").Parse(tmplStr) |
| | | 70 | | if err != nil { |
| | | 71 | | return "", fmt.Errorf("parsing changelog template: %w", err) |
| | | 72 | | } |
| | | 73 | | |
| | | 74 | | var buf bytes.Buffer |
| | | 75 | | if err := tmpl.Execute(&buf, data); err != nil { |
| | | 76 | | return "", fmt.Errorf("executing changelog template: %w", err) |
| | | 77 | | } |
| | | 78 | | return strings.TrimSpace(buf.String()), nil |
| | | 79 | | } |
| | | 80 | | |
| | | 81 | | func (g *TemplateGenerator) buildTemplateData( |
| | | 82 | | version domain.Version, |
| | | 83 | | project string, |
| | | 84 | | commits []domain.Commit, |
| | | 85 | | sections []domain.ChangelogSectionConfig, |
| | | 86 | | ) templateData { |
| | | 87 | | // Group breaking changes separately. |
| | | 88 | | breakingCommits := filterBreakingCommits(commits) |
| | | 89 | | commitsByType := groupCommitsByType(commits) |
| | | 90 | | |
| | | 91 | | secs := make([]sectionData, 0, len(sections)) |
| | | 92 | | for _, sec := range sections { |
| | | 93 | | if sec.Hidden { |
| | | 94 | | continue |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | var sectionCommits []domain.Commit |
| | | 98 | | if sec.Type == "breaking" { |
| | | 99 | | sectionCommits = breakingCommits |
| | | 100 | | } else { |
| | | 101 | | sectionCommits = commitsByType[sec.Type] |
| | | 102 | | } |
| | | 103 | | |
| | | 104 | | if len(sectionCommits) == 0 { |
| | | 105 | | continue |
| | | 106 | | } |
| | | 107 | | |
| | | 108 | | secs = append(secs, sectionData{ |
| | | 109 | | Title: sec.Title, |
| | | 110 | | Commits: toCommitData(sectionCommits), |
| | | 111 | | }) |
| | | 112 | | } |
| | | 113 | | |
| | | 114 | | return templateData{ |
| | | 115 | | Version: version.String(), |
| | | 116 | | Project: project, |
| | | 117 | | Date: time.Now().Format("2006-01-02"), |
| | | 118 | | Sections: secs, |
| | | 119 | | } |
| | | 120 | | } |
| | | 121 | | |
| | 10 | 122 | | func filterBreakingCommits(commits []domain.Commit) []domain.Commit { |
| | 10 | 123 | | var result []domain.Commit |
| | 7 | 124 | | for i := range commits { |
| | 1 | 125 | | if commits[i].IsBreakingChange { |
| | 1 | 126 | | result = append(result, commits[i]) |
| | 1 | 127 | | } |
| | | 128 | | } |
| | 10 | 129 | | return result |
| | | 130 | | } |
| | | 131 | | |
| | 10 | 132 | | func groupCommitsByType(commits []domain.Commit) map[string][]domain.Commit { |
| | 10 | 133 | | groups := make(map[string][]domain.Commit) |
| | 7 | 134 | | for i := range commits { |
| | 7 | 135 | | if commits[i].Type != "" { |
| | 7 | 136 | | groups[commits[i].Type] = append(groups[commits[i].Type], commits[i]) |
| | 7 | 137 | | } |
| | | 138 | | } |
| | 10 | 139 | | return groups |
| | | 140 | | } |
| | | 141 | | |
| | 7 | 142 | | func toCommitData(commits []domain.Commit) []commitData { |
| | 7 | 143 | | result := make([]commitData, 0, len(commits)) |
| | 7 | 144 | | for i := range commits { |
| | 7 | 145 | | short := commits[i].Hash |
| | 1 | 146 | | if len(short) > 7 { |
| | 1 | 147 | | short = short[:7] |
| | 1 | 148 | | } |
| | 7 | 149 | | result = append(result, commitData{ |
| | 7 | 150 | | Hash: commits[i].Hash, |
| | 7 | 151 | | ShortHash: short, |
| | 7 | 152 | | Type: commits[i].Type, |
| | 7 | 153 | | Scope: commits[i].Scope, |
| | 7 | 154 | | Description: commits[i].Description, |
| | 7 | 155 | | Author: commits[i].Author, |
| | 7 | 156 | | Breaking: commits[i].IsBreakingChange, |
| | 7 | 157 | | }) |
| | | 158 | | } |
| | 7 | 159 | | return result |
| | | 160 | | } |