0
0
mirror of https://github.com/thegeeklab/git-sv.git synced 2024-11-21 22:10:39 +00:00

Merge pull request #43 from bvieira/#40

Feature: support templates for release-notes and changelog
This commit is contained in:
Beatriz Vieira 2022-03-01 00:29:50 -03:00 committed by GitHub
commit cf43b2af4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 566 additions and 155 deletions

View File

@ -1,4 +1,4 @@
version: "1.0" version: "1.1"
versioning: versioning:
update-major: [] update-major: []

126
README.md
View File

@ -1,6 +1,6 @@
<p align="center"> <p align="center">
<h1 align="center">sv4git</h1> <h1 align="center">sv4git</h1>
<p align="center">semantic version for git</p> <p align="center">A command line tool (CLI) to validate commit messages, bump version, create tags and generate changelogs!</p>
<p align="center"> <p align="center">
<a href="https://github.com/bvieira/sv4git/releases/latest"><img alt="Release" src="https://img.shields.io/github/release/bvieira/sv4git.svg?style=for-the-badge"></a> <a href="https://github.com/bvieira/sv4git/releases/latest"><img alt="Release" src="https://img.shields.io/github/release/bvieira/sv4git.svg?style=for-the-badge"></a>
<a href="https://pkg.go.dev/github.com/bvieira/sv4git/v2"><img alt="Go Reference" src="https://img.shields.io/badge/-Reference-blue?style=for-the-badge&logo=go&labelColor=gray"></a> <a href="https://pkg.go.dev/github.com/bvieira/sv4git/v2"><img alt="Go Reference" src="https://img.shields.io/badge/-Reference-blue?style=for-the-badge&logo=go&labelColor=gray"></a>
@ -26,11 +26,17 @@
If you want to install from source using `go install`, just run: If you want to install from source using `go install`, just run:
```bash ```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 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 ### 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**. 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: To see the current config, run:
@ -39,9 +45,9 @@ To see the current config, run:
git sv cfg show git sv cfg show
``` ```
#### Configuration Types ##### Configuration Types
##### Default ###### Default
To check the default configuration, run: To check the default configuration, run:
@ -49,7 +55,7 @@ To check the default configuration, run:
git sv cfg default git sv cfg default
``` ```
##### User ###### User
For user config, it is necessary to define the `SV4GIT_HOME` environment variable, eg.: 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 └── config.yml
``` ```
##### Repository ###### Repository
Create a `.sv4git.yml` file on the root of your repository, eg.: [.sv4git.yml](.sv4git.yml). Create a `.sv4git.yml` file on the root of your repository, eg.: [.sv4git.yml](.sv4git.yml).
#### Configuration format ##### Configuration format
```yml ```yml
version: "1.0" #config version version: "1.1" #config version
versioning: # versioning bump versioning: # versioning bump
update-major: [] # Commit types used to bump major. update-major: [] # Commit types used to bump major.
@ -85,14 +91,25 @@ tag:
pattern: '%d.%d.%d' # Pattern used to create git tag. pattern: '%d.%d.%d' # Pattern used to create git tag.
release-notes: release-notes:
# Headers names for release notes markdown. To disable a section just remove the header line. # Deprecated!!! please use 'sections' instead!
# It's possible to add other commit types, the release note will be created respecting the following order: # Headers names for release notes markdown. To disable a section just remove the header
# feat, fix, refactor, perf, test, build, ci, chore, docs, style, breaking-change # 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: headers:
breaking-change: Breaking Changes breaking-change: Breaking Changes
feat: Features feat: Features
fix: Bug Fixes 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. branches: # Git branches config.
prefix: ([a-z]+\/)? # Prefix used on branch name, it should be a regex group. prefix: ([a-z]+\/)? # Prefix used on branch name, it should be a regex group.
suffix: (-.*)? # Suffix used on branch name, it should be a regex group. suffix: (-.*)? # Suffix 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. 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 <https://pkg.go.dev/time#Time.Format> 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 ### Running
Run `git-sv` to get the list of available parameters: Run `git-sv` to get the list of available parameters:

View File

