From 7a6d2a015d2af3a94586757c67295209c60c979c Mon Sep 17 00:00:00 2001 From: tsuyoshiwada Date: Wed, 21 Feb 2018 23:12:46 +0900 Subject: [PATCH 1/6] feat: Remove commit message preview on select format --- cmd/git-chglog/initializer.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/cmd/git-chglog/initializer.go b/cmd/git-chglog/initializer.go index 72564736..c68a1aa5 100644 --- a/cmd/git-chglog/initializer.go +++ b/cmd/git-chglog/initializer.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "os" "path/filepath" + "strings" "github.com/fatih/color" gitcmd "github.com/tsuyoshiwada/go-gitcmd" @@ -25,9 +26,9 @@ var ( } fmtTypeScopeSubject = "(): -- feat(core) Add new feature" - fmtTypeSubject = ": -- feat: Add new feature" - fmtGitBasic = "< subject> -- Add new feature" - fmtSubject = " -- Add new feature (Not detect `type` field)" + fmtTypeSubject = ": -- feat: Add new feature" + fmtGitBasic = "< subject> -- Add new feature" + fmtSubject = " -- Add new feature (Not detect `type` field)" commitMessageFormats = []string{ fmtTypeScopeSubject, fmtTypeSubject, @@ -134,6 +135,12 @@ func (init *Initializer) createQuestions() []*survey.Question { Options: commitMessageFormats, Default: commitMessageFormats[0], }, + Transform: func(ans interface{}) (newAns interface{}) { + if s, ok := ans.(string); ok { + newAns = strings.TrimSpace(strings.Split(s, "--")[0]) + } + return + }, }, { Name: "template", From b7385619d12a4201f276ff1ad179fa0c3b6fc1b0 Mon Sep 17 00:00:00 2001 From: tsuyoshiwada Date: Sun, 25 Feb 2018 00:55:55 +0900 Subject: [PATCH 2/6] chore: Add coverage measurement task for local confirmation --- Makefile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Makefile b/Makefile index 126db51a..4a5c2aa9 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ clean: rm -rf ./dist/ rm -rf ./git-chglog rm -rf $(GOPATH)/bin/git-chglog + rm -rf cover.out .PHONY: bulid build: @@ -20,6 +21,12 @@ build: test: go test -v `go list ./... | grep -v /vendor/` +.PHONY: coverage +coverage: + goverage -coverprofile=cover.out `go list ./... | grep -v /vendor/` + go tool cover -func=cover.out + @rm -rf cover.out + .PHONY: install install: go install ./cmd/git-chglog From 1b2210dbca5deb3a9ab3e93b8765b51aca115955 Mon Sep 17 00:00:00 2001 From: tsuyoshiwada Date: Sun, 25 Feb 2018 00:56:29 +0900 Subject: [PATCH 3/6] chore: Remove unnecessary task --- Makefile | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Makefile b/Makefile index 4a5c2aa9..919d3cbc 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,3 @@ coverage: .PHONY: install install: go install ./cmd/git-chglog - -.PHONY: chglog -chglog: - git-chglog -c ./.chglog/config.yml From 8780cd12442b65b6931231933f77f5fd3e20a468 Mon Sep 17 00:00:00 2001 From: tsuyoshiwada Date: Sun, 25 Feb 2018 00:57:27 +0900 Subject: [PATCH 4/6] refactor: Refactor `Initializer` to testable --- cmd/git-chglog/builder.go | 6 + cmd/git-chglog/cli.go | 5 +- cmd/git-chglog/cli_test.go | 4 +- cmd/git-chglog/config.go | 4 +- cmd/git-chglog/config_builder.go | 73 ++++ cmd/git-chglog/config_builder_mock.go | 9 + cmd/git-chglog/config_builder_test.go | 62 ++++ cmd/git-chglog/config_test.go | 4 +- cmd/git-chglog/context.go | 11 +- cmd/git-chglog/fs.go | 12 + cmd/git-chglog/fs_mock.go | 14 +- cmd/git-chglog/initializer.go | 421 +++--------------------- cmd/git-chglog/initializer_test.go | 73 ++++ cmd/git-chglog/main.go | 46 ++- cmd/git-chglog/questioner.go | 188 +++++++++++ cmd/git-chglog/questioner_mock.go | 9 + cmd/git-chglog/template_builder.go | 143 ++++++++ cmd/git-chglog/template_builder_mock.go | 9 + cmd/git-chglog/template_builder_test.go | 253 ++++++++++++++ cmd/git-chglog/variables.go | 95 ++++++ cmd/git-chglog/variables_test.go | 30 ++ 21 files changed, 1067 insertions(+), 404 deletions(-) create mode 100644 cmd/git-chglog/builder.go create mode 100644 cmd/git-chglog/config_builder.go create mode 100644 cmd/git-chglog/config_builder_mock.go create mode 100644 cmd/git-chglog/config_builder_test.go create mode 100644 cmd/git-chglog/initializer_test.go create mode 100644 cmd/git-chglog/questioner.go create mode 100644 cmd/git-chglog/questioner_mock.go create mode 100644 cmd/git-chglog/template_builder.go create mode 100644 cmd/git-chglog/template_builder_mock.go create mode 100644 cmd/git-chglog/template_builder_test.go create mode 100644 cmd/git-chglog/variables.go create mode 100644 cmd/git-chglog/variables_test.go diff --git a/cmd/git-chglog/builder.go b/cmd/git-chglog/builder.go new file mode 100644 index 00000000..4d049e41 --- /dev/null +++ b/cmd/git-chglog/builder.go @@ -0,0 +1,6 @@ +package main + +// Builder ... +type Builder interface { + Build(*Answer) (string, error) +} diff --git a/cmd/git-chglog/cli.go b/cmd/git-chglog/cli.go index c370f952..dcb44b60 100644 --- a/cmd/git-chglog/cli.go +++ b/cmd/git-chglog/cli.go @@ -12,7 +12,7 @@ import ( // CLI ... type CLI struct { - ctx *Context + ctx *CLIContext fs FileSystem logger *Logger configLoader ConfigLoader @@ -22,8 +22,7 @@ type CLI struct { // NewCLI ... func NewCLI( - ctx *Context, - fs FileSystem, + ctx *CLIContext, fs FileSystem, configLoader ConfigLoader, generator Generator, ) *CLI { diff --git a/cmd/git-chglog/cli_test.go b/cmd/git-chglog/cli_test.go index 4319f3e8..ddbfbf17 100644 --- a/cmd/git-chglog/cli_test.go +++ b/cmd/git-chglog/cli_test.go @@ -40,7 +40,7 @@ func TestCLIForStdout(t *testing.T) { } c := NewCLI( - &Context{ + &CLIContext{ WorkingDir: "/", ConfigPath: "/.chglog/config.yml", OutputPath: "", @@ -108,7 +108,7 @@ func TestCLIForFile(t *testing.T) { } c := NewCLI( - &Context{ + &CLIContext{ WorkingDir: "/", ConfigPath: "/.chglog/config.yml", OutputPath: "/dir/to/CHANGELOG.tpl", diff --git a/cmd/git-chglog/config.go b/cmd/git-chglog/config.go index 85e72b3b..e8aa5184 100644 --- a/cmd/git-chglog/config.go +++ b/cmd/git-chglog/config.go @@ -70,7 +70,7 @@ type Config struct { } // Normalize ... -func (config *Config) Normalize(ctx *Context) error { +func (config *Config) Normalize(ctx *CLIContext) error { err := mergo.Merge(config, &Config{ Bin: "git", Template: "CHANGELOG.tpl.md", @@ -148,7 +148,7 @@ func (config *Config) normalizeStyleOfGitHub() { } // Convert ... -func (config *Config) Convert(ctx *Context) *chglog.Config { +func (config *Config) Convert(ctx *CLIContext) *chglog.Config { info := config.Info opts := config.Options diff --git a/cmd/git-chglog/config_builder.go b/cmd/git-chglog/config_builder.go new file mode 100644 index 00000000..8619a4cf --- /dev/null +++ b/cmd/git-chglog/config_builder.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "strings" +) + +// ConfigBuilder ... +type ConfigBuilder interface { + Builder +} + +type configBuilderImpl struct{} + +// NewConfigBuilder ... +func NewConfigBuilder() ConfigBuilder { + return &configBuilderImpl{} +} + +// Build ... +func (*configBuilderImpl) Build(ans *Answer) (string, error) { + var msgFormat *CommitMessageFormat + + for _, f := range formats { + if f.Display == ans.CommitMessageFormat { + msgFormat = f + break + } + } + + if msgFormat == nil { + return "", fmt.Errorf("\"%s\" is an invalid commit message format", ans.CommitMessageFormat) + } + + repoURL := strings.TrimRight(ans.RepositoryURL, "/") + if repoURL == "" { + repoURL = "\"\"" + } + + config := fmt.Sprintf(`style: %s +template: %s +info: + title: CHANGELOG + repository_url: %s +options: + commits: + # filters: + # Type: + # - feat + # - fix + # - perf + # - refactor + commit_groups: + # title_maps: + # feat: Features + # fix: Bug Fixes + # perf: Performance Improvements + # refactor: Code Refactoring + header: + pattern: "%s" + pattern_maps:%s + notes: + keywords: + - BREAKING CHANGE`, + ans.Style, + defaultTemplateFilename, + repoURL, + msgFormat.Pattern, + msgFormat.PatternMapString(), + ) + + return config, nil +} diff --git a/cmd/git-chglog/config_builder_mock.go b/cmd/git-chglog/config_builder_mock.go new file mode 100644 index 00000000..0f7c9f2e --- /dev/null +++ b/cmd/git-chglog/config_builder_mock.go @@ -0,0 +1,9 @@ +package main + +type mockConfigBuilderImpl struct { + ReturnBuild func(*Answer) (string, error) +} + +func (m *mockConfigBuilderImpl) Build(ans *Answer) (string, error) { + return m.ReturnBuild(ans) +} diff --git a/cmd/git-chglog/config_builder_test.go b/cmd/git-chglog/config_builder_test.go new file mode 100644 index 00000000..040711d8 --- /dev/null +++ b/cmd/git-chglog/config_builder_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConfigBulider(t *testing.T) { + assert := assert.New(t) + builder := NewConfigBuilder() + + out, err := builder.Build(&Answer{ + RepositoryURL: "https://github.com/git-chglog/git-chglog/git-chglog/", + Style: styleNone, + CommitMessageFormat: fmtGitBasic.Display, + Template: tplStandard, + }) + + assert.Nil(err) + assert.Contains(out, "style: none") + assert.Contains(out, "template: CHANGELOG.tpl.md") + assert.Contains(out, " repository_url: https://github.com/git-chglog/git-chglog/git-chglog") + assert.Contains(out, fmt.Sprintf(" pattern: \"%s\"", fmtGitBasic.Pattern)) + assert.Contains(out, fmt.Sprintf( + ` pattern_maps: + - %s + - %s`, + fmtGitBasic.PatternMaps[0], + fmtGitBasic.PatternMaps[1], + )) +} + +func TestConfigBuliderEmptyRepoURL(t *testing.T) { + assert := assert.New(t) + builder := NewConfigBuilder() + + out, err := builder.Build(&Answer{ + RepositoryURL: "", + Style: styleNone, + CommitMessageFormat: fmtGitBasic.Display, + Template: tplStandard, + }) + + assert.Nil(err) + assert.Contains(out, " repository_url: \"\"") +} + +func TestConfigBuliderInvalidFormat(t *testing.T) { + assert := assert.New(t) + builder := NewConfigBuilder() + + _, err := builder.Build(&Answer{ + RepositoryURL: "", + Style: styleNone, + CommitMessageFormat: "", + Template: tplStandard, + }) + + assert.Contains(err.Error(), "invalid commit message format") +} diff --git a/cmd/git-chglog/config_test.go b/cmd/git-chglog/config_test.go index 20a80278..0d2b35f2 100644 --- a/cmd/git-chglog/config_test.go +++ b/cmd/git-chglog/config_test.go @@ -16,7 +16,7 @@ func TestConfigNormalize(t *testing.T) { }, } - err := config.Normalize(&Context{ + err := config.Normalize(&CLIContext{ ConfigPath: "/test/config.yml", }) @@ -30,7 +30,7 @@ func TestConfigNormalize(t *testing.T) { Template: "/CHANGELOG.tpl.md", } - err = config.Normalize(&Context{ + err = config.Normalize(&CLIContext{ ConfigPath: "/test/config.yml", }) diff --git a/cmd/git-chglog/context.go b/cmd/git-chglog/context.go index df003280..c6a8740e 100644 --- a/cmd/git-chglog/context.go +++ b/cmd/git-chglog/context.go @@ -4,8 +4,8 @@ import ( "io" ) -// Context ... -type Context struct { +// CLIContext ... +type CLIContext struct { WorkingDir string Stdout io.Writer Stderr io.Writer @@ -16,3 +16,10 @@ type Context struct { NoEmoji bool Query string } + +// InitContext ... +type InitContext struct { + WorkingDir string + Stdout io.Writer + Stderr io.Writer +} diff --git a/cmd/git-chglog/fs.go b/cmd/git-chglog/fs.go index 06a703f0..1617e0bb 100644 --- a/cmd/git-chglog/fs.go +++ b/cmd/git-chglog/fs.go @@ -2,13 +2,16 @@ package main import ( "io" + "io/ioutil" "os" ) // FileSystem ... type FileSystem interface { + Exists(path string) bool MkdirP(path string) error Create(name string) (File, error) + WriteFile(path string, content []byte) error } // File ... @@ -25,6 +28,11 @@ var fs = &osFileSystem{} type osFileSystem struct{} +func (*osFileSystem) Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + func (*osFileSystem) MkdirP(path string) error { if _, err := os.Stat(path); os.IsNotExist(err) { return os.MkdirAll(path, os.ModePerm) @@ -35,3 +43,7 @@ func (*osFileSystem) MkdirP(path string) error { func (*osFileSystem) Create(name string) (File, error) { return os.Create(name) } + +func (*osFileSystem) WriteFile(path string, content []byte) error { + return ioutil.WriteFile(path, content, os.ModePerm) +} diff --git a/cmd/git-chglog/fs_mock.go b/cmd/git-chglog/fs_mock.go index 4ddd9b7a..75b5fcfd 100644 --- a/cmd/git-chglog/fs_mock.go +++ b/cmd/git-chglog/fs_mock.go @@ -1,8 +1,14 @@ package main type mockFileSystem struct { - ReturnMkdirP func(string) error - ReturnCreate func(string) (File, error) + ReturnExists func(string) bool + ReturnMkdirP func(string) error + ReturnCreate func(string) (File, error) + ReturnWriteFile func(string, []byte) error +} + +func (m *mockFileSystem) Exists(path string) bool { + return m.ReturnExists(path) } func (m *mockFileSystem) MkdirP(path string) error { @@ -13,6 +19,10 @@ func (m *mockFileSystem) Create(name string) (File, error) { return m.ReturnCreate(name) } +func (m *mockFileSystem) WriteFile(path string, content []byte) error { + return m.ReturnWriteFile(path, content) +} + type mockFile struct { File ReturnWrite func([]byte) (int, error) diff --git a/cmd/git-chglog/initializer.go b/cmd/git-chglog/initializer.go index c68a1aa5..920df14b 100644 --- a/cmd/git-chglog/initializer.go +++ b/cmd/git-chglog/initializer.go @@ -1,426 +1,95 @@ package main import ( - "errors" "fmt" - "io/ioutil" - "os" "path/filepath" - "strings" "github.com/fatih/color" gitcmd "github.com/tsuyoshiwada/go-gitcmd" - survey "gopkg.in/AlecAivazis/survey.v1" - emoji "gopkg.in/kyokomi/emoji.v1" ) -var ( - defaultConfigFilename = "config.yml" - defaultTemplateFilename = "CHANGELOG.tpl.md" - - styleGitHub = "github" - styleNone = "none" - changelogStyles = []string{ - styleGitHub, - styleNone, - } - - fmtTypeScopeSubject = "(): -- feat(core) Add new feature" - fmtTypeSubject = ": -- feat: Add new feature" - fmtGitBasic = "< subject> -- Add new feature" - fmtSubject = " -- Add new feature (Not detect `type` field)" - commitMessageFormats = []string{ - fmtTypeScopeSubject, - fmtTypeSubject, - fmtGitBasic, - fmtSubject, - } - - tplStandard = "standard" - tplCool = "cool" - templateStyles = []string{ - tplStandard, - tplCool, - } -) - -// Answer ... -type Answer struct { - RepositoryURL string `survey:"repository_url"` - Style string `survey:"style"` - CommitMessageFormat string `survey:"commit_message_format"` - Template string `survey:"template"` - IncludeMerges bool `survey:"include_merges"` - IncludeReverts bool `survey:"include_reverts"` - ConfigDir string `survey:"config_dir"` -} - // Initializer ... type Initializer struct { - client gitcmd.Client + ctx *InitContext + client gitcmd.Client + fs FileSystem + logger *Logger + questioner Questioner + configBuilder ConfigBuilder + templateBuilder TemplateBuilder } // NewInitializer ... -func NewInitializer() *Initializer { +func NewInitializer( + ctx *InitContext, + fs FileSystem, + questioner Questioner, + configBuilder ConfigBuilder, + templateBuilder TemplateBuilder, +) *Initializer { return &Initializer{ - client: gitcmd.New(&gitcmd.Config{ - Bin: "git", - }), + ctx: ctx, + fs: fs, + logger: NewLogger(ctx.Stdout, ctx.Stderr, false, false), + questioner: questioner, + configBuilder: configBuilder, + templateBuilder: templateBuilder, } } // Run ... func (init *Initializer) Run() int { - answer, err := init.ask() + ans, err := init.questioner.Ask() if err != nil { + init.logger.Error(err.Error()) return ExitCodeError } - err = init.generateConfigure(answer) - if err != nil { + if err = init.fs.MkdirP(filepath.Join(init.ctx.WorkingDir, ans.ConfigDir)); err != nil { + init.logger.Error(err.Error()) + return ExitCodeError + } + + if err = init.generateConfig(ans); err != nil { + init.logger.Error(err.Error()) + return ExitCodeError + } + + if err = init.generateTemplate(ans); err != nil { + init.logger.Error(err.Error()) return ExitCodeError } success := color.CyanString("✔") - emoji.Fprintf(os.Stdout, ` + init.logger.Log(fmt.Sprintf(` :sparkles: %s %s %s %s %s - `, color.GreenString("Configuration file and template generation completed!"), success, - filepath.Join(answer.ConfigDir, defaultConfigFilename), + filepath.Join(ans.ConfigDir, defaultConfigFilename), success, - filepath.Join(answer.ConfigDir, defaultTemplateFilename), - ) + filepath.Join(ans.ConfigDir, defaultTemplateFilename), + )) return ExitCodeOK } -func (init *Initializer) ask() (*Answer, error) { - answer := &Answer{} - qs := init.createQuestions() - err := survey.Ask(qs, answer) - if err != nil { - return nil, err - } - - return answer, nil -} - -func (init *Initializer) createQuestions() []*survey.Question { - originURL := init.getRepositoryURL() - - return []*survey.Question{ - { - Name: "repository_url", - Prompt: &survey.Input{ - Message: "What is the URL of your repository?", - Default: originURL, - }, - }, - { - Name: "style", - Prompt: &survey.Select{ - Message: "What is your favorite style?", - Options: changelogStyles, - Default: changelogStyles[0], - }, - }, - { - Name: "commit_message_format", - Prompt: &survey.Select{ - Message: "Choose the format of your favorite commit message", - Options: commitMessageFormats, - Default: commitMessageFormats[0], - }, - Transform: func(ans interface{}) (newAns interface{}) { - if s, ok := ans.(string); ok { - newAns = strings.TrimSpace(strings.Split(s, "--")[0]) - } - return - }, - }, - { - Name: "template", - Prompt: &survey.Select{ - Message: "What is your favorite template style?", - Options: templateStyles, - Default: templateStyles[0], - }, - }, - { - Name: "include_merges", - Prompt: &survey.Confirm{ - Message: "Do you include Merge Commit in CHANGELOG?", - Default: true, - }, - }, - { - Name: "include_reverts", - Prompt: &survey.Confirm{ - Message: "Do you include Revert Commit in CHANGELOG?", - Default: true, - }, - }, - { - Name: "config_dir", - Prompt: &survey.Input{ - Message: "In which directory do you output configuration files and templates?", - Default: ".chglog", - }, - }, - } -} - -func (init *Initializer) getRepositoryURL() string { - if init.client.CanExec() != nil || init.client.InsideWorkTree() != nil { - return "" - } - - rawurl, err := init.client.Exec("config", "--get", "remote.origin.url") - if err != nil { - return "" - } - - return remoteOriginURLToHTTP(rawurl) -} - -func (init *Initializer) generateConfigure(answer *Answer) error { - var err error - - err = fs.MkdirP(answer.ConfigDir) +func (init *Initializer) generateConfig(ans *Answer) error { + s, err := init.configBuilder.Build(ans) if err != nil { return err } - config := init.createConfigYamlContent(answer) - tpl := init.createTemplate(answer) + return init.fs.WriteFile(filepath.Join(init.ctx.WorkingDir, ans.ConfigDir, defaultConfigFilename), []byte(s)) +} - configPath := filepath.Join(answer.ConfigDir, defaultConfigFilename) - templatePath := filepath.Join(answer.ConfigDir, defaultTemplateFilename) - - err = init.createFileWithConfirm(configPath, config) +func (init *Initializer) generateTemplate(ans *Answer) error { + s, err := init.templateBuilder.Build(ans) if err != nil { return err } - err = init.createFileWithConfirm(templatePath, tpl) - if err != nil { - return err - } - - return nil -} - -func (*Initializer) createFileWithConfirm(path, content string) error { - if _, err := os.Stat(path); err == nil { - answer := struct { - OK bool - }{} - - err := survey.Ask([]*survey.Question{ - { - Name: "ok", - Prompt: &survey.Confirm{ - Message: fmt.Sprintf("\"%s\" already exists. Do you want to overwrite?", path), - Default: true, - }, - }, - }, &answer) - - if err != nil { - return err - } - - if !answer.OK { - return errors.New("creation of the file was interrupted") - } - } - - err := ioutil.WriteFile(path, []byte(content), os.ModePerm) - if err != nil { - return err - } - - return nil -} - -func (init *Initializer) createConfigYamlContent(answer *Answer) string { - var ( - style = answer.Style - template = defaultTemplateFilename - repositoryURL = answer.RepositoryURL - headerPattern string - headerPatternMaps string - ) - - switch answer.CommitMessageFormat { - case fmtTypeScopeSubject: - headerPattern = `^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$` - headerPatternMaps = ` - - Type - - Scope - - Subject` - case fmtTypeSubject: - headerPattern = `^(\\w*)\\:\\s(.*)$` - headerPatternMaps = ` - - Type - - Subject` - case fmtGitBasic: - headerPattern = `^((\\w+)\\s.*)$` - headerPatternMaps = ` - - Subject - - Type` - case fmtSubject: - headerPattern = `^(.*)$` - headerPatternMaps = ` - - Subject` - } - - config := fmt.Sprintf(`style: %s -template: %s -info: - title: CHANGELOG - repository_url: %s -options: - commits: - # filters: - # Type: - # - feat - # - fix - # - perf - # - refactor - commit_groups: - # title_maps: - # feat: Features - # fix: Bug Fixes - # perf: Performance Improvements - # refactor: Code Refactoring - header: - pattern: "%s" - pattern_maps:%s - notes: - keywords: - - BREAKING CHANGE`, - style, - template, - repositoryURL, - headerPattern, - headerPatternMaps, - ) - - return config -} - -func (init *Initializer) createTemplate(answer *Answer) string { - tpl := "{{range .Versions}}\n" - - // versions - tpl += init.versionHeader(answer.Style, answer.Template) - - // commits - tpl += init.commits(answer.Style, answer.Template, answer.CommitMessageFormat) - - // revert - if answer.IncludeReverts { - tpl += `{{if .RevertCommits}} -### Reverts -{{range .RevertCommits}} -* {{.Revert.Header}}{{end}} -{{end}}` - } - - // merge - if answer.IncludeMerges { - tpl += fmt.Sprintf(`{{if .MergeCommits}} -### %s -{{range .MergeCommits}} -* {{.Header}}{{end}} -{{end}}`, init.mergeTitle(answer.Style)) - } - - tpl += `{{range .NoteGroups}} -### {{.Title}} -{{range .Notes}} -{{.Body}} -{{end}} -{{end}} -{{end}}` - - return tpl -} - -func (*Initializer) versionHeader(style, template string) string { - var ( - tpl string - tagName string - date = "{{datetime \"2006-01-02\" .Tag.Date}}" - ) - - // parts - switch style { - case styleGitHub: - tpl = "\n" - tagName = "{{if .Tag.Previous}}[{{.Tag.Name}}]({{$.Info.RepositoryURL}}/compare/{{.Tag.Previous.Name}}...{{.Tag.Name}}){{else}}{{.Tag.Name}}{{end}}" - default: - tagName = "{{.Tag.Name}}" - } - - // format - switch template { - case tplStandard: - tpl = fmt.Sprintf("%s## %s (%s)\n", - tpl, - tagName, - date, - ) - case tplCool: - tpl = fmt.Sprintf("%s## %s\n\n> %s\n", - tpl, - tagName, - date, - ) - } - - return tpl -} - -func (*Initializer) commits(style, template, format string) string { - var ( - header string - body string - ) - - switch format { - case fmtSubject: - body = `{{range .Commits}} -* {{.Header}}{{end}} -` - - default: - if format == fmtTypeScopeSubject { - header = "{{if ne .Scope \"\"}}**{{.Scope}}:** {{end}}{{.Subject}}" - } else { - header = "{{.Subject}}" - } - - body = fmt.Sprintf(`### {{.Title}} -{{range .Commits}} -* %s{{end}} -`, header) - } - - return fmt.Sprintf(`{{range .CommitGroups}} -%s{{end}}`, body) -} - -func (*Initializer) mergeTitle(style string) string { - switch style { - case styleGitHub: - return "Pull Requests" - default: - return "Merges" - } + return init.fs.WriteFile(filepath.Join(init.ctx.WorkingDir, ans.ConfigDir, defaultTemplateFilename), []byte(s)) } diff --git a/cmd/git-chglog/initializer_test.go b/cmd/git-chglog/initializer_test.go new file mode 100644 index 00000000..5c4717c4 --- /dev/null +++ b/cmd/git-chglog/initializer_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInitializer(t *testing.T) { + assert := assert.New(t) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + mockFs := &mockFileSystem{ + ReturnMkdirP: func(path string) error { + if path == "/test/config" { + return nil + } + return errors.New("") + }, + ReturnWriteFile: func(path string, content []byte) error { + if path == "/test/config/config.yml" || path == "/test/config/CHANGELOG.tpl.md" { + return nil + } + return errors.New("") + }, + } + + questioner := &mockQuestionerImpl{ + ReturnAsk: func() (*Answer, error) { + return &Answer{ + ConfigDir: "config", + }, nil + }, + } + + configBuilder := &mockConfigBuilderImpl{ + ReturnBuild: func(ans *Answer) (string, error) { + if ans.ConfigDir == "config" { + return "config", nil + } + return "", errors.New("") + }, + } + + tplBuilder := &mockTemplateBuilderImpl{ + ReturnBuild: func(ans *Answer) (string, error) { + if ans.ConfigDir == "config" { + return "template", nil + } + return "", errors.New("") + }, + } + + init := NewInitializer( + &InitContext{ + WorkingDir: "/test", + Stdout: stdout, + Stderr: stderr, + }, + mockFs, + questioner, + configBuilder, + tplBuilder, + ) + + assert.Equal(ExitCodeOK, init.Run()) + assert.Equal("", stderr.String()) + assert.Contains(stdout.String(), "Configuration file and template") +} diff --git a/cmd/git-chglog/main.go b/cmd/git-chglog/main.go index 25090787..c4781ed0 100644 --- a/cmd/git-chglog/main.go +++ b/cmd/git-chglog/main.go @@ -5,6 +5,7 @@ import ( "os" "github.com/fatih/color" + gitcmd "github.com/tsuyoshiwada/go-gitcmd" "github.com/urfave/cli" ) @@ -111,29 +112,44 @@ func main() { wd, err := os.Getwd() if err != nil { fmt.Fprintln(os.Stderr, "failed to get working directory", err) - os.Exit(1) + os.Exit(ExitCodeError) } // initializer if c.Bool("init") { - os.Exit(NewInitializer().Run()) + initializer := NewInitializer( + &InitContext{ + WorkingDir: wd, + Stdout: os.Stdout, + Stderr: os.Stderr, + }, + fs, + NewQuestioner( + gitcmd.New(&gitcmd.Config{ + Bin: "git", + }), + fs, + ), + NewConfigBuilder(), + NewTemplateBuilder(), + ) + + os.Exit(initializer.Run()) } // chglog - ctx := &Context{ - WorkingDir: wd, - Stdout: os.Stdout, - Stderr: os.Stderr, - ConfigPath: c.String("config"), - OutputPath: c.String("output"), - Silent: c.Bool("silent"), - NoColor: c.Bool("no-color"), - NoEmoji: c.Bool("no-emoji"), - Query: c.Args().First(), - } - chglogCLI := NewCLI( - ctx, + &CLIContext{ + WorkingDir: wd, + Stdout: os.Stdout, + Stderr: os.Stderr, + ConfigPath: c.String("config"), + OutputPath: c.String("output"), + Silent: c.Bool("silent"), + NoColor: c.Bool("no-color"), + NoEmoji: c.Bool("no-emoji"), + Query: c.Args().First(), + }, fs, NewConfigLoader(), NewGenerator(), diff --git a/cmd/git-chglog/questioner.go b/cmd/git-chglog/questioner.go new file mode 100644 index 00000000..3d3082d5 --- /dev/null +++ b/cmd/git-chglog/questioner.go @@ -0,0 +1,188 @@ +package main + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + + gitcmd "github.com/tsuyoshiwada/go-gitcmd" + survey "gopkg.in/AlecAivazis/survey.v1" +) + +// Answer ... +type Answer struct { + RepositoryURL string `survey:"repository_url"` + Style string `survey:"style"` + CommitMessageFormat string `survey:"commit_message_format"` + Template string `survey:"template"` + IncludeMerges bool `survey:"include_merges"` + IncludeReverts bool `survey:"include_reverts"` + ConfigDir string `survey:"config_dir"` +} + +// Questioner ... +type Questioner interface { + Ask() (*Answer, error) +} + +type questionerImpl struct { + client gitcmd.Client + fs FileSystem +} + +// NewQuestioner ... +func NewQuestioner(client gitcmd.Client, fs FileSystem) Questioner { + return &questionerImpl{ + client: client, + fs: fs, + } +} + +// Ask ... +func (q *questionerImpl) Ask() (*Answer, error) { + ans, err := q.ask() + if err != nil { + return nil, err + } + + config := filepath.Join(ans.ConfigDir, defaultConfigFilename) + tpl := filepath.Join(ans.ConfigDir, defaultTemplateFilename) + c := q.fs.Exists(config) + t := q.fs.Exists(tpl) + msg := "" + + if c && t { + msg = fmt.Sprintf("\"%s\" and \"%s\" already exists. Do you want to overwrite?", config, tpl) + } else if c { + msg = fmt.Sprintf("\"%s\" already exists. Do you want to overwrite?", config) + } else if t { + msg = fmt.Sprintf("\"%s\" already exists. Do you want to overwrite?", tpl) + } + + if msg != "" { + overwrite := false + err = survey.AskOne(&survey.Confirm{ + Message: msg, + Default: true, + }, &overwrite, nil) + + if err != nil || !overwrite { + return nil, errors.New("creation of the file was interrupted") + } + } + + return ans, nil +} + +func (q *questionerImpl) ask() (*Answer, error) { + ans := &Answer{} + fmts := q.getFormats() + + questions := []*survey.Question{ + { + Name: "repository_url", + Prompt: &survey.Input{ + Message: "What is the URL of your repository?", + Default: q.getRepositoryURL(), + }, + }, + { + Name: "style", + Prompt: &survey.Select{ + Message: "What is your favorite style?", + Options: styles, + Default: styles[0], + }, + }, + { + Name: "commit_message_format", + Prompt: &survey.Select{ + Message: "Choose the format of your favorite commit message", + Options: fmts, + Default: fmts[0], + }, + Transform: func(ans interface{}) (newAns interface{}) { + if s, ok := ans.(string); ok { + newAns = q.parseFormat(s) + } + return + }, + }, + { + Name: "template", + Prompt: &survey.Select{ + Message: "What is your favorite template style?", + Options: templates, + Default: templates[0], + }, + }, + { + Name: "include_merges", + Prompt: &survey.Confirm{ + Message: "Do you include Merge Commit in CHANGELOG?", + Default: true, + }, + }, + { + Name: "include_reverts", + Prompt: &survey.Confirm{ + Message: "Do you include Revert Commit in CHANGELOG?", + Default: true, + }, + }, + { + Name: "config_dir", + Prompt: &survey.Input{ + Message: "In which directory do you output configuration files and templates?", + Default: defaultConfigDir, + }, + }, + } + + err := survey.Ask(questions, ans) + if err != nil { + return nil, err + } + + return ans, nil +} + +func (*questionerImpl) getFormats() []string { + arr := make([]string, len(formats)) + max := 0 + + for _, f := range formats { + l := len(f.Display) + if max < l { + max = l + } + } + + for i, f := range formats { + arr[i] = fmt.Sprintf( + "%s -- %s", + f.Display+strings.Repeat(" ", max-len(f.Display)), + f.Preview, + ) + } + + return arr +} + +func (*questionerImpl) parseFormat(input string) string { + return strings.TrimSpace(strings.Split(input, "--")[0]) +} + +func (q *questionerImpl) getRepositoryURL() string { + if q.client.CanExec() != nil || q.client.InsideWorkTree() != nil { + return "" + } + + rawurl, err := q.client.Exec("config", "--get", "remote.origin.url") + if err != nil { + return "" + } + + return remoteOriginURLToHTTP(rawurl) +} diff --git a/cmd/git-chglog/questioner_mock.go b/cmd/git-chglog/questioner_mock.go new file mode 100644 index 00000000..dedae8f5 --- /dev/null +++ b/cmd/git-chglog/questioner_mock.go @@ -0,0 +1,9 @@ +package main + +type mockQuestionerImpl struct { + ReturnAsk func() (*Answer, error) +} + +func (m *mockQuestionerImpl) Ask() (*Answer, error) { + return m.ReturnAsk() +} diff --git a/cmd/git-chglog/template_builder.go b/cmd/git-chglog/template_builder.go new file mode 100644 index 00000000..7154c0f3 --- /dev/null +++ b/cmd/git-chglog/template_builder.go @@ -0,0 +1,143 @@ +package main + +import "fmt" + +// TemplateBuilder ... +type TemplateBuilder interface { + Builder +} + +type templateBuilderImpl struct{} + +// NewTemplateBuilder ... +func NewTemplateBuilder() TemplateBuilder { + return &templateBuilderImpl{} +} + +// Build ... +func (t *templateBuilderImpl) Build(ans *Answer) (string, error) { + // versions + tpl := "{{range .Versions}}\n" + + // version header + tpl += t.versionHeader(ans.Style, ans.Template) + + // commits + tpl += t.commits(ans.Template, ans.CommitMessageFormat) + + // revert + if ans.IncludeReverts { + tpl += t.reverts() + } + + // merges + if ans.IncludeMerges { + tpl += t.merges(ans.Style) + } + + // notes + tpl += t.notes() + + // versions end + tpl += "\n{{end}}" + + return tpl, nil +} + +func (*templateBuilderImpl) versionHeader(style, template string) string { + var ( + tpl string + tagName string + date = "{{datetime \"2006-01-02\" .Tag.Date}}" + ) + + // parts + switch style { + case styleGitHub: + tpl = "\n" + tagName = "{{if .Tag.Previous}}[{{.Tag.Name}}]({{$.Info.RepositoryURL}}/compare/{{.Tag.Previous.Name}}...{{.Tag.Name}}){{else}}{{.Tag.Name}}{{end}}" + default: + tagName = "{{.Tag.Name}}" + } + + // format + switch template { + case tplStandard: + tpl = fmt.Sprintf("%s## %s (%s)\n", + tpl, + tagName, + date, + ) + case tplCool: + tpl = fmt.Sprintf("%s## %s\n\n> %s\n", + tpl, + tagName, + date, + ) + } + + return tpl +} + +func (*templateBuilderImpl) commits(template, format string) string { + var ( + header string + body string + ) + + switch format { + case fmtSubject.Display: + body = `{{range .Commits}} +* {{.Header}}{{end}} +` + + default: + if format == fmtTypeScopeSubject.Display { + header = "{{if ne .Scope \"\"}}**{{.Scope}}:** {{end}}{{.Subject}}" + } else { + header = "{{.Subject}}" + } + + body = fmt.Sprintf(`### {{.Title}} +{{range .Commits}} +* %s{{end}} +`, header) + } + + return fmt.Sprintf(`{{range .CommitGroups}} +%s{{end}}`, body) +} + +func (*templateBuilderImpl) reverts() string { + return `{{if .RevertCommits}} +### Reverts +{{range .RevertCommits}} +* {{.Revert.Header}}{{end}} +{{end}}` +} + +func (t *templateBuilderImpl) merges(style string) string { + var title string + + switch style { + case styleGitHub: + title = "Pull Requests" + default: + title = "Merges" + } + + return fmt.Sprintf(`{{if .MergeCommits}} +### %s +{{range .MergeCommits}} +* {{.Header}}{{end}} +{{end}}`, title) +} + +func (*templateBuilderImpl) notes() string { + return `{{range .NoteGroups}} +### {{.Title}} +{{range .Notes}} +{{.Body}} +{{end}} +{{end}}` +} diff --git a/cmd/git-chglog/template_builder_mock.go b/cmd/git-chglog/template_builder_mock.go new file mode 100644 index 00000000..d8cf50fd --- /dev/null +++ b/cmd/git-chglog/template_builder_mock.go @@ -0,0 +1,9 @@ +package main + +type mockTemplateBuilderImpl struct { + ReturnBuild func(*Answer) (string, error) +} + +func (m *mockTemplateBuilderImpl) Build(ans *Answer) (string, error) { + return m.ReturnBuild(ans) +} diff --git a/cmd/git-chglog/template_builder_test.go b/cmd/git-chglog/template_builder_test.go new file mode 100644 index 00000000..9d6449e0 --- /dev/null +++ b/cmd/git-chglog/template_builder_test.go @@ -0,0 +1,253 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTemplateBuilderDefault(t *testing.T) { + assert := assert.New(t) + builder := NewTemplateBuilder() + + out, err := builder.Build(&Answer{ + Style: styleGitHub, + CommitMessageFormat: fmtTypeScopeSubject.Display, + Template: tplStandard, + IncludeMerges: true, + IncludeReverts: true, + }) + + assert.Nil(err) + assert.Equal(`{{range .Versions}} + +## {{if .Tag.Previous}}[{{.Tag.Name}}]({{$.Info.RepositoryURL}}/compare/{{.Tag.Previous.Name}}...{{.Tag.Name}}){{else}}{{.Tag.Name}}{{end}} ({{datetime "2006-01-02" .Tag.Date}}) +{{range .CommitGroups}} +### {{.Title}} +{{range .Commits}} +* {{if ne .Scope ""}}**{{.Scope}}:** {{end}}{{.Subject}}{{end}} +{{end}}{{if .RevertCommits}} +### Reverts +{{range .RevertCommits}} +* {{.Revert.Header}}{{end}} +{{end}}{{if .MergeCommits}} +### Pull Requests +{{range .MergeCommits}} +* {{.Header}}{{end}} +{{end}}{{range .NoteGroups}} +### {{.Title}} +{{range .Notes}} +{{.Body}} +{{end}} +{{end}} +{{end}}`, out) +} + +func TestTemplateBuilderNone(t *testing.T) { + assert := assert.New(t) + builder := NewTemplateBuilder() + + out, err := builder.Build(&Answer{ + Style: styleNone, + CommitMessageFormat: fmtTypeScopeSubject.Display, + Template: tplStandard, + IncludeMerges: true, + IncludeReverts: true, + }) + + assert.Nil(err) + assert.Equal(`{{range .Versions}} +## {{.Tag.Name}} ({{datetime "2006-01-02" .Tag.Date}}) +{{range .CommitGroups}} +### {{.Title}} +{{range .Commits}} +* {{if ne .Scope ""}}**{{.Scope}}:** {{end}}{{.Subject}}{{end}} +{{end}}{{if .RevertCommits}} +### Reverts +{{range .RevertCommits}} +* {{.Revert.Header}}{{end}} +{{end}}{{if .MergeCommits}} +### Merges +{{range .MergeCommits}} +* {{.Header}}{{end}} +{{end}}{{range .NoteGroups}} +### {{.Title}} +{{range .Notes}} +{{.Body}} +{{end}} +{{end}} +{{end}}`, out) +} + +func TestTemplateBuilderCool(t *testing.T) { + assert := assert.New(t) + builder := NewTemplateBuilder() + + out, err := builder.Build(&Answer{ + Style: styleNone, + CommitMessageFormat: fmtTypeScopeSubject.Display, + Template: tplCool, + IncludeMerges: true, + IncludeReverts: true, + }) + + assert.Nil(err) + assert.Equal(`{{range .Versions}} +## {{.Tag.Name}} + +> {{datetime "2006-01-02" .Tag.Date}} +{{range .CommitGroups}} +### {{.Title}} +{{range .Commits}} +* {{if ne .Scope ""}}**{{.Scope}}:** {{end}}{{.Subject}}{{end}} +{{end}}{{if .RevertCommits}} +### Reverts +{{range .RevertCommits}} +* {{.Revert.Header}}{{end}} +{{end}}{{if .MergeCommits}} +### Merges +{{range .MergeCommits}} +* {{.Header}}{{end}} +{{end}}{{range .NoteGroups}} +### {{.Title}} +{{range .Notes}} +{{.Body}} +{{end}} +{{end}} +{{end}}`, out) +} + +func TestTemplateBuilderSubjectOnly(t *testing.T) { + assert := assert.New(t) + builder := NewTemplateBuilder() + + out, err := builder.Build(&Answer{ + Style: styleNone, + CommitMessageFormat: fmtSubject.Display, + Template: tplStandard, + IncludeMerges: true, + IncludeReverts: true, + }) + + assert.Nil(err) + assert.Equal(`{{range .Versions}} +## {{.Tag.Name}} ({{datetime "2006-01-02" .Tag.Date}}) +{{range .CommitGroups}} +{{range .Commits}} +* {{.Header}}{{end}} +{{end}}{{if .RevertCommits}} +### Reverts +{{range .RevertCommits}} +* {{.Revert.Header}}{{end}} +{{end}}{{if .MergeCommits}} +### Merges +{{range .MergeCommits}} +* {{.Header}}{{end}} +{{end}}{{range .NoteGroups}} +### {{.Title}} +{{range .Notes}} +{{.Body}} +{{end}} +{{end}} +{{end}}`, out) +} + +func TestTemplateBuilderSubject(t *testing.T) { + assert := assert.New(t) + builder := NewTemplateBuilder() + + out, err := builder.Build(&Answer{ + Style: styleNone, + CommitMessageFormat: fmtTypeSubject.Display, + Template: tplStandard, + IncludeMerges: true, + IncludeReverts: true, + }) + + assert.Nil(err) + assert.Equal(`{{range .Versions}} +## {{.Tag.Name}} ({{datetime "2006-01-02" .Tag.Date}}) +{{range .CommitGroups}} +### {{.Title}} +{{range .Commits}} +* {{.Subject}}{{end}} +{{end}}{{if .RevertCommits}} +### Reverts +{{range .RevertCommits}} +* {{.Revert.Header}}{{end}} +{{end}}{{if .MergeCommits}} +### Merges +{{range .MergeCommits}} +* {{.Header}}{{end}} +{{end}}{{range .NoteGroups}} +### {{.Title}} +{{range .Notes}} +{{.Body}} +{{end}} +{{end}} +{{end}}`, out) +} + +func TestTemplateBuilderIgnoreReverts(t *testing.T) { + assert := assert.New(t) + builder := NewTemplateBuilder() + + out, err := builder.Build(&Answer{ + Style: styleNone, + CommitMessageFormat: fmtTypeSubject.Display, + Template: tplStandard, + IncludeMerges: true, + IncludeReverts: false, + }) + + assert.Nil(err) + assert.Equal(`{{range .Versions}} +## {{.Tag.Name}} ({{datetime "2006-01-02" .Tag.Date}}) +{{range .CommitGroups}} +### {{.Title}} +{{range .Commits}} +* {{.Subject}}{{end}} +{{end}}{{if .MergeCommits}} +### Merges +{{range .MergeCommits}} +* {{.Header}}{{end}} +{{end}}{{range .NoteGroups}} +### {{.Title}} +{{range .Notes}} +{{.Body}} +{{end}} +{{end}} +{{end}}`, out) +} + +func TestTemplateBuilderIgnoreMerges(t *testing.T) { + assert := assert.New(t) + builder := NewTemplateBuilder() + + out, err := builder.Build(&Answer{ + Style: styleNone, + CommitMessageFormat: fmtTypeSubject.Display, + Template: tplStandard, + IncludeMerges: false, + IncludeReverts: true, + }) + + assert.Nil(err) + assert.Equal(`{{range .Versions}} +## {{.Tag.Name}} ({{datetime "2006-01-02" .Tag.Date}}) +{{range .CommitGroups}} +### {{.Title}} +{{range .Commits}} +* {{.Subject}}{{end}} +{{end}}{{if .RevertCommits}} +### Reverts +{{range .RevertCommits}} +* {{.Revert.Header}}{{end}} +{{end}}{{range .NoteGroups}} +### {{.Title}} +{{range .Notes}} +{{.Body}} +{{end}} +{{end}} +{{end}}`, out) +} diff --git a/cmd/git-chglog/variables.go b/cmd/git-chglog/variables.go new file mode 100644 index 00000000..0253f10b --- /dev/null +++ b/cmd/git-chglog/variables.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "strings" +) + +// Defaults +var ( + defaultConfigDir = ".chglog" + defaultConfigFilename = "config.yml" + defaultTemplateFilename = "CHANGELOG.tpl.md" +) + +// Styles +var ( + styleGitHub = "github" + styleNone = "none" + styles = []string{ + styleGitHub, + styleNone, + } +) + +// CommitMessageFormat ... +type CommitMessageFormat struct { + Preview string + Display string + Pattern string + PatternMaps []string +} + +// PatternMapString ... +func (f *CommitMessageFormat) PatternMapString() string { + s := " []" + l := len(f.PatternMaps) + if l == 0 { + return s + } + + arr := make([]string, l) + for i, p := range f.PatternMaps { + arr[i] = fmt.Sprintf( + "%s- %s", + strings.Repeat(" ", 6), + p, + ) + } + + return fmt.Sprintf("\n%s", strings.Join(arr, "\n")) +} + +// Formats +var ( + fmtTypeScopeSubject = &CommitMessageFormat{ + Preview: "feat(core) Add new feature", + Display: "(): ", + Pattern: `^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$`, + PatternMaps: []string{"Type", "Scope", "Subject"}, + } + fmtTypeSubject = &CommitMessageFormat{ + Preview: "feat: Add new feature", + Display: ": ", + Pattern: `^(\\w*)\\:\\s(.*)$`, + PatternMaps: []string{"Type", "Subject"}, + } + fmtGitBasic = &CommitMessageFormat{ + Preview: "Add new feature", + Display: "< subject>", + Pattern: `^((\\w+)\\s.*)$`, + PatternMaps: []string{"Subject", "Type"}, + } + fmtSubject = &CommitMessageFormat{ + Preview: "Add new feature (Not detect `type` field)", + Display: "", + Pattern: `^(.*)$`, + PatternMaps: []string{"Subject"}, + } + formats = []*CommitMessageFormat{ + fmtTypeScopeSubject, + fmtTypeSubject, + fmtGitBasic, + fmtSubject, + } +) + +// Templates +var ( + tplStandard = "standard" + tplCool = "cool" + templates = []string{ + tplStandard, + tplCool, + } +) diff --git a/cmd/git-chglog/variables_test.go b/cmd/git-chglog/variables_test.go new file mode 100644 index 00000000..c286ce93 --- /dev/null +++ b/cmd/git-chglog/variables_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCommitMessageFormatPatternMaps(t *testing.T) { + assert := assert.New(t) + + f := &CommitMessageFormat{ + PatternMaps: []string{ + "Type", + "Scope", + "Subject", + }, + } + + assert.Equal(` + - Type + - Scope + - Subject`, f.PatternMapString()) + + f = &CommitMessageFormat{ + PatternMaps: []string{}, + } + + assert.Equal(" []", f.PatternMapString()) +} From d165ea884a56229be12eb81b2689210b64362b6e Mon Sep 17 00:00:00 2001 From: tsuyoshiwada Date: Sun, 25 Feb 2018 01:10:36 +0900 Subject: [PATCH 5/6] fix: Fix a bug that `Commit.Revert.Header` is not converted by `GitHubProcessor` --- processor.go | 6 ++++++ processor_test.go | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/processor.go b/processor.go index 5fef2dae..f86d4e8c 100644 --- a/processor.go +++ b/processor.go @@ -42,9 +42,15 @@ func (p *GitHubProcessor) ProcessCommit(commit *Commit) *Commit { commit.Header = p.addLinks(commit.Header) commit.Subject = p.addLinks(commit.Subject) commit.Body = p.addLinks(commit.Body) + for _, note := range commit.Notes { note.Body = p.addLinks(note.Body) } + + if commit.Revert != nil { + commit.Revert.Header = p.addLinks(commit.Revert.Header) + } + return commit } diff --git a/processor_test.go b/processor_test.go index 68a0a2a8..5c589f49 100644 --- a/processor_test.go +++ b/processor_test.go @@ -51,4 +51,19 @@ gh-56 hoge fuga`, }, ), ) + + assert.Equal( + &Commit{ + Revert: &Revert{ + Header: "revert header [@mention](https://github.com/mention) [#123](https://example.com/issues/123)", + }, + }, + processor.ProcessCommit( + &Commit{ + Revert: &Revert{ + Header: "revert header @mention #123", + }, + }, + ), + ) } From 114b7d6fc8c5f6fda328e326bb961ed7f1609a62 Mon Sep 17 00:00:00 2001 From: tsuyoshiwada Date: Sun, 25 Feb 2018 15:57:15 +0900 Subject: [PATCH 6/6] feat: Supports annotated git-tag and adds `Tag.Subject` field #3 --- fields.go | 6 ++- tag_reader.go | 84 ++++++++++++++++++++++++++-------------- tag_reader_test.go | 95 +++++++++++++++++++++++----------------------- 3 files changed, 106 insertions(+), 79 deletions(-) diff --git a/fields.go b/fields.go index 1ad9b743..1f6f0b75 100644 --- a/fields.go +++ b/fields.go @@ -80,13 +80,15 @@ type CommitGroup struct { // If you give `Tag`, the reference hierarchy will be deepened. // This struct is used to minimize the hierarchy of references type RelateTag struct { - Name string - Date time.Time + Name string + Subject string + Date time.Time } // Tag is data of git-tag type Tag struct { Name string + Subject string Date time.Time Next *RelateTag Previous *RelateTag diff --git a/tag_reader.go b/tag_reader.go index 5cb35840..ddfa44cd 100644 --- a/tag_reader.go +++ b/tag_reader.go @@ -2,8 +2,7 @@ package chglog import ( "fmt" - "regexp" - "strconv" + "sort" "strings" "time" @@ -11,24 +10,24 @@ import ( ) type tagReader struct { - client gitcmd.Client - format string - reTag *regexp.Regexp + client gitcmd.Client + format string + separator string } func newTagReader(client gitcmd.Client) *tagReader { return &tagReader{ - client: client, - reTag: regexp.MustCompile("tag: ([\\w\\.\\-_]+),?"), + client: client, + separator: "@@__CHGLOG__@@", } } func (r *tagReader) ReadAll() ([]*Tag, error) { out, err := r.client.Exec( - "log", - "--tags", - "--simplify-by-decoration", - "--pretty=%D\t%at", + "for-each-ref", + "--format", + "%(refname)"+r.separator+"%(subject)"+r.separator+"%(taggerdate)"+r.separator+"%(authordate)", + "refs/tags", ) tags := []*Tag{} @@ -40,28 +39,49 @@ func (r *tagReader) ReadAll() ([]*Tag, error) { lines := strings.Split(out, "\n") for _, line := range lines { - if line == "" { + tokens := strings.Split(line, r.separator) + + if len(tokens) != 4 { continue } - tokens := strings.Split(line, "\t") - - res := r.reTag.FindAllStringSubmatch(tokens[0], -1) - if len(res) == 0 { - continue - } - - ts, err := strconv.Atoi(tokens[1]) + name := r.parseRefname(tokens[0]) + subject := r.parseSubject(tokens[1]) + date, err := r.parseDate(tokens[2]) if err != nil { - continue + t, err2 := r.parseDate(tokens[3]) + if err2 != nil { + return nil, err2 + } + date = t } tags = append(tags, &Tag{ - Name: res[0][1], - Date: time.Unix(int64(ts), 0), + Name: name, + Subject: subject, + Date: date, }) } + r.sortTags(tags) + r.assignPreviousAndNextTag(tags) + + return tags, nil +} + +func (*tagReader) parseRefname(input string) string { + return strings.Replace(input, "refs/tags/", "", 1) +} + +func (*tagReader) parseSubject(input string) string { + return strings.TrimSpace(input) +} + +func (*tagReader) parseDate(input string) (time.Time, error) { + return time.Parse("Mon Jan 2 15:04:05 2006 -0700", input) +} + +func (*tagReader) assignPreviousAndNextTag(tags []*Tag) { total := len(tags) for i, tag := range tags { @@ -72,21 +92,27 @@ func (r *tagReader) ReadAll() ([]*Tag, error) { if i > 0 { next = &RelateTag{ - Name: tags[i-1].Name, - Date: tags[i-1].Date, + Name: tags[i-1].Name, + Subject: tags[i-1].Subject, + Date: tags[i-1].Date, } } if i+1 < total { prev = &RelateTag{ - Name: tags[i+1].Name, - Date: tags[i+1].Date, + Name: tags[i+1].Name, + Subject: tags[i+1].Subject, + Date: tags[i+1].Date, } } tag.Next = next tag.Previous = prev } - - return tags, nil +} + +func (*tagReader) sortTags(tags []*Tag) { + sort.Slice(tags, func(i, j int) bool { + return !tags[i].Date.Before(tags[j].Date) + }) } diff --git a/tag_reader_test.go b/tag_reader_test.go index a44df44a..3d292aab 100644 --- a/tag_reader_test.go +++ b/tag_reader_test.go @@ -13,19 +13,17 @@ func TestTagReader(t *testing.T) { assert := assert.New(t) client := &mockClient{ ReturnExec: func(subcmd string, args ...string) (string, error) { - if subcmd != "log" { + if subcmd != "for-each-ref" { return "", errors.New("") } return strings.Join([]string{ "", - "tag: v5.2.0-beta.1, origin/labs/router\t1518023112", - "tag: 2.0.0\t1517875200", - "tag: v2.0.4-rc.1\t1517788800", - "tag: 2.0.4-beta.1\t1517702400", - "tag: hoge_fuga\t1517616000", - "tag: 1.9.29-alpha.0\t1517529600", - "hoge\t0", - "foo\t0", + "refs/tags/v2.0.4-beta.1@@__CHGLOG__@@Release v2.0.4-beta.1@@__CHGLOG__@@Thu Feb 1 00:00:00 2018 +0000@@__CHGLOG__@@", + "refs/tags/4.4.3@@__CHGLOG__@@This is tag subject@@__CHGLOG__@@@@__CHGLOG__@@Fri Feb 2 00:00:00 2018 +0000", + "refs/tags/4.4.4@@__CHGLOG__@@Release 4.4.4@@__CHGLOG__@@Fri Feb 2 10:00:40 2018 +0000@@__CHGLOG__@@", + "refs/tags/5.0.0-rc.0@@__CHGLOG__@@Release 5.0.0-rc.0@@__CHGLOG__@@Sat Feb 3 12:30:10 2018 +0000@@__CHGLOG__@@", + "refs/tags/hoge_fuga@@__CHGLOG__@@Invalid semver tag name@@__CHGLOG__@@Mon Mar 12 12:30:10 2018 +0000@@__CHGLOG__@@", + "hoge@@__CHGLOG__@@", }, "\n"), nil }, } @@ -36,68 +34,69 @@ func TestTagReader(t *testing.T) { assert.Equal( []*Tag{ &Tag{ - Name: "v5.2.0-beta.1", - Date: time.Unix(1518023112, 0), - Next: nil, + Name: "hoge_fuga", + Subject: "Invalid semver tag name", + Date: time.Date(2018, 3, 12, 12, 30, 10, 0, time.UTC), + Next: nil, Previous: &RelateTag{ - Name: "2.0.0", - Date: time.Unix(1517875200, 0), + Name: "5.0.0-rc.0", + Subject: "Release 5.0.0-rc.0", + Date: time.Date(2018, 2, 3, 12, 30, 10, 0, time.UTC), }, }, &Tag{ - Name: "2.0.0", - Date: time.Unix(1517875200, 0), + Name: "5.0.0-rc.0", + Subject: "Release 5.0.0-rc.0", + Date: time.Date(2018, 2, 3, 12, 30, 10, 0, time.UTC), Next: &RelateTag{ - Name: "v5.2.0-beta.1", - Date: time.Unix(1518023112, 0), + Name: "hoge_fuga", + Subject: "Invalid semver tag name", + Date: time.Date(2018, 3, 12, 12, 30, 10, 0, time.UTC), }, Previous: &RelateTag{ - Name: "v2.0.4-rc.1", - Date: time.Unix(1517788800, 0), + Name: "4.4.4", + Subject: "Release 4.4.4", + Date: time.Date(2018, 2, 2, 10, 0, 40, 0, time.UTC), }, }, &Tag{ - Name: "v2.0.4-rc.1", - Date: time.Unix(1517788800, 0), + Name: "4.4.4", + Subject: "Release 4.4.4", + Date: time.Date(2018, 2, 2, 10, 0, 40, 0, time.UTC), Next: &RelateTag{ - Name: "2.0.0", - Date: time.Unix(1517875200, 0), + Name: "5.0.0-rc.0", + Subject: "Release 5.0.0-rc.0", + Date: time.Date(2018, 2, 3, 12, 30, 10, 0, time.UTC), }, Previous: &RelateTag{ - Name: "2.0.4-beta.1", - Date: time.Unix(1517702400, 0), + Name: "4.4.3", + Subject: "This is tag subject", + Date: time.Date(2018, 2, 2, 0, 0, 0, 0, time.UTC), }, }, &Tag{ - Name: "2.0.4-beta.1", - Date: time.Unix(1517702400, 0), + Name: "4.4.3", + Subject: "This is tag subject", + Date: time.Date(2018, 2, 2, 0, 0, 0, 0, time.UTC), Next: &RelateTag{ - Name: "v2.0.4-rc.1", - Date: time.Unix(1517788800, 0), + Name: "4.4.4", + Subject: "Release 4.4.4", + Date: time.Date(2018, 2, 2, 10, 0, 40, 0, time.UTC), }, Previous: &RelateTag{ - Name: "hoge_fuga", - Date: time.Unix(1517616000, 0), + Name: "v2.0.4-beta.1", + Subject: "Release v2.0.4-beta.1", + Date: time.Date(2018, 2, 1, 0, 0, 0, 0, time.UTC), }, }, &Tag{ - Name: "hoge_fuga", - Date: time.Unix(1517616000, 0), + Name: "v2.0.4-beta.1", + Subject: "Release v2.0.4-beta.1", + Date: time.Date(2018, 2, 1, 0, 0, 0, 0, time.UTC), Next: &RelateTag{ - Name: "2.0.4-beta.1", - Date: time.Unix(1517702400, 0), - }, - Previous: &RelateTag{ - Name: "1.9.29-alpha.0", - Date: time.Unix(1517529600, 0), - }, - }, - &Tag{ - Name: "1.9.29-alpha.0", - Date: time.Unix(1517529600, 0), - Next: &RelateTag{ - Name: "hoge_fuga", - Date: time.Unix(1517616000, 0), + Name: "4.4.3", + Subject: "This is tag subject", + Date: time.Date(2018, 2, 2, 0, 0, 0, 0, time.UTC), }, Previous: nil, },