diff --git a/README.md b/README.md index 8cf33a0..e820a02 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,15 @@ git-sv rn -h ##### Available commands -| Variable | description | -| --------- | ---------- | -| current-version, cv | get last released version from git | -| next-version, nv | generate the next version based on git commit messages | -| commit-log, cl | list all commit logs since last version as jsons | -| release-notes, rn | generate release notes | -| tag, tg | generate tag with version based on git commit messages | -| help, h | Shows a list of commands or help for one command | +| Variable | description | has options | +| --------- | ---------- | :----------: | +| current-version, cv | get last released version from git | :x: | +| next-version, nv | generate the next version based on git commit messages | :x: | +| commit-log, cl | list all commit logs since last version as jsons | :heavy_check_mark: | +| release-notes, rn | generate release notes | :heavy_check_mark: | +| changelog, cgl | generate changelog | :heavy_check_mark: | +| tag, tg | generate tag with version based on git commit messages | :x: | +| help, h | Shows a list of commands or help for one command | :x: | ## Development diff --git a/cmd/git-sv/handlers.go b/cmd/git-sv/handlers.go index 7615184..6e2473c 100644 --- a/cmd/git-sv/handlers.go +++ b/cmd/git-sv/handlers.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "fmt" + "sort" "sv4git/sv" "time" @@ -76,7 +77,7 @@ func getTagCommits(git sv.Git, tag string) ([]sv.GitCommitLog, error) { return git.Log(prev, tag) } -func releaseNotesHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor) func(c *cli.Context) error { +func releaseNotesHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor, outputFormatter sv.OutputFormatter) func(c *cli.Context) error { return func(c *cli.Context) error { var commits []sv.GitCommitLog var rnVersion semver.Version @@ -93,8 +94,8 @@ func releaseNotesHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, return err } - releasenote := rnProcessor.Get(date, commits) - fmt.Println(rnProcessor.Format(releasenote, rnVersion)) + releasenote := rnProcessor.Create(rnVersion, date, commits) + fmt.Println(outputFormatter.FormatReleaseNote(releasenote)) return nil } } @@ -161,7 +162,7 @@ func getNextVersionInfo(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) ( return semverProcessor.NextVersion(currentVer, commits), time.Now(), commits, nil } -func tagHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor) func(c *cli.Context) error { +func tagHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) func(c *cli.Context) error { return func(c *cli.Context) error { describe := git.Describe() @@ -184,3 +185,46 @@ func tagHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcess return nil } } + +func changelogHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor, formatter sv.OutputFormatter) func(c *cli.Context) error { + return func(c *cli.Context) error { + + tags, err := git.Tags() + if err != nil { + return err + } + sort.Slice(tags, func(i, j int) bool { + return tags[i].Date.After(tags[j].Date) + }) + + var releaseNotes []sv.ReleaseNote + + size := c.Int("size") + all := c.Bool("all") + for i, tag := range tags { + if !all && i >= size { + break + } + + previousTag := "" + if i+1 < len(tags) { + previousTag = tags[i+1].Name + } + + commits, err := git.Log(previousTag, tag.Name) + if err != nil { + return fmt.Errorf("error getting git log from tag: %s, message: %v", tag.Name, err) + } + + currentVer, err := sv.ToVersion(tag.Name) + if err != nil { + return fmt.Errorf("error parsing version: %s from describe, message: %v", tag.Name, err) + } + releaseNotes = append(releaseNotes, rnProcessor.Create(currentVer, tag.Date, commits)) + } + + fmt.Println(formatter.FormatChangelog(releaseNotes)) + + return nil + } +} diff --git a/cmd/git-sv/main.go b/cmd/git-sv/main.go index 67c8e20..ebb1449 100644 --- a/cmd/git-sv/main.go +++ b/cmd/git-sv/main.go @@ -17,6 +17,7 @@ func main() { git := sv.NewGit(cfg.BreakingChangePrefixes, cfg.IssueIDPrefixes, cfg.TagPattern) semverProcessor := sv.NewSemVerCommitsProcessor(cfg.IncludeUnknownTypeAsPatch, cfg.MajorVersionTypes, cfg.MinorVersionTypes, cfg.PatchVersionTypes) releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotesTags) + outputFormatter := sv.NewOutputFormatter() app := cli.NewApp() app.Name = "sv" @@ -40,20 +41,30 @@ func main() { Aliases: []string{"cl"}, Usage: "list all commit logs since last version as jsons", Action: commitLogHandler(git, semverProcessor), - Flags: []cli.Flag{&cli.StringFlag{Name: "t", Usage: "get commit log from tag"}}, + Flags: []cli.Flag{&cli.StringFlag{Name: "t", Aliases: []string{"tag"}, Usage: "get commit log from tag"}}, }, { Name: "release-notes", Aliases: []string{"rn"}, Usage: "generate release notes", - Action: releaseNotesHandler(git, semverProcessor, releasenotesProcessor), - Flags: []cli.Flag{&cli.StringFlag{Name: "t", Usage: "get release note from tag"}}, + Action: releaseNotesHandler(git, semverProcessor, releasenotesProcessor, outputFormatter), + Flags: []cli.Flag{&cli.StringFlag{Name: "t", Aliases: []string{"tag"}, Usage: "get release note from tag"}}, + }, + { + Name: "changelog", + Aliases: []string{"cgl"}, + Usage: "generate changelog", + Action: changelogHandler(git, semverProcessor, releasenotesProcessor, outputFormatter), + Flags: []cli.Flag{ + &cli.IntFlag{Name: "size", Value: 10, Aliases: []string{"n"}, Usage: "get changelog from last 'n' tags"}, + &cli.BoolFlag{Name: "all", Usage: "ignore size parameter, get changelog for every tag"}, + }, }, { Name: "tag", Aliases: []string{"tg"}, Usage: "generate tag with version based on git commit messages", - Action: tagHandler(git, semverProcessor, releasenotesProcessor), + Action: tagHandler(git, semverProcessor), }, } diff --git a/sv/formatter.go b/sv/formatter.go new file mode 100644 index 0000000..74860db --- /dev/null +++ b/sv/formatter.go @@ -0,0 +1,98 @@ +package sv + +import ( + "bytes" + "fmt" + "text/template" +) + +type releaseNoteTemplateVariables struct { + Version string + Date string + Sections map[string]ReleaseNoteSection + BreakingChanges []string +} + +const ( + cglTemplate = `# Changelog +{{- range .}} + +{{template "rnTemplate" .}} +--- +{{- end}} +` + + rnSectionItem = "- {{if .Scope}}**{{.Scope}}:** {{end}}{{.Subject}} ({{.Hash}}){{if .Metadata.issueid}} ({{.Metadata.issueid}}){{end}}" + + rnSection = `{{- if .}} + +### {{.Name}} +{{range $k,$v := .Items}} +{{template "rnSectionItem" $v}} +{{- end}} +{{- end}}` + + rnSectionBreakingChanges = `{{- if .}} + +### Breaking Changes +{{range $k,$v := .}} +- {{$v}} +{{- end}} +{{- end}}` + + rnTemplate = `## v{{.Version}} ({{.Date}}) +{{- template "rnSection" .Sections.feat}} +{{- template "rnSection" .Sections.fix}} +{{- template "rnSectionBreakingChanges" .BreakingChanges}} +` +) + +// OutputFormatter output formatter interface. +type OutputFormatter interface { + FormatReleaseNote(releasenote ReleaseNote) string + FormatChangelog(releasenotes []ReleaseNote) string +} + +// OutputFormatterImpl formater for release note and changelog. +type OutputFormatterImpl struct { + releasenoteTemplate *template.Template + changelogTemplate *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} +} + +// FormatReleaseNote format a release note. +func (p OutputFormatterImpl) FormatReleaseNote(releasenote ReleaseNote) string { + var b bytes.Buffer + p.releasenoteTemplate.Execute(&b, releaseNoteVariables(releasenote)) + return b.String() +} + +// FormatChangelog format a changelog +func (p OutputFormatterImpl) FormatChangelog(releasenotes []ReleaseNote) string { + var templateVars []releaseNoteTemplateVariables + for _, v := range releasenotes { + templateVars = append(templateVars, releaseNoteVariables(v)) + } + + var b bytes.Buffer + p.changelogTemplate.Execute(&b, templateVars) + return b.String() +} + +func releaseNoteVariables(releasenote ReleaseNote) releaseNoteTemplateVariables { + return releaseNoteTemplateVariables{ + Version: fmt.Sprintf("%d.%d.%d", releasenote.Version.Major(), releasenote.Version.Minor(), releasenote.Version.Patch()), + Date: releasenote.Date.Format("2006-01-02"), + Sections: releasenote.Sections, + BreakingChanges: releasenote.BreakingChanges, + } +} diff --git a/sv/git.go b/sv/git.go index 709d336..b126b31 100644 --- a/sv/git.go +++ b/sv/git.go @@ -13,10 +13,13 @@ import ( ) const ( - logSeparator = "##" - endLine = "~~" - breakingChangesKey = "breakingchange" - issueIDKey = "issueid" + logSeparator = "##" + endLine = "~~" + + // BreakingChangesKey key to breaking change metadata + BreakingChangesKey = "breakingchange" + // IssueIDKey key to issue id metadata + IssueIDKey = "issueid" ) // Git commands @@ -52,7 +55,7 @@ type GitImpl struct { // NewGit constructor func NewGit(breakinChangePrefixes, issueIDPrefixes []string, tagPattern string) *GitImpl { return &GitImpl{ - messageMetadata: map[string][]string{breakingChangesKey: breakinChangePrefixes, issueIDKey: issueIDPrefixes}, + messageMetadata: map[string][]string{BreakingChangesKey: breakinChangePrefixes, IssueIDKey: issueIDPrefixes}, tagPattern: tagPattern, } } diff --git a/sv/helpers_test.go b/sv/helpers_test.go index 6d54f11..69b6656 100644 --- a/sv/helpers_test.go +++ b/sv/helpers_test.go @@ -19,15 +19,16 @@ func commitlog(t string, metadata map[string]string) GitCommitLog { } } -func releaseNote(date time.Time, sections map[string]ReleaseNoteSection, breakingChanges []string) ReleaseNote { +func releaseNote(version semver.Version, date time.Time, sections map[string]ReleaseNoteSection, breakingChanges []string) ReleaseNote { return ReleaseNote{ + Version: version, Date: date.Truncate(time.Minute), Sections: sections, BreakingChanges: breakingChanges, } } -func rnSection(name string, items []GitCommitLog) ReleaseNoteSection { +func newReleaseNoteSection(name string, items []GitCommitLog) ReleaseNoteSection { return ReleaseNoteSection{ Name: name, Items: items, diff --git a/sv/releasenotes.go b/sv/releasenotes.go index 991c3a4..9a30f41 100644 --- a/sv/releasenotes.go +++ b/sv/releasenotes.go @@ -1,56 +1,28 @@ package sv import ( - "bytes" - "fmt" - "text/template" "time" "github.com/Masterminds/semver" ) -type releaseNoteTemplate struct { - Version string - Date string - Sections map[string]ReleaseNoteSection - BreakingChanges []string -} - -const markdownTemplate = `## v{{.Version}} ({{.Date}}) - -{{if .Sections.feat}}### {{.Sections.feat.Name}} -{{range $k,$v := .Sections.feat.Items}} -- {{if $v.Scope}}**{{$v.Scope}}:** {{end}}{{$v.Subject}} ({{$v.Hash}}) {{if $v.Metadata.issueid}}({{$v.Metadata.issueid}}){{end}}{{end}}{{end}} - -{{if .Sections.fix}}### {{.Sections.fix.Name}} -{{range $k,$v := .Sections.fix.Items}} -- {{if $v.Scope}}**{{$v.Scope}}:** {{end}}{{$v.Subject}} ({{$v.Hash}}) {{if $v.Metadata.issueid}}({{$v.Metadata.issueid}}){{end}}{{end}}{{end}} - -{{if .BreakingChanges}}### Breaking Changes -{{range $k,$v := .BreakingChanges}} -- {{$v}}{{end}} -{{end}}` - // ReleaseNoteProcessor release note processor interface. type ReleaseNoteProcessor interface { - Get(date time.Time, commits []GitCommitLog) ReleaseNote - Format(releasenote ReleaseNote, version semver.Version) string + Create(version semver.Version, date time.Time, commits []GitCommitLog) ReleaseNote } // ReleaseNoteProcessorImpl release note based on commit log. type ReleaseNoteProcessorImpl struct { - tags map[string]string - template *template.Template + tags map[string]string } // NewReleaseNoteProcessor ReleaseNoteProcessor constructor. func NewReleaseNoteProcessor(tags map[string]string) *ReleaseNoteProcessorImpl { - template := template.Must(template.New("markdown").Parse(markdownTemplate)) - return &ReleaseNoteProcessorImpl{tags: tags, template: template} + return &ReleaseNoteProcessorImpl{tags: tags} } -// Get generate a release note based on commits. -func (p ReleaseNoteProcessorImpl) Get(date time.Time, commits []GitCommitLog) ReleaseNote { +// Create create a release note based on commits. +func (p ReleaseNoteProcessorImpl) Create(version semver.Version, date time.Time, commits []GitCommitLog) ReleaseNote { sections := make(map[string]ReleaseNoteSection) var breakingChanges []string for _, commit := range commits { @@ -62,30 +34,17 @@ func (p ReleaseNoteProcessorImpl) Get(date time.Time, commits []GitCommitLog) Re section.Items = append(section.Items, commit) sections[commit.Type] = section } - if value, exists := commit.Metadata[BreakingChangeTag]; exists { + if value, exists := commit.Metadata[BreakingChangesKey]; exists { breakingChanges = append(breakingChanges, value) } } - return ReleaseNote{Date: date.Truncate(time.Minute), Sections: sections, BreakingChanges: breakingChanges} -} - -// Format format a release note. -func (p ReleaseNoteProcessorImpl) Format(releasenote ReleaseNote, version semver.Version) string { - templateVars := releaseNoteTemplate{ - Version: fmt.Sprintf("%d.%d.%d", version.Major(), version.Minor(), version.Patch()), - Date: releasenote.Date.Format("2006-01-02"), - Sections: releasenote.Sections, - BreakingChanges: releasenote.BreakingChanges, - } - - var b bytes.Buffer - p.template.Execute(&b, templateVars) - return b.String() + return ReleaseNote{Version: version, Date: date.Truncate(time.Minute), Sections: sections, BreakingChanges: breakingChanges} } // ReleaseNote release note. type ReleaseNote struct { + Version semver.Version Date time.Time Sections map[string]ReleaseNoteSection BreakingChanges []string diff --git a/sv/releasenotes_test.go b/sv/releasenotes_test.go index c65d063..4ca20b6 100644 --- a/sv/releasenotes_test.go +++ b/sv/releasenotes_test.go @@ -4,41 +4,47 @@ import ( "reflect" "testing" "time" + + "github.com/Masterminds/semver" ) -func TestReleaseNoteProcessorImpl_Get(t *testing.T) { +func TestReleaseNoteProcessorImpl_Create(t *testing.T) { date := time.Now() tests := []struct { name string + version semver.Version date time.Time commits []GitCommitLog want ReleaseNote }{ { name: "mapped tag", + version: *semver.MustParse("1.0.0"), date: date, commits: []GitCommitLog{commitlog("t1", map[string]string{})}, - want: releaseNote(date, map[string]ReleaseNoteSection{"t1": rnSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, nil), + want: releaseNote(*semver.MustParse("1.0.0"), date, map[string]ReleaseNoteSection{"t1": newReleaseNoteSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, nil), }, { name: "unmapped tag", + version: *semver.MustParse("1.0.0"), date: date, commits: []GitCommitLog{commitlog("t1", map[string]string{}), commitlog("unmapped", map[string]string{})}, - want: releaseNote(date, map[string]ReleaseNoteSection{"t1": rnSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, nil), + want: releaseNote(*semver.MustParse("1.0.0"), date, map[string]ReleaseNoteSection{"t1": newReleaseNoteSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, nil), }, { name: "breaking changes tag", + version: *semver.MustParse("1.0.0"), date: date, commits: []GitCommitLog{commitlog("t1", map[string]string{}), commitlog("unmapped", map[string]string{"breakingchange": "breaks"})}, - want: releaseNote(date, map[string]ReleaseNoteSection{"t1": rnSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, []string{"breaks"}), + want: releaseNote(*semver.MustParse("1.0.0"), date, map[string]ReleaseNoteSection{"t1": newReleaseNoteSection("Tag 1", []GitCommitLog{commitlog("t1", map[string]string{})})}, []string{"breaks"}), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := NewReleaseNoteProcessor(map[string]string{"t1": "Tag 1", "t2": "Tag 2"}) - if got := p.Get(tt.date, tt.commits); !reflect.DeepEqual(got, tt.want) { - t.Errorf("ReleaseNoteProcessorImpl.Get() = %v, want %v", got, tt.want) + if got := p.Create(tt.version, tt.date, tt.commits); !reflect.DeepEqual(got, tt.want) { + t.Errorf("ReleaseNoteProcessorImpl.Create() = %v, want %v", got, tt.want) } }) } diff --git a/sv/semver.go b/sv/semver.go index 1c0523c..8350bda 100644 --- a/sv/semver.go +++ b/sv/semver.go @@ -24,9 +24,6 @@ func ToVersion(value string) (semver.Version, error) { return *v, nil } -// BreakingChangeTag breaking change tag from commit metadata -const BreakingChangeTag string = "breakingchange" - // SemVerCommitsProcessor interface type SemVerCommitsProcessor interface { NextVersion(version semver.Version, commits []GitCommitLog) semver.Version @@ -72,7 +69,7 @@ func (p SemVerCommitsProcessorImpl) NextVersion(version semver.Version, commits } func (p SemVerCommitsProcessorImpl) versionTypeToUpdate(commit GitCommitLog) versionType { - if _, exists := commit.Metadata[BreakingChangeTag]; exists { + if _, exists := commit.Metadata[BreakingChangesKey]; exists { return major } if _, exists := p.MajorVersionTypes[commit.Type]; exists {