@ -70,15 +70,21 @@ func readConfig(filepath string) (Config, error) {
func defaultConfig() Config { func defaultConfig() Config {
skipDetached := false skipDetached := false
return Config{ return Config{
Version: "1.0", Version: "1.1",
Versioning: sv.VersioningConfig{ Versioning: sv.VersioningConfig{
UpdateMajor: []string{}, UpdateMajor: []string{},
UpdateMinor: []string{"feat"}, UpdateMinor: []string{"feat"},
UpdatePatch: []string{"build", "ci", "chore", "docs", "fix", "perf", "refactor", "style", "test"}, UpdatePatch: []string{"build", "ci", "chore", "docs", "fix", "perf", "refactor", "style", "test"},
IgnoreUnknown: false, IgnoreUnknown: false,
}, },
Tag: sv.TagConfig{Pattern: "%d.%d.%d"}, Tag: sv.TagConfig{Pattern: "%d.%d.%d"},
ReleaseNotes: sv.ReleaseNotesConfig{Headers: map[string]string{"fix": "Bug Fixes", "feat": "Features", "breaking-change": "Breaking Changes"}}, 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{ Branches: sv.BranchesConfig{
Prefix: "([a-z]+\\/)?", Prefix: "([a-z]+\\/)?",
Suffix: "(-.*)?", Suffix: "(-.*)?",
@ -129,3 +135,35 @@ func (t *mergeTransformer) Transformer(typ reflect.Type) func(dst, src reflect.V
} }
return nil 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
}

View File

@ -1,7 +1,10 @@
package main package main
import "fmt" import (
"fmt"
"os"
)
func warnf(format string, values ...interface{}) { func warnf(format string, values ...interface{}) {
fmt.Printf("WARN: "+format+"\n", values...) fmt.Fprintf(os.Stderr, "WARN: "+format+"\n", values...)
} }

View File

@ -1,6 +1,8 @@
package main package main
import ( import (
"embed"
"io/fs"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
@ -15,17 +17,36 @@ var Version = "source"
const ( const (
configFilename = "config.yml" configFilename = "config.yml"
repoConfigFilename = ".sv4git.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() { func main() {
log.SetFlags(0) 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) messageProcessor := sv.NewMessageProcessor(cfg.CommitMessage, cfg.Branches)
git := sv.NewGit(messageProcessor, cfg.Tag) git := sv.NewGit(messageProcessor, cfg.Tag)
semverProcessor := sv.NewSemVerCommitsProcessor(cfg.Versioning, cfg.CommitMessage) semverProcessor := sv.NewSemVerCommitsProcessor(cfg.Versioning, cfg.CommitMessage)
releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotes) releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotes)
outputFormatter := sv.NewOutputFormatter() outputFormatter := sv.NewOutputFormatter(templateFS(filepath.Join(repoPath, configDir, "templates")))
app := cli.NewApp() app := cli.NewApp()
app.Name = "sv" app.Name = "sv"
@ -145,26 +166,22 @@ func main() {
} }
} }
func loadCfg() Config { func loadCfg(repoPath string) Config {
envCfg := loadEnvConfig()
cfg := defaultConfig() cfg := defaultConfig()
envCfg := loadEnvConfig()
if envCfg.Home != "" { if envCfg.Home != "" {
if homeCfg, err := readConfig(filepath.Join(envCfg.Home, configFilename)); err == nil { homeCfgFilepath := filepath.Join(envCfg.Home, configFilename)
if merr := merge(&cfg, homeCfg); merr != nil { 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) log.Fatal("failed to merge user config, error: ", merr)
} }
} }
} }
repoPath, rerr := getRepoPath() repoCfgFilepath := filepath.Join(repoPath, repoConfigFilename)
if rerr != nil { if repoCfg, err := readConfig(repoCfgFilepath); err == nil {
log.Fatal("failed to get repository path, error: ", rerr) if merr := merge(&cfg, migrateConfig(repoCfg, repoCfgFilepath)); merr != nil {
}
if repoCfg, err := readConfig(filepath.Join(repoPath, repoConfigFilename)); err == nil {
if merr := merge(&cfg, repoCfg); merr != nil {
log.Fatal("failed to merge repo config, error: ", merr) log.Fatal("failed to merge repo config, error: ", merr)
} }
if len(repoCfg.ReleaseNotes.Headers) > 0 { // mergo is merging maps, headers will be overwritten if len(repoCfg.ReleaseNotes.Headers) > 0 { // mergo is merging maps, headers will be overwritten

View File

@ -0,0 +1,6 @@
# Changelog
{{- range .}}
{{template "releasenotes-md.tpl" .}}
---
{{- end}}

View File

@ -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}}

View File

@ -0,0 +1,7 @@
{{- if ne .Name ""}}
### {{.Name}}
{{range $k,$v := .Messages}}
- {{$v}}
{{- end}}
{{- end}}

View File

@ -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}}

View File

@ -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")
}
})
}
}

View File

