diff --git a/.sv4git.yml b/.sv4git.yml index 99fed19..ff2b04d 100644 --- a/.sv4git.yml +++ b/.sv4git.yml @@ -1,4 +1,4 @@ -version: "1.0" +version: "1.1" versioning: update-major: [] diff --git a/README.md b/README.md index cae1622..b2c6d3e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

sv4git

-

semantic version for git

+

A command line tool (CLI) to validate commit messages, bump version, create tags and generate changelogs!

Release Go Reference @@ -26,11 +26,17 @@ If you want to install from source using `go install`, just run: ```bash +# keep in mind that with this, it will compile from source and won't show the version on cli -h. go install github.com/bvieira/sv4git/v2/cmd/git-sv@latest + +# if you want to add the version on the binary, run this command instead. +SV4GIT_VERSION=$(go list -f '{{ .Version }}' -m github.com/bvieira/sv4git/v2@latest | sed 's/v//') && go install --ldflags "-X main.Version=$SV4GIT_VERSION" github.com/bvieira/sv4git/v2/cmd/git-sv@v$SV4GIT_VERSION ``` ### Config +#### YAML + There are 3 config levels when using sv4git: [default](#default), [user](#user), [repository](#repository). All of them are merged considering the follow priority: **repository > user > default**. To see the current config, run: @@ -39,9 +45,9 @@ To see the current config, run: git sv cfg show ``` -#### Configuration Types +##### Configuration Types -##### Default +###### Default To check the default configuration, run: @@ -49,7 +55,7 @@ To check the default configuration, run: git sv cfg default ``` -##### User +###### User For user config, it is necessary to define the `SV4GIT_HOME` environment variable, eg.: @@ -64,14 +70,14 @@ And create a `config.yml` file inside it, eg.: └── config.yml ``` -##### Repository +###### Repository Create a `.sv4git.yml` file on the root of your repository, eg.: [.sv4git.yml](.sv4git.yml). -#### Configuration format +##### Configuration format ```yml -version: "1.0" #config version +version: "1.1" #config version versioning: # versioning bump update-major: [] # Commit types used to bump major. @@ -85,13 +91,24 @@ tag: pattern: '%d.%d.%d' # Pattern used to create git tag. release-notes: - # Headers names for release notes markdown. To disable a section just remove the header line. - # It's possible to add other commit types, the release note will be created respecting the following order: - # feat, fix, refactor, perf, test, build, ci, chore, docs, style, breaking-change + # Deprecated!!! please use 'sections' instead! + # Headers names for release notes markdown. To disable a section just remove the header + # line. It's possible to add other commit types, the release note will be created + # respecting the following order: feat, fix, refactor, perf, test, build, ci, chore, docs, style, breaking-change. headers: breaking-change: Breaking Changes feat: Features fix: Bug Fixes + + sections: # Array with each section of release note. Check template section for more information. + - name: Features # Name used on section. + section-type: commits # Type of the section, supported types: commits, breaking-changes. + commit-types: [feat] # Commit types for commit section-type, one commit type cannot be in more than one section. + - name: Bug Fixes + section-type: commits + commit-types: [fix] + - name: Breaking Changes + section-type: breaking-changes branches: # Git branches config. prefix: ([a-z]+\/)? # Prefix used on branch name, it should be a regex group. @@ -116,6 +133,95 @@ commit-message: regex: '[A-Z]+-[0-9]+' # Regex for issue id. ``` +#### Templates + +**sv4git** uses *go templates* to format the output for `release-notes` and `changelog`, to see how the default template is configured check [template directory](cmd/git-sv/resources/templates). On v2.7.0+, its possible to overwrite the default configuration by adding `.sv4git/templates` on your repository. The cli expects that at least 2 files exists on your directory: `changelog-md.tpl` and `releasenotes-md.tpl`. + +```bash +.sv4git +└── templates + ├── changelog-md.tpl + └── releasenotes-md.tpl +``` + +Everything inside `.sv4git/templates` will be loaded, so it's possible to add more files to be used as needed. + +##### Variables + +To execute the template the `releasenotes-md.tpl` will receive a single **ReleaseNote** and `changelog-md.tpl` will receive a list of **ReleaseNote** as variables. + +Each **ReleaseNoteSection** will be configured according with `release-notes.section` from config file. The order for each section will be maintained and the **SectionType** is defined according with `section-type` attribute as described on the table below. + +| section-type | ReleaseNoteSection | +| -- | -- | +| commits | ReleaseNoteCommitsSection | +| breaking-changes | ReleaseNoteBreakingChangeSection | + +> :warning: currently only `commits` and `breaking-changes` are supported as `section-types`, using a different value for this field will make the section to be removed from the template variables. + +Check below the variables available: + +```go +ReleaseNote + Release string // 'v' followed by version if present, if not tag will be used instead. + Tag string // Current tag, if available. + Version *Version // Version from tag or next version according with semver. + Date time.Time + Sections []ReleaseNoteSection // ReleaseNoteCommitsSection or ReleaseNoteBreakingChangeSection + AuthorNames []string // Author names recovered from commit message (user.name from git) + +Version + Major int + Minor int + Patch int + Prerelease string + Metadata string + Original string + +ReleaseNoteCommitsSection // SectionType == commits + SectionType string + SectionName string + Types []string + Items []GitCommitLog + HasMultipleTypes bool + +ReleaseNoteBreakingChangeSection // SectionType == breaking-changes + SectionType string + SectionName string + Messages []string + +GitCommitLog + Date string + Timestamp int + AuthorName string + Hash string + Message CommitMessage + +CommitMessage + Type string + Scope string + Description string + Body string + IsBreakingChange bool + Metadata map[string]string +``` + +##### Functions + +Beside the [go template functions](https://pkg.go.dev/text/template#hdr-Functions), the folowing functions are availiable to use in the templates. Check [formatter_functions.go](sv/formatter_functions.go) to see the functions implementation. + +###### timefmt + +**Usage:** timefmt time "2006-01-02" + +Receive a time.Time and a layout string and returns a textual representation of the time according with the layout provided. Check for more information. + +###### getsection + +**Usage:** getsection sections "Features" + +Receive a list of ReleaseNoteSection and a Section name and returns a section with the provided name. If no section is found, it will return `nil`. + ### Running Run `git-sv` to get the list of available parameters: diff --git a/cmd/git-sv/config.go b/cmd/git-sv/config.go index 9df2d16..4bbd0e2 100644 --- a/cmd/git-sv/config.go +++ b/cmd/git-sv/config.go @@ -70,15 +70,21 @@ func readConfig(filepath string) (Config, error) { func defaultConfig() Config { skipDetached := false return Config{ - Version: "1.0", + Version: "1.1", Versioning: sv.VersioningConfig{ UpdateMajor: []string{}, UpdateMinor: []string{"feat"}, UpdatePatch: []string{"build", "ci", "chore", "docs", "fix", "perf", "refactor", "style", "test"}, IgnoreUnknown: false, }, - Tag: sv.TagConfig{Pattern: "%d.%d.%d"}, - ReleaseNotes: sv.ReleaseNotesConfig{Headers: map[string]string{"fix": "Bug Fixes", "feat": "Features", "breaking-change": "Breaking Changes"}}, + Tag: sv.TagConfig{Pattern: "%d.%d.%d"}, + ReleaseNotes: sv.ReleaseNotesConfig{ + Sections: []sv.ReleaseNotesSectionConfig{ + {Name: "Features", SectionType: sv.ReleaseNotesSectionTypeCommits, CommitTypes: []string{"feat"}}, + {Name: "Bug Fixes", SectionType: sv.ReleaseNotesSectionTypeCommits, CommitTypes: []string{"fix"}}, + {Name: "Breaking Changes", SectionType: sv.ReleaseNotesSectionTypeBreakingChanges}, + }, + }, Branches: sv.BranchesConfig{ Prefix: "([a-z]+\\/)?", Suffix: "(-.*)?", @@ -129,3 +135,35 @@ func (t *mergeTransformer) Transformer(typ reflect.Type) func(dst, src reflect.V } return nil } + +func migrateConfig(cfg Config, filename string) Config { + if cfg.ReleaseNotes.Headers == nil { + return cfg + } + warnf("config 'release-notes.headers' on %s is deprecated, please use 'sections' instead!", filename) + + return Config{ + Version: cfg.Version, + Versioning: cfg.Versioning, + Tag: cfg.Tag, + ReleaseNotes: sv.ReleaseNotesConfig{ + Sections: migrateReleaseNotesConfig(cfg.ReleaseNotes.Headers), + }, + Branches: cfg.Branches, + CommitMessage: cfg.CommitMessage, + } +} + +func migrateReleaseNotesConfig(headers map[string]string) []sv.ReleaseNotesSectionConfig { + order := []string{"feat", "fix", "refactor", "perf", "test", "build", "ci", "chore", "docs", "style"} + var sections []sv.ReleaseNotesSectionConfig + for _, key := range order { + if name, exists := headers[key]; exists { + sections = append(sections, sv.ReleaseNotesSectionConfig{Name: name, SectionType: sv.ReleaseNotesSectionTypeCommits, CommitTypes: []string{key}}) + } + } + if name, exists := headers["breaking-change"]; exists { + sections = append(sections, sv.ReleaseNotesSectionConfig{Name: name, SectionType: sv.ReleaseNotesSectionTypeBreakingChanges}) + } + return sections +} diff --git a/cmd/git-sv/log.go b/cmd/git-sv/log.go index 69b0feb..d5a57c5 100644 --- a/cmd/git-sv/log.go +++ b/cmd/git-sv/log.go @@ -1,7 +1,10 @@ package main -import "fmt" +import ( + "fmt" + "os" +) func warnf(format string, values ...interface{}) { - fmt.Printf("WARN: "+format+"\n", values...) + fmt.Fprintf(os.Stderr, "WARN: "+format+"\n", values...) } diff --git a/cmd/git-sv/main.go b/cmd/git-sv/main.go index fa2053f..b2a4a02 100644 --- a/cmd/git-sv/main.go +++ b/cmd/git-sv/main.go @@ -1,6 +1,8 @@ package main import ( + "embed" + "io/fs" "log" "os" "path/filepath" @@ -15,17 +17,36 @@ var Version = "source" const ( configFilename = "config.yml" repoConfigFilename = ".sv4git.yml" + configDir = ".sv4git" ) +var ( + //go:embed resources/templates/*.tpl + defaultTemplatesFS embed.FS +) + +func templateFS(filepath string) fs.FS { + if _, err := os.Stat(filepath); err != nil { + defaultTemplatesFS, _ := fs.Sub(defaultTemplatesFS, "resources/templates") + return defaultTemplatesFS + } + return os.DirFS(filepath) +} + func main() { log.SetFlags(0) - cfg := loadCfg() + repoPath, rerr := getRepoPath() + if rerr != nil { + log.Fatal("failed to discovery repository top level, error: ", rerr) + } + + cfg := loadCfg(repoPath) messageProcessor := sv.NewMessageProcessor(cfg.CommitMessage, cfg.Branches) git := sv.NewGit(messageProcessor, cfg.Tag) semverProcessor := sv.NewSemVerCommitsProcessor(cfg.Versioning, cfg.CommitMessage) releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotes) - outputFormatter := sv.NewOutputFormatter() + outputFormatter := sv.NewOutputFormatter(templateFS(filepath.Join(repoPath, configDir, "templates"))) app := cli.NewApp() app.Name = "sv" @@ -145,26 +166,22 @@ func main() { } } -func loadCfg() Config { - envCfg := loadEnvConfig() - +func loadCfg(repoPath string) Config { cfg := defaultConfig() + envCfg := loadEnvConfig() if envCfg.Home != "" { - if homeCfg, err := readConfig(filepath.Join(envCfg.Home, configFilename)); err == nil { - if merr := merge(&cfg, homeCfg); merr != nil { + homeCfgFilepath := filepath.Join(envCfg.Home, configFilename) + if homeCfg, err := readConfig(homeCfgFilepath); err == nil { + if merr := merge(&cfg, migrateConfig(homeCfg, homeCfgFilepath)); merr != nil { log.Fatal("failed to merge user config, error: ", merr) } } } - repoPath, rerr := getRepoPath() - if rerr != nil { - log.Fatal("failed to get repository path, error: ", rerr) - } - - if repoCfg, err := readConfig(filepath.Join(repoPath, repoConfigFilename)); err == nil { - if merr := merge(&cfg, repoCfg); merr != nil { + repoCfgFilepath := filepath.Join(repoPath, repoConfigFilename) + if repoCfg, err := readConfig(repoCfgFilepath); err == nil { + if merr := merge(&cfg, migrateConfig(repoCfg, repoCfgFilepath)); merr != nil { log.Fatal("failed to merge repo config, error: ", merr) } if len(repoCfg.ReleaseNotes.Headers) > 0 { // mergo is merging maps, headers will be overwritten diff --git a/cmd/git-sv/resources/templates/changelog-md.tpl b/cmd/git-sv/resources/templates/changelog-md.tpl new file mode 100644 index 0000000..d478272 --- /dev/null +++ b/cmd/git-sv/resources/templates/changelog-md.tpl @@ -0,0 +1,6 @@ +# Changelog +{{- range .}} + +{{template "releasenotes-md.tpl" .}} +--- +{{- end}} \ No newline at end of file diff --git a/cmd/git-sv/resources/templates/releasenotes-md.tpl b/cmd/git-sv/resources/templates/releasenotes-md.tpl new file mode 100644 index 0000000..a513e69 --- /dev/null +++ b/cmd/git-sv/resources/templates/releasenotes-md.tpl @@ -0,0 +1,8 @@ +## {{if .Release}}{{.Release}}{{end}}{{if and (not .Date.IsZero) .Release}} ({{end}}{{timefmt .Date "2006-01-02"}}{{if and (not .Date.IsZero) .Release}}){{end}} +{{- range $section := .Sections }} +{{- if (eq $section.SectionType "commits") }} +{{- template "rn-md-section-commits.tpl" $section }} +{{- else if (eq $section.SectionType "breaking-changes")}} +{{- template "rn-md-section-breaking-changes.tpl" $section }} +{{- end}} +{{- end}} diff --git a/cmd/git-sv/resources/templates/rn-md-section-breaking-changes.tpl b/cmd/git-sv/resources/templates/rn-md-section-breaking-changes.tpl new file mode 100644 index 0000000..862b1dc --- /dev/null +++ b/cmd/git-sv/resources/templates/rn-md-section-breaking-changes.tpl @@ -0,0 +1,7 @@ +{{- if ne .Name ""}} + +### {{.Name}} +{{range $k,$v := .Messages}} +- {{$v}} +{{- end}} +{{- end}} \ No newline at end of file diff --git a/cmd/git-sv/resources/templates/rn-md-section-commits.tpl b/cmd/git-sv/resources/templates/rn-md-section-commits.tpl new file mode 100644 index 0000000..013f90b --- /dev/null +++ b/cmd/git-sv/resources/templates/rn-md-section-commits.tpl @@ -0,0 +1,7 @@ +{{- if .}}{{- if ne .SectionName ""}} + +### {{.SectionName}} +{{range $k,$v := .Items}} +- {{if $v.Message.Scope}}**{{$v.Message.Scope}}:** {{end}}{{$v.Message.Description}} ({{$v.Hash}}){{if $v.Message.Metadata.issue}} ({{$v.Message.Metadata.issue}}){{end}} +{{- end}} +{{- end}}{{- end}} \ No newline at end of file diff --git a/cmd/git-sv/resources_test.go b/cmd/git-sv/resources_test.go new file mode 100644 index 0000000..4d22540 --- /dev/null +++ b/cmd/git-sv/resources_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "testing" +) + +func Test_checkTemplatesFiles(t *testing.T) { + tests := []string{ + "resources/templates/changelog-md.tpl", + "resources/templates/releasenotes-md.tpl", + } + for _, tt := range tests { + t.Run(tt, func(t *testing.T) { + got, err := defaultTemplatesFS.ReadFile(tt) + if err != nil { + t.Errorf("missing template error = %v", err) + return + } + if len(got) <= 0 { + t.Errorf("empty template") + } + }) + } +} diff --git a/sv/config.go b/sv/config.go index eb894e1..af19378 100644 --- a/sv/config.go +++ b/sv/config.go @@ -68,5 +68,29 @@ type TagConfig struct { // ReleaseNotesConfig release notes preferences. type ReleaseNotesConfig struct { - Headers map[string]string `yaml:"headers"` + Headers map[string]string `yaml:"headers,omitempty"` + Sections []ReleaseNotesSectionConfig `yaml:"sections"` } + +func (cfg ReleaseNotesConfig) sectionConfig(sectionType string) *ReleaseNotesSectionConfig { + for _, sectionCfg := range cfg.Sections { + if sectionCfg.SectionType == sectionType { + return §ionCfg + } + } + return nil +} + +// ReleaseNotesSectionConfig preferences for a single section on release notes. +type ReleaseNotesSectionConfig struct { + Name string `yaml:"name"` + SectionType string `yaml:"section-type"` + CommitTypes []string `yaml:"commit-types,flow,omitempty"` +} + +const ( + // ReleaseNotesSectionTypeCommits ReleaseNotesSectionConfig.SectionType value. + ReleaseNotesSectionTypeCommits = "commits" + // ReleaseNotesSectionTypeBreakingChanges ReleaseNotesSectionConfig.SectionType value. + ReleaseNotesSectionTypeBreakingChanges = "breaking-changes" +) diff --git a/sv/formatter.go b/sv/formatter.go index 9d93367..61b7ee8 100644 --- a/sv/formatter.go +++ b/sv/formatter.go @@ -2,53 +2,23 @@ package sv import ( "bytes" + "io/fs" + "sort" "text/template" + "time" + + "github.com/Masterminds/semver/v3" ) type releaseNoteTemplateVariables struct { - Release string - Date string - Sections map[string]ReleaseNoteSection - Order []string - BreakingChanges BreakingChangeSection + Release string + Tag string + Version *semver.Version + Date time.Time + Sections []ReleaseNoteSection + AuthorNames []string } -const ( - cglTemplate = `# Changelog -{{- range .}} - -{{template "rnTemplate" .}} ---- -{{- end}} -` - - rnSectionItem = "- {{if .Message.Scope}}**{{.Message.Scope}}:** {{end}}{{.Message.Description}} ({{.Hash}}){{if .Message.Metadata.issue}} ({{.Message.Metadata.issue}}){{end}}" - - rnSection = `{{- if .}}{{- if ne .Name ""}} - -### {{.Name}} -{{range $k,$v := .Items}} -{{template "rnSectionItem" $v}} -{{- end}} -{{- end}}{{- end}}` - - rnSectionBreakingChanges = `{{- if ne .Name ""}} - -### {{.Name}} -{{range $k,$v := .Messages}} -- {{$v}} -{{- end}} -{{- end}}` - - rnTemplate = `## {{if .Release}}{{.Release}}{{end}}{{if and .Date .Release}} ({{end}}{{.Date}}{{if and .Date .Release}}){{end}} -{{- $sections := .Sections }} -{{- range $key := .Order }} -{{- template "rnSection" (index $sections $key) }} -{{- end}} -{{- template "rnSectionBreakingChanges" .BreakingChanges}} -` -) - // OutputFormatter output formatter interface. type OutputFormatter interface { FormatReleaseNote(releasenote ReleaseNote) (string, error) @@ -57,24 +27,23 @@ type OutputFormatter interface { // OutputFormatterImpl formater for release note and changelog. type OutputFormatterImpl struct { - releasenoteTemplate *template.Template - changelogTemplate *template.Template + templates *template.Template } // NewOutputFormatter TemplateProcessor constructor. -func NewOutputFormatter() *OutputFormatterImpl { - cgl := template.Must(template.New("cglTemplate").Parse(cglTemplate)) - rn := template.Must(cgl.New("rnTemplate").Parse(rnTemplate)) - template.Must(rn.New("rnSectionItem").Parse(rnSectionItem)) - template.Must(rn.New("rnSection").Parse(rnSection)) - template.Must(rn.New("rnSectionBreakingChanges").Parse(rnSectionBreakingChanges)) - return &OutputFormatterImpl{releasenoteTemplate: rn, changelogTemplate: cgl} +func NewOutputFormatter(templatesFS fs.FS) *OutputFormatterImpl { + templateFNs := map[string]interface{}{ + "timefmt": timeFormat, + "getsection": getSection, + } + tpls := template.Must(template.New("templates").Funcs(templateFNs).ParseFS(templatesFS, "*")) + return &OutputFormatterImpl{templates: tpls} } // FormatReleaseNote format a release note. func (p OutputFormatterImpl) FormatReleaseNote(releasenote ReleaseNote) (string, error) { var b bytes.Buffer - if err := p.releasenoteTemplate.Execute(&b, releaseNoteVariables(releasenote)); err != nil { + if err := p.templates.ExecuteTemplate(&b, "releasenotes-md.tpl", releaseNoteVariables(releasenote)); err != nil { return "", err } return b.String(), nil @@ -88,29 +57,34 @@ func (p OutputFormatterImpl) FormatChangelog(releasenotes []ReleaseNote) (string } var b bytes.Buffer - if err := p.changelogTemplate.Execute(&b, templateVars); err != nil { + if err := p.templates.ExecuteTemplate(&b, "changelog-md.tpl", templateVars); err != nil { return "", err } return b.String(), nil } func releaseNoteVariables(releasenote ReleaseNote) releaseNoteTemplateVariables { - date := "" - if !releasenote.Date.IsZero() { - date = releasenote.Date.Format("2006-01-02") - } - - release := "" + release := releasenote.Tag if releasenote.Version != nil { release = "v" + releasenote.Version.String() - } else if releasenote.Tag != "" { - release = releasenote.Tag } return releaseNoteTemplateVariables{ - Release: release, - Date: date, - Sections: releasenote.Sections, - Order: []string{"feat", "fix", "refactor", "perf", "test", "build", "ci", "chore", "docs", "style"}, - BreakingChanges: releasenote.BreakingChanges, + Release: release, + Tag: releasenote.Tag, + Version: releasenote.Version, + Date: releasenote.Date, + Sections: releasenote.Sections, + AuthorNames: toSortedArray(releasenote.AuthorsNames), } } + +func toSortedArray(input map[string]struct{}) []string { + result := make([]string, len(input)) + i := 0 + for k := range input { + result[i] = k + i++ + } + sort.Strings(result) + return result +} diff --git a/sv/formatter_functions.go b/sv/formatter_functions.go new file mode 100644 index 0000000..607b988 --- /dev/null +++ b/sv/formatter_functions.go @@ -0,0 +1,19 @@ +package sv + +import "time" + +func timeFormat(t time.Time, format string) string { + if t.IsZero() { + return "" + } + return t.Format(format) +} + +func getSection(sections []ReleaseNoteSection, name string) ReleaseNoteSection { + for _, section := range sections { + if section.SectionName() == name { + return section + } + } + return nil +} diff --git a/sv/formatter_functions_test.go b/sv/formatter_functions_test.go new file mode 100644 index 0000000..83a2d92 --- /dev/null +++ b/sv/formatter_functions_test.go @@ -0,0 +1,45 @@ +package sv + +import ( + "reflect" + "testing" + "time" +) + +func Test_timeFormat(t *testing.T) { + tests := []struct { + name string + time time.Time + format string + want string + }{ + {"valid time", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), "2006-01-02", "2022-01-01"}, + {"empty time", time.Time{}, "2006-01-02", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := timeFormat(tt.time, tt.format); got != tt.want { + t.Errorf("timeFormat() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_getSection(t *testing.T) { + tests := []struct { + name string + sections []ReleaseNoteSection + sectionName string + want ReleaseNoteSection + }{ + {"existing section", []ReleaseNoteSection{ReleaseNoteCommitsSection{Name: "section 0"}, ReleaseNoteCommitsSection{Name: "section 1"}, ReleaseNoteCommitsSection{Name: "section 2"}}, "section 1", ReleaseNoteCommitsSection{Name: "section 1"}}, + {"nonexisting section", []ReleaseNoteSection{ReleaseNoteCommitsSection{Name: "section 0"}, ReleaseNoteCommitsSection{Name: "section 1"}, ReleaseNoteCommitsSection{Name: "section 2"}}, "section 10", nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := getSection(tt.sections, tt.sectionName); !reflect.DeepEqual(got, tt.want) { + t.Errorf("getSection() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/sv/formatter_test.go b/sv/formatter_test.go index 73a5104..ea29235 100644 --- a/sv/formatter_test.go +++ b/sv/formatter_test.go @@ -1,12 +1,16 @@ package sv import ( + "bytes" + "os" "testing" "time" "github.com/Masterminds/semver/v3" ) +var templatesFS = os.DirFS("../cmd/git-sv/resources/templates") + var dateChangelog = `## v1.0.0 (2020-05-01) ` @@ -55,7 +59,7 @@ func TestOutputFormatterImpl_FormatReleaseNote(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NewOutputFormatter().FormatReleaseNote(tt.input) + got, err := NewOutputFormatter(templatesFS).FormatReleaseNote(tt.input) if got != tt.want { t.Errorf("OutputFormatterImpl.FormatReleaseNote() = %v, want %v", got, tt.want) } @@ -78,10 +82,57 @@ func emptyReleaseNote(tag string, date time.Time) ReleaseNote { func fullReleaseNote(tag string, date time.Time) ReleaseNote { v, _ := semver.NewVersion(tag) - sections := map[string]ReleaseNoteSection{ - "build": newReleaseNoteSection("Build", []GitCommitLog{commitlog("build", map[string]string{})}), - "feat": newReleaseNoteSection("Features", []GitCommitLog{commitlog("feat", map[string]string{})}), - "fix": newReleaseNoteSection("Bug Fixes", []GitCommitLog{commitlog("fix", map[string]string{})}), + sections := []ReleaseNoteSection{ + newReleaseNoteCommitsSection("Features", []string{"feat"}, []GitCommitLog{commitlog("feat", map[string]string{}, "a")}), + newReleaseNoteCommitsSection("Bug Fixes", []string{"fix"}, []GitCommitLog{commitlog("fix", map[string]string{}, "a")}), + newReleaseNoteCommitsSection("Build", []string{"build"}, []GitCommitLog{commitlog("build", map[string]string{}, "a")}), + ReleaseNoteBreakingChangeSection{"Breaking Changes", []string{"break change message"}}, } - return releaseNote(v, tag, date, sections, []string{"break change message"}) + return releaseNote(v, tag, date, sections, map[string]struct{}{"a": {}}) +} + +func Test_checkTemplatesExecution(t *testing.T) { + tpls := NewOutputFormatter(templatesFS).templates + tests := []struct { + template string + variables interface{} + }{ + {"changelog-md.tpl", changelogVariables("v1.0.0", "v1.0.1")}, + {"releasenotes-md.tpl", releaseNotesVariables("v1.0.0")}, + } + for _, tt := range tests { + t.Run(tt.template, func(t *testing.T) { + var b bytes.Buffer + err := tpls.ExecuteTemplate(&b, tt.template, tt.variables) + if err != nil { + t.Errorf("invalid template err = %v", err) + return + } + if len(b.Bytes()) <= 0 { + t.Errorf("empty template") + } + }) + } +} + +func releaseNotesVariables(release string) releaseNoteTemplateVariables { + return releaseNoteTemplateVariables{ + Release: release, + Date: time.Date(2006, 1, 02, 0, 0, 0, 0, time.UTC), + Sections: []ReleaseNoteSection{ + newReleaseNoteCommitsSection("Features", []string{"feat"}, []GitCommitLog{commitlog("feat", map[string]string{}, "a")}), + newReleaseNoteCommitsSection("Bug Fixes", []string{"fix"}, []GitCommitLog{commitlog("fix", map[string]string{}, "a")}), + newReleaseNoteCommitsSection("Build", []string{"build"}, []GitCommitLog{commitlog("build", map[string]string{}, "a")}), + ReleaseNoteBreakingChangeSection{"Breaking Changes", []string{"break change message"}}, + }, + } +} + +func changelogVariables(releases ...string) []releaseNoteTemplateVariables { + var variables []releaseNoteTemplateVariables + for _, r := range releases { + variables = append(variables, releaseNotesVariables(r)) + } + return variables + } diff --git a/sv/git.go b/sv/git.go index bab6d14..7474824 100644 --- a/sv/git.go +++ b/sv/git.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "strconv" "strings" "time" @@ -31,9 +32,11 @@ type Git interface { // GitCommitLog description of a single commit log. type GitCommitLog struct { - Date string `json:"date,omitempty"` - Hash string `json:"hash,omitempty"` - Message CommitMessage `json:"message,omitempty"` + Date string `json:"date,omitempty"` + Timestamp int `json:"timestamp,omitempty"` + AuthorName string `json:"authorName,omitempty"` + Hash string `json:"hash,omitempty"` + Message CommitMessage `json:"message,omitempty"` } // GitTag git tag info. @@ -90,7 +93,7 @@ func (GitImpl) LastTag() string { // Log return git log. func (g GitImpl) Log(lr LogRange) ([]GitCommitLog, error) { - format := "--pretty=format:\"%ad" + logSeparator + "%h" + logSeparator + "%s" + logSeparator + "%b" + endLine + "\"" + format := "--pretty=format:\"%ad" + logSeparator + "%at" + logSeparator + "%cN" + logSeparator + "%h" + logSeparator + "%s" + logSeparator + "%b" + endLine + "\"" params := []string{"log", "--date=short", format} if lr.start != "" || lr.end != "" { @@ -200,10 +203,13 @@ func parseLogOutput(messageProcessor MessageProcessor, log string) []GitCommitLo func parseCommitLog(messageProcessor MessageProcessor, commit string) GitCommitLog { content := strings.Split(strings.Trim(commit, "\""), logSeparator) + timestamp, _ := strconv.Atoi(content[1]) return GitCommitLog{ - Date: content[0], - Hash: content[1], - Message: messageProcessor.Parse(content[2], content[3]), + Date: content[0], + Timestamp: timestamp, + AuthorName: content[2], + Hash: content[3], + Message: messageProcessor.Parse(content[4], content[5]), } } diff --git a/sv/helpers_test.go b/sv/helpers_test.go index 90975dd..cc7b66e 100644 --- a/sv/helpers_test.go +++ b/sv/helpers_test.go @@ -11,7 +11,7 @@ func version(v string) *semver.Version { return r } -func commitlog(ctype string, metadata map[string]string) GitCommitLog { +func commitlog(ctype string, metadata map[string]string, author string) GitCommitLog { breaking := false if _, found := metadata[breakingChangeMetadataKey]; found { breaking = true @@ -23,26 +23,24 @@ func commitlog(ctype string, metadata map[string]string) GitCommitLog { IsBreakingChange: breaking, Metadata: metadata, }, + AuthorName: author, } } -func releaseNote(version *semver.Version, tag string, date time.Time, sections map[string]ReleaseNoteSection, breakingChanges []string) ReleaseNote { - var bchanges BreakingChangeSection - if len(breakingChanges) > 0 { - bchanges = BreakingChangeSection{Name: "Breaking Changes", Messages: breakingChanges} - } +func releaseNote(version *semver.Version, tag string, date time.Time, sections []ReleaseNoteSection, authorsNames map[string]struct{}) ReleaseNote { return ReleaseNote{ - Version: version, - Tag: tag, - Date: date.Truncate(time.Minute), - Sections: sections, - BreakingChanges: bchanges, + Version: version, + Tag: tag, + Date: date.Truncate(time.Minute), + Sections: sections, + AuthorsNames: authorsNames, } } -func newReleaseNoteSection(name string, items []GitCommitLog) ReleaseNoteSection { - return ReleaseNoteSection{ +func newReleaseNoteCommitsSection(name string, types []string, items []GitCommitLog) ReleaseNoteCommitsSection { + return ReleaseNoteCommitsSection{ Name: name, + Types: types, Items: items, } } diff --git a/sv/releasenotes.go b/sv/releasenotes.go index 3c44ba0..f4a7d85 100644 --- a/sv/releasenotes.go +++ b/sv/releasenotes.go @@ -23,16 +23,20 @@ func NewReleaseNoteProcessor(cfg ReleaseNotesConfig) *ReleaseNoteProcessorImpl { // Create create a release note based on commits. func (p ReleaseNoteProcessorImpl) Create(version *semver.Version, tag string, date time.Time, commits []GitCommitLog) ReleaseNote { - sections := make(map[string]ReleaseNoteSection) + mapping := commitSectionMapping(p.cfg.Sections) + + sections := make(map[string]ReleaseNoteCommitsSection) + authors := make(map[string]struct{}) var breakingChanges []string for _, commit := range commits { - if name, exists := p.cfg.Headers[commit.Message.Type]; exists { - section, sexists := sections[commit.Message.Type] + authors[commit.AuthorName] = struct{}{} + if sectionCfg, exists := mapping[commit.Message.Type]; exists { + section, sexists := sections[sectionCfg.Name] if !sexists { - section = ReleaseNoteSection{Name: name} + section = ReleaseNoteCommitsSection{Name: sectionCfg.Name, Types: sectionCfg.CommitTypes} } section.Items = append(section.Items, commit) - sections[commit.Message.Type] = section + sections[sectionCfg.Name] = section } if commit.Message.BreakingMessage() != "" { // TODO: if no message found, should use description instead? @@ -40,30 +44,96 @@ func (p ReleaseNoteProcessorImpl) Create(version *semver.Version, tag string, da } } - var breakingChangeSection BreakingChangeSection - if name, exists := p.cfg.Headers[breakingChangeMetadataKey]; exists && len(breakingChanges) > 0 { - breakingChangeSection = BreakingChangeSection{Name: name, Messages: breakingChanges} + var breakingChangeSection ReleaseNoteBreakingChangeSection + if bcCfg := p.cfg.sectionConfig(ReleaseNotesSectionTypeBreakingChanges); bcCfg != nil && len(breakingChanges) > 0 { + breakingChangeSection = ReleaseNoteBreakingChangeSection{Name: bcCfg.Name, Messages: breakingChanges} } - return ReleaseNote{Version: version, Tag: tag, Date: date.Truncate(time.Minute), Sections: sections, BreakingChanges: breakingChangeSection} + return ReleaseNote{Version: version, Tag: tag, Date: date.Truncate(time.Minute), Sections: p.toReleaseNoteSections(sections, breakingChangeSection), AuthorsNames: authors} +} + +func (p ReleaseNoteProcessorImpl) toReleaseNoteSections(commitSections map[string]ReleaseNoteCommitsSection, breakingChange ReleaseNoteBreakingChangeSection) []ReleaseNoteSection { + hasBreaking := 0 + if breakingChange.Name != "" { + hasBreaking = 1 + } + + sections := make([]ReleaseNoteSection, len(commitSections)+hasBreaking) + i := 0 + for _, cfg := range p.cfg.Sections { + if cfg.SectionType == ReleaseNotesSectionTypeBreakingChanges && hasBreaking > 0 { + sections[i] = breakingChange + i++ + } + if s, exists := commitSections[cfg.Name]; cfg.SectionType == ReleaseNotesSectionTypeCommits && exists { + sections[i] = s + i++ + } + } + + return sections +} + +func commitSectionMapping(sections []ReleaseNotesSectionConfig) map[string]ReleaseNotesSectionConfig { + mapping := make(map[string]ReleaseNotesSectionConfig) + for _, section := range sections { + if section.SectionType == ReleaseNotesSectionTypeCommits { + for _, commitType := range section.CommitTypes { + mapping[commitType] = section + } + } + } + return mapping } // ReleaseNote release note. type ReleaseNote struct { - Version *semver.Version - Tag string - Date time.Time - Sections map[string]ReleaseNoteSection - BreakingChanges BreakingChangeSection + Version *semver.Version + Tag string + Date time.Time + Sections []ReleaseNoteSection + AuthorsNames map[string]struct{} } -// BreakingChangeSection breaking change section. -type BreakingChangeSection struct { +// ReleaseNoteSection section in release notes. +type ReleaseNoteSection interface { + SectionType() string + SectionName() string +} + +// ReleaseNoteBreakingChangeSection breaking change section. +type ReleaseNoteBreakingChangeSection struct { Name string Messages []string } -// ReleaseNoteSection release note section. -type ReleaseNoteSection struct { +// SectionType section type. +func (ReleaseNoteBreakingChangeSection) SectionType() string { + return ReleaseNotesSectionTypeBreakingChanges +} + +// SectionName section name. +func (s ReleaseNoteBreakingChangeSection) SectionName() string { + return s.Name +} + +// ReleaseNoteCommitsSection release note section. +type ReleaseNoteCommitsSection struct { Name string + Types []string Items []GitCommitLog } + +// SectionType section type. +func (ReleaseNoteCommitsSection) SectionType() string { + return ReleaseNotesSectionTypeCommits +} + +// SectionName section name. +func (s ReleaseNoteCommitsSection) SectionName() string { + return s.Name +} + +// HasMultipleTypes return true if has more than one commit type. +func (s ReleaseNoteCommitsSection) HasMultipleTypes() bool { + return len(s.Types) > 1 +} diff --git a/sv/releasenotes_test.go b/sv/releasenotes_test.go index 7af1578..af3029f 100644 --- a/sv/releasenotes_test.go +++ b/sv/releasenotes_test.go @@ -24,29 +24,37 @@ func TestReleaseNoteProcessorImpl_Create(t *testing.T) { version: semver.MustParse("1.0.0"), tag: "v1.0.0", date: date, - commits: []GitCommitLog{commitlog("t1", map[string]string{})}, - want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, map[string]ReleaseNoteSection{"t1": newReleaseNoteSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, nil), + commits: []GitCommitLog{commitlog("t1", map[string]string{}, "a")}, + want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "a")})}, map[string]struct{}{"a": {}}), }, { name: "unmapped tag", version: semver.MustParse("1.0.0"), tag: "v1.0.0", date: date, - commits: []GitCommitLog{commitlog("t1", map[string]string{}), commitlog("unmapped", map[string]string{})}, - want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, map[string]ReleaseNoteSection{"t1": newReleaseNoteSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, nil), + commits: []GitCommitLog{commitlog("t1", map[string]string{}, "a"), commitlog("unmapped", map[string]string{}, "a")}, + want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "a")})}, map[string]struct{}{"a": {}}), }, { name: "breaking changes tag", version: semver.MustParse("1.0.0"), tag: "v1.0.0", date: date, - commits: []GitCommitLog{commitlog("t1", map[string]string{}), commitlog("unmapped", map[string]string{"breaking-change": "breaks"})}, - want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, map[string]ReleaseNoteSection{"t1": newReleaseNoteSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, []string{"breaks"}), + commits: []GitCommitLog{commitlog("t1", map[string]string{}, "a"), commitlog("unmapped", map[string]string{"breaking-change": "breaks"}, "a")}, + want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "a")}), ReleaseNoteBreakingChangeSection{Name: "Breaking Changes", Messages: []string{"breaks"}}}, map[string]struct{}{"a": {}}), + }, + { + name: "multiple authors", + version: semver.MustParse("1.0.0"), + tag: "v1.0.0", + date: date, + commits: []GitCommitLog{commitlog("t1", map[string]string{}, "author3"), commitlog("t1", map[string]string{}, "author2"), commitlog("t1", map[string]string{}, "author1")}, + want: releaseNote(semver.MustParse("1.0.0"), "v1.0.0", date, []ReleaseNoteSection{newReleaseNoteCommitsSection("Tag 1", []string{"t1"}, []GitCommitLog{commitlog("t1", map[string]string{}, "author3"), commitlog("t1", map[string]string{}, "author2"), commitlog("t1", map[string]string{}, "author1")})}, map[string]struct{}{"author1": {}, "author2": {}, "author3": {}}), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := NewReleaseNoteProcessor(ReleaseNotesConfig{Headers: map[string]string{"t1": "Tag 1", "t2": "Tag 2", "breaking-change": "Breaking Changes"}}) + p := NewReleaseNoteProcessor(ReleaseNotesConfig{Sections: []ReleaseNotesSectionConfig{{Name: "Tag 1", SectionType: "commits", CommitTypes: []string{"t1"}}, {Name: "Tag 2", SectionType: "commits", CommitTypes: []string{"t2"}}, {Name: "Breaking Changes", SectionType: "breaking-changes"}}}) if got := p.Create(tt.version, tt.tag, tt.date, tt.commits); !reflect.DeepEqual(got, tt.want) { t.Errorf("ReleaseNoteProcessorImpl.Create() = %v, want %v", got, tt.want) } diff --git a/sv/semver_test.go b/sv/semver_test.go index 0f2a5f4..1940a39 100644 --- a/sv/semver_test.go +++ b/sv/semver_test.go @@ -18,14 +18,14 @@ func TestSemVerCommitsProcessorImpl_NextVersion(t *testing.T) { }{ {"no update", true, version("0.0.0"), []GitCommitLog{}, version("0.0.0"), false}, {"no update without version", true, nil, []GitCommitLog{}, nil, false}, - {"no update on unknown type", true, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{})}, version("0.0.0"), false}, - {"no update on unmapped known type", false, version("0.0.0"), []GitCommitLog{commitlog("none", map[string]string{})}, version("0.0.0"), false}, - {"update patch on unknown type", false, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{})}, version("0.0.1"), true}, - {"patch update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{})}, version("0.0.1"), true}, - {"patch update without version", false, nil, []GitCommitLog{commitlog("patch", map[string]string{})}, nil, true}, - {"minor update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("minor", map[string]string{})}, version("0.1.0"), true}, - {"major update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("major", map[string]string{})}, version("1.0.0"), true}, - {"breaking change update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("patch", map[string]string{"breaking-change": "break"})}, version("1.0.0"), true}, + {"no update on unknown type", true, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{}, "a")}, version("0.0.0"), false}, + {"no update on unmapped known type", false, version("0.0.0"), []GitCommitLog{commitlog("none", map[string]string{}, "a")}, version("0.0.0"), false}, + {"update patch on unknown type", false, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{}, "a")}, version("0.0.1"), true}, + {"patch update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a")}, version("0.0.1"), true}, + {"patch update without version", false, nil, []GitCommitLog{commitlog("patch", map[string]string{}, "a")}, nil, true}, + {"minor update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a"), commitlog("minor", map[string]string{}, "a")}, version("0.1.0"), true}, + {"major update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a"), commitlog("major", map[string]string{}, "a")}, version("1.0.0"), true}, + {"breaking change update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}, "a"), commitlog("patch", map[string]string{"breaking-change": "break"}, "a")}, version("1.0.0"), true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {