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!
@@ -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) {