@ -68,5 +68,29 @@ type TagConfig struct {
// ReleaseNotesConfig release notes preferences. // ReleaseNotesConfig release notes preferences.
type ReleaseNotesConfig struct { 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 &sectionCfg
}
}
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"
)

View File

@ -2,53 +2,23 @@ package sv
import ( import (
"bytes" "bytes"
"io/fs"
"sort"
"text/template" "text/template"
"time"
"github.com/Masterminds/semver/v3"
) )
type releaseNoteTemplateVariables struct { type releaseNoteTemplateVariables struct {
Release string Release string
Date string Tag string
Sections map[string]ReleaseNoteSection Version *semver.Version
Order []string Date time.Time
BreakingChanges BreakingChangeSection 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. // OutputFormatter output formatter interface.
type OutputFormatter interface { type OutputFormatter interface {
FormatReleaseNote(releasenote ReleaseNote) (string, error) FormatReleaseNote(releasenote ReleaseNote) (string, error)
@ -57,24 +27,23 @@ type OutputFormatter interface {
// OutputFormatterImpl formater for release note and changelog. // OutputFormatterImpl formater for release note and changelog.
type OutputFormatterImpl struct { type OutputFormatterImpl struct {
releasenoteTemplate *template.Template templates *template.Template
changelogTemplate *template.Template
} }
// NewOutputFormatter TemplateProcessor constructor. // NewOutputFormatter TemplateProcessor constructor.
func NewOutputFormatter() *OutputFormatterImpl { func NewOutputFormatter(templatesFS fs.FS) *OutputFormatterImpl {
cgl := template.Must(template.New("cglTemplate").Parse(cglTemplate)) templateFNs := map[string]interface{}{
rn := template.Must(cgl.New("rnTemplate").Parse(rnTemplate)) "timefmt": timeFormat,
template.Must(rn.New("rnSectionItem").Parse(rnSectionItem)) "getsection": getSection,
template.Must(rn.New("rnSection").Parse(rnSection)) }
template.Must(rn.New("rnSectionBreakingChanges").Parse(rnSectionBreakingChanges)) tpls := template.Must(template.New("templates").Funcs(templateFNs).ParseFS(templatesFS, "*"))
return &OutputFormatterImpl{releasenoteTemplate: rn, changelogTemplate: cgl} return &OutputFormatterImpl{templates: tpls}
} }
// FormatReleaseNote format a release note. // FormatReleaseNote format a release note.
func (p OutputFormatterImpl) FormatReleaseNote(releasenote ReleaseNote) (string, error) { func (p OutputFormatterImpl) FormatReleaseNote(releasenote ReleaseNote) (string, error) {
var b bytes.Buffer 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 "", err
} }
return b.String(), nil return b.String(), nil
@ -88,29 +57,34 @@ func (p OutputFormatterImpl) FormatChangelog(releasenotes []ReleaseNote) (string
} }
var b bytes.Buffer 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 "", err
} }
return b.String(), nil return b.String(), nil
} }
func releaseNoteVariables(releasenote ReleaseNote) releaseNoteTemplateVariables { func releaseNoteVariables(releasenote ReleaseNote) releaseNoteTemplateVariables {
date := "" release := releasenote.Tag
if !releasenote.Date.IsZero() {
date = releasenote.Date.Format("2006-01-02")
}
release := ""
if releasenote.Version != nil { if releasenote.Version != nil {
release = "v" + releasenote.Version.String() release = "v" + releasenote.Version.String()
} else if releasenote.Tag != "" {
release = releasenote.Tag
} }
return releaseNoteTemplateVariables{ return releaseNoteTemplateVariables{
Release: release, Release: release,
Date: date, Tag: releasenote.Tag,
Sections: releasenote.Sections, Version: releasenote.Version,
Order: []string{"feat", "fix", "refactor", "perf", "test", "build", "ci", "chore", "docs", "style"}, Date: releasenote.Date,
BreakingChanges: releasenote.BreakingChanges, 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
}

19
sv/formatter_functions.go Normal file
View File

@ -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
}

View File

@ -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)
}
})
}
}

View File

@ -1,12 +1,16 @@
package sv package sv
import ( import (
"bytes"
"os"
"testing" "testing"
"time" "time"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
) )
var templatesFS = os.DirFS("../cmd/git-sv/resources/templates")
var dateChangelog = `## v1.0.0 (2020-05-01) var dateChangelog = `## v1.0.0 (2020-05-01)
` `
@ -55,7 +59,7 @@ func TestOutputFormatterImpl_FormatReleaseNote(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 { if got != tt.want {
t.Errorf("OutputFormatterImpl.FormatReleaseNote() = %v, want %v", 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 { func fullReleaseNote(tag string, date time.Time) ReleaseNote {
v, _ := semver.NewVersion(tag) v, _ := semver.NewVersion(tag)
sections := map[string]ReleaseNoteSection{ sections := []ReleaseNoteSection{
"build": newReleaseNoteSection("Build", []GitCommitLog{commitlog("build", map[string]string{})}), newReleaseNoteCommitsSection("Features", []string{"feat"}, []GitCommitLog{commitlog("feat", map[string]string{}, "a")}),
"feat": newReleaseNoteSection("Features", []GitCommitLog{commitlog("feat", map[string]string{})}), newReleaseNoteCommitsSection("Bug Fixes", []string{"fix"}, []GitCommitLog{commitlog("fix", map[string]string{}, "a")}),
"fix": newReleaseNoteSection("Bug Fixes", []GitCommitLog{commitlog("fix", map[string]string{})}), 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
} }

View File

@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"strconv"
"strings" "strings"
"time" "time"
@ -31,9 +32,11 @@ type Git interface {
// GitCommitLog description of a single commit log. // GitCommitLog description of a single commit log.
type GitCommitLog struct { type GitCommitLog struct {
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Hash string `json:"hash,omitempty"` Timestamp int `json:"timestamp,omitempty"`
Message CommitMessage `json:"message,omitempty"` AuthorName string `json:"authorName,omitempty"`
Hash string `json:"hash,omitempty"`
Message CommitMessage `json:"message,omitempty"`
} }
// GitTag git tag info. // GitTag git tag info.
@ -90,7 +93,7 @@ func (GitImpl) LastTag() string {
// Log return git log. // Log return git log.
func (g GitImpl) Log(lr LogRange) ([]GitCommitLog, error) { 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} params := []string{"log", "--date=short", format}
if lr.start != "" || lr.end != "" { if lr.start != "" || lr.end != "" {
@ -200,10 +203,13 @@ func parseLogOutput(messageProcessor MessageProcessor, log string) []GitCommitLo
func parseCommitLog(messageProcessor MessageProcessor, commit string) GitCommitLog { func parseCommitLog(messageProcessor MessageProcessor, commit string) GitCommitLog {
content := strings.Split(strings.Trim(commit, "\""), logSeparator) content := strings.Split(strings.Trim(commit, "\""), logSeparator)
timestamp, _ := strconv.Atoi(content[1])
return GitCommitLog{ return GitCommitLog{
Date: content[0], Date: content[0],
Hash: content[1], Timestamp: timestamp,
Message: messageProcessor.Parse(content[2], content[3]), AuthorName: content[2],
Hash: content[3],
Message: messageProcessor.Parse(content[4], content[5]),
} }
} }

View File

@ -11,7 +11,7 @@ func version(v string) *semver.Version {
return r return r
} }
func commitlog(ctype string, metadata map[string]string) GitCommitLog { func commitlog(ctype string, metadata map[string]string, author string) GitCommitLog {
breaking := false breaking := false
if _, found := metadata[breakingChangeMetadataKey]; found { if _, found := metadata[breakingChangeMetadataKey]; found {
breaking = true breaking = true
@ -23,26 +23,24 @@ func commitlog(ctype string, metadata map[string]string) GitCommitLog {
IsBreakingChange: breaking, IsBreakingChange: breaking,
Metadata: metadata, Metadata: metadata,
}, },
AuthorName: author,
} }
} }
func releaseNote(version *semver.Version, tag string, date time.Time, sections map[string]ReleaseNoteSection, breakingChanges []string) ReleaseNote { func releaseNote(version *semver.Version, tag string, date time.Time, sections []ReleaseNoteSection, authorsNames map[string]struct{}) ReleaseNote {
var bchanges BreakingChangeSection
if len(breakingChanges) > 0 {
bchanges = BreakingChangeSection{Name: "Breaking Changes", Messages: breakingChanges}
}
return ReleaseNote{ return ReleaseNote{
Version: version, Version: version,
Tag: tag, Tag: tag,
Date: date.Truncate(time.Minute), Date: date.Truncate(time.Minute),
Sections: sections, Sections: sections,
BreakingChanges: bchanges, AuthorsNames: authorsNames,
} }
} }
func newReleaseNoteSection(name string, items []GitCommitLog) ReleaseNoteSection { func newReleaseNoteCommitsSection(name string, types []string, items []GitCommitLog) ReleaseNoteCommitsSection {
return ReleaseNoteSection{ return ReleaseNoteCommitsSection{
Name: name, Name: name,
Types: types,
Items: items, Items: items,
} }
} }

View File

@ -23,16 +23,20 @@ func NewReleaseNoteProcessor(cfg ReleaseNotesConfig) *ReleaseNoteProcessorImpl {
// Create create a release note based on commits. // Create create a release note based on commits.
func (p ReleaseNoteProcessorImpl) Create(version *semver.Version, tag string, date time.Time, commits []GitCommitLog) ReleaseNote { 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 var breakingChanges []string
for _, commit := range commits { for _, commit := range commits {
if name, exists := p.cfg.Headers[commit.Message.Type]; exists { authors[commit.AuthorName] = struct{}{}
section, sexists := sections[commit.Message.Type] if sectionCfg, exists := mapping[commit.Message.Type]; exists {
section, sexists := sections[sectionCfg.Name]
if !sexists { if !sexists {
section = ReleaseNoteSection{Name: name} section = ReleaseNoteCommitsSection{Name: sectionCfg.Name, Types: sectionCfg.CommitTypes}
} }
section.Items = append(section.Items, commit) section.Items = append(section.Items, commit)
sections[commit.Message.Type] = section sections[sectionCfg.Name] = section
} }
if commit.Message.BreakingMessage() != "" { if commit.Message.BreakingMessage() != "" {
// TODO: if no message found, should use description instead? // 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 var breakingChangeSection ReleaseNoteBreakingChangeSection
if name, exists := p.cfg.Headers[breakingChangeMetadataKey]; exists && len(breakingChanges) > 0 { if bcCfg := p.cfg.sectionConfig(ReleaseNotesSectionTypeBreakingChanges); bcCfg != nil && len(breakingChanges) > 0 {
breakingChangeSection = BreakingChangeSection{Name: name, Messages: breakingChanges} 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. // ReleaseNote release note.
type ReleaseNote struct { type ReleaseNote struct {
Version *semver.Version Version *semver.Version
Tag string Tag string
Date time.Time Date time.Time
Sections map[string]ReleaseNoteSection Sections []ReleaseNoteSection
BreakingChanges BreakingChangeSection AuthorsNames map[string]struct{}
} }
// BreakingChangeSection breaking change section. // ReleaseNoteSection section in release notes.
type BreakingChangeSection struct { type ReleaseNoteSection interface {
SectionType() string
SectionName() string
}
// ReleaseNoteBreakingChangeSection breaking change section.
type ReleaseNoteBreakingChangeSection struct {
Name string Name string
Messages []string Messages []string
} }
// ReleaseNoteSection release note section. // SectionType section type.
type ReleaseNoteSection struct { 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 Name string
Types []string
Items []GitCommitLog 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
}

View File

@ -24,29 +24,37 @@ func TestReleaseNoteProcessorImpl_Create(t *testing.T) {
version: semver.MustParse("1.0.0"), version: semver.MustParse("1.0.0"),
tag: "v1.0.0", tag: "v1.0.0",
date: date, date: date,
commits: []GitCommitLog{commitlog("t1", map[string]string{})}, commits: []GitCommitLog{commitlog("t1", map[string]string{}, "a")},
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), 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", name: "unmapped tag",
version: semver.MustParse("1.0.0"), version: semver.MustParse("1.0.0"),
tag: "v1.0.0", tag: "v1.0.0",
date: date, date: date,
commits: []GitCommitLog{commitlog("t1", map[string]string{}), commitlog("unmapped", map[string]string{})}, 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, map[string]ReleaseNoteSection{"t1": newReleaseNoteSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, nil), 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", name: "breaking changes tag",
version: semver.MustParse("1.0.0"), version: semver.MustParse("1.0.0"),
tag: "v1.0.0", tag: "v1.0.0",
date: date, date: date,
commits: []GitCommitLog{commitlog("t1", map[string]string{}), commitlog("unmapped", map[string]string{"breaking-change": "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, map[string]ReleaseNoteSection{"t1": newReleaseNoteSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, []string{"breaks"}), 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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) { 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) t.Errorf("ReleaseNoteProcessorImpl.Create() = %v, want %v", got, tt.want)
} }

View File

@ -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", true, version("0.0.0"), []GitCommitLog{}, version("0.0.0"), false},
{"no update without version", true, nil, []GitCommitLog{}, nil, 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 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{})}, 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{})}, version("0.0.1"), true}, {"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{})}, 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{})}, nil, 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{}), commitlog("minor", map[string]string{})}, version("0.1.0"), 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{}), commitlog("major", map[string]string{})}, version("1.0.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{}), commitlog("patch", map[string]string{"breaking-change": "break"})}, 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {