diff --git a/.sv4git.yml b/.sv4git.yml new file mode 100644 index 0000000..3f38617 --- /dev/null +++ b/.sv4git.yml @@ -0,0 +1,21 @@ +version: "1.0" + +versioning: + update-major: [] + update-minor: + - feat + update-patch: + - build + - ci + - chore + - fix + - perf + - refactor + - test + +commit-message: + footer: + issue: + key: issue + issue: + regex: '#[0-9]+' diff --git a/README.md b/README.md index 7f11866..fce07fc 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,130 @@ -# sv4git - -Semantic version for git +

+

sv4git

+

semantic version for git

+

+ Release + GitHub stars + Software License + GitHub Actions + Go Report Card + Software License +

+

## Getting Started ### Installing -download the latest release and add the binary on your path +- Download the latest release and add the binary on your path +- Optional: Set `SV4GIT_HOME` to define user configs, check [config](#config) for more information. ### Config -you can config using the environment variables +There are 3 config levels when using sv4git: [default](#default), [user](#user), [repository](#repository). All 3 are merged using the follow priority: **repository > user > default**. -| Variable | description | default | -| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| SV4GIT_MAJOR_VERSION_TYPES | types used to bump major version | | -| SV4GIT_MINOR_VERSION_TYPES | types used to bump minor version | feat | -| SV4GIT_PATCH_VERSION_TYPES | types used to bump patch version | build,ci,chore,docs,fix,perf,refactor,style,test | -| SV4GIT_INCLUDE_UNKNOWN_TYPE_AS_PATCH | force patch bump on unknown type | true | -| SV4GIT_BRAKING_CHANGE_PREFIXES | list of prefixes that will be used to identify a breaking change | BREAKING CHANGE:,BREAKING CHANGES: | -| SV4GIT_ISSUEID_PREFIXES | list of prefixes that will be used to identify an issue id | jira:,JIRA:,Jira: | -| SV4GIT_TAG_PATTERN | tag version pattern | %d.%d.%d | -| SV4GIT_RELEASE_NOTES_TAGS | release notes headers for each visible type | fix:Bug Fixes,feat:Features | -| SV4GIT_VALIDATE_MESSAGE_SKIP_BRANCHES | ignore branches from this list on validate commit message | master,develop | -| SV4GIT_COMMIT_MESSAGE_TYPES | list of valid commit types for commit message | build,ci,chore,docs,feat,fix,perf,refactor,revert,style,test | -| SV4GIT_ISSUE_KEY_NAME | metadata key name used on validate commit message hook to enhance footer, if blank footer will not be added | jira | -| SV4GIT_ISSUE_REGEX | issue id regex, if blank footer will not be added | [A-Z]+-[0-9]+ | -| SV4GIT_BRANCH_ISSUE_REGEX | regex to extract issue id from branch name, must have 3 groups (prefix, id, posfix), if blank footer will not be added | ^([a-z]+\\/)?([A-Z]+-[0-9]+)(-.*)? | +To see current config, run: + +```bash +git sv cfg show +``` + +#### Configuration types + +##### Default + +To check what is the default configuration, run: + +```bash +git sv cfg default +``` + +##### User + +To configure define `SV4GIT_HOME` environment variable, eg.: + +```bash +SV4GIT_HOME=/home/myuser/.sv4git # myuser is just an example +``` + +And define the `config.yml` inside it, eg: + +```bash +.sv4git +└── config.yml +``` + +##### Repository + +Create a `.sv4git.yml` on the root of your repository, eg.: [.sv4git.yml](.sv4git.yml) + +#### Configuration format + +```yml +version: "1.0" #config version + +versioning: # versioning bump + update-major: [] # commit types used to bump major + update-minor: # commit types used to bump minor + - feat + update-patch: # commit types used to bump patch + - build + - ci + - chore + - docs + - fix + - perf + - refactor + - style + - test + # when type is not present on update rules and is unknown (not mapped on commit message types), + # if ignore-unknown=false bump patch, if ignore-unknown=true do not bump version + ignore-unknown: false + +tag: + pattern: '%d.%d.%d' # pattern used to create git tag + +release-notes: + headers: # headers names for relase notes markdown, to disable a section, just remove the header line + breaking-change: Breaking Changes + feat: Features + fix: Bug Fixes + +branches: # git branches config + prefix: ([a-z]+\/)? # prefix used on branch name, should be a regex group + suffix: (-.*)? # suffix used on branch name, should be a regex group + disable-issue: false # set true if there is no need to recover issue id from branch name + skip: # list of branch names ignored on commit message validation + - master + - main + - developer + +commit-message: + types: # supported commit types + - build + - ci + - chore + - docs + - feat + - fix + - perf + - refactor + - revert + - style + - test + scope: + # define supported scopes, if blank, scope will not be validated, if not, only scope listed will be valid. + # don't forget to add "" on your list if you need to define scopes and keep it optional + values: [] + footer: + issue: + key: jira # name used to define an issue on footer metadata + key-synonyms: # supported variations for footer metadata + - Jira + - JIRA + use-hash: false # if false, use : separator, if true, use # separator + issue: + regex: '[A-Z]+-[0-9]+' # regex for issue id +``` ### Running @@ -48,7 +146,7 @@ git sv next-version #### Usage -use `--help` or `-h` to get usage information, dont forget that some commands have unique options too +use `--help` or `-h` to get usage information, don't forget that some commands have unique options too ```bash # sv help @@ -60,18 +158,19 @@ git-sv rn -h ##### Available commands -| 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 according to range as jsons | :heavy_check_mark: | -| commit-notes, cn | generate a commit notes according to range | :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: | -| commit, cmt | execute git commit with convetional commit message helper | :x: | -| validate-commit-message, vcm | use as prepare-commit-message hook to validate commit message | :heavy_check_mark: | -| help, h | shows a list of commands or help for one command | :x: | +| Variable | description | has options or subcommands | +| ---------------------------- | ------------------------------------------------------------- | :------------------------: | +| config, cfg | show config information | :heavy_check_mark: | +| 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 according to range as jsons | :heavy_check_mark: | +| commit-notes, cn | generate a commit notes according to range | :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: | +| commit, cmt | execute git commit with convetional commit message helper | :x: | +| validate-commit-message, vcm | use as prepare-commit-message hook to validate commit message | :heavy_check_mark: | +| help, h | shows a list of commands or help for one command | :x: | ##### Use range diff --git a/cmd/git-sv/config.go b/cmd/git-sv/config.go index 51421bb..42f8e98 100644 --- a/cmd/git-sv/config.go +++ b/cmd/git-sv/config.go @@ -1,33 +1,90 @@ package main import ( + "errors" + "fmt" + "io/ioutil" "log" + "os/exec" + "strings" + "sv4git/sv" "github.com/kelseyhightower/envconfig" + "gopkg.in/yaml.v3" ) -// Config env vars for cli configuration -type Config struct { - MajorVersionTypes []string `envconfig:"MAJOR_VERSION_TYPES" default:""` - MinorVersionTypes []string `envconfig:"MINOR_VERSION_TYPES" default:"feat"` - PatchVersionTypes []string `envconfig:"PATCH_VERSION_TYPES" default:"build,ci,chore,docs,fix,perf,refactor,style,test"` - IncludeUnknownTypeAsPatch bool `envconfig:"INCLUDE_UNKNOWN_TYPE_AS_PATCH" default:"true"` - BreakingChangePrefixes []string `envconfig:"BRAKING_CHANGE_PREFIXES" default:"BREAKING CHANGE:,BREAKING CHANGES:"` - IssueIDPrefixes []string `envconfig:"ISSUEID_PREFIXES" default:"jira:,JIRA:,Jira:"` - TagPattern string `envconfig:"TAG_PATTERN" default:"%d.%d.%d"` - ReleaseNotesTags map[string]string `envconfig:"RELEASE_NOTES_TAGS" default:"fix:Bug Fixes,feat:Features"` - ValidateMessageSkipBranches []string `envconfig:"VALIDATE_MESSAGE_SKIP_BRANCHES" default:"master,develop"` - CommitMessageTypes []string `envconfig:"COMMIT_MESSAGE_TYPES" default:"build,ci,chore,docs,feat,fix,perf,refactor,revert,style,test"` - IssueKeyName string `envconfig:"ISSUE_KEY_NAME" default:"jira"` - IssueRegex string `envconfig:"ISSUE_REGEX" default:"[A-Z]+-[0-9]+"` - BranchIssueRegex string `envconfig:"BRANCH_ISSUE_REGEX" default:"^([a-z]+\\/)?([A-Z]+-[0-9]+)(-.*)?"` //TODO breaking change: use issue regex instead of duplicating issue regex +// EnvConfig env vars for cli configuration +type EnvConfig struct { + Home string `envconfig:"SV4GIT_HOME" default:""` } -func loadConfig() Config { - var c Config - err := envconfig.Process("SV4GIT", &c) +func loadEnvConfig() EnvConfig { + var c EnvConfig + err := envconfig.Process("", &c) if err != nil { log.Fatal(err.Error()) } return c } + +// Config cli yaml config +type Config struct { + Version string `yaml:"version"` + Versioning sv.VersioningConfig `yaml:"versioning"` + Tag sv.TagConfig `yaml:"tag"` + ReleaseNotes sv.ReleaseNotesConfig `yaml:"release-notes"` + Branches sv.BranchesConfig `yaml:"branches"` + CommitMessage sv.CommitMessageConfig `yaml:"commit-message"` +} + +func getRepoPath() (string, error) { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + out, err := cmd.CombinedOutput() + if err != nil { + return "", errors.New(string(out)) + } + return strings.TrimSpace(string(out)), nil +} + +func loadConfig(filepath string) (Config, error) { + content, rerr := ioutil.ReadFile(filepath) + if rerr != nil { + return Config{}, rerr + } + + var cfg Config + cerr := yaml.Unmarshal(content, &cfg) + if cerr != nil { + return Config{}, fmt.Errorf("could not parse config from path: %s, error: %v", filepath, cerr) + } + + return cfg, nil +} + +func defaultConfig() Config { + return Config{ + Version: "1.0", + Versioning: sv.VersioningConfig{ + UpdateMajor: []string{}, + UpdateMinor: []string{"feat"}, + UpdatePatch: []string{"build", "ci", "chore", "docs", "fix", "perf", "refactor", "style", "test"}, + IgnoreUnknown: false, + }, + Tag: sv.TagConfig{Pattern: "%d.%d.%d"}, + ReleaseNotes: sv.ReleaseNotesConfig{Headers: map[string]string{"fix": "Bug Fixes", "feat": "Features", "breaking-change": "Breaking Changes"}}, + Branches: sv.BranchesConfig{ + PrefixRegex: "([a-z]+\\/)?", + SuffixRegex: "(-.*)?", + DisableIssue: false, + Skip: []string{"master", "main", "developer"}, + }, + CommitMessage: sv.CommitMessageConfig{ + Types: []string{"build", "ci", "chore", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test"}, + Scope: sv.CommitMessageScopeConfig{}, + Footer: map[string]sv.CommitMessageFooterConfig{ + "issue": {Key: "jira", KeySynonyms: []string{"Jira", "JIRA"}}, + }, + Issue: sv.CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"}, + }, + } +} diff --git a/cmd/git-sv/handlers.go b/cmd/git-sv/handlers.go index ccf2f01..206f194 100644 --- a/cmd/git-sv/handlers.go +++ b/cmd/git-sv/handlers.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "os" + "path/filepath" "sort" "strings" "sv4git/sv" @@ -12,8 +13,32 @@ import ( "github.com/Masterminds/semver/v3" "github.com/urfave/cli/v2" + "gopkg.in/yaml.v3" ) +func configDefaultHandler() func(c *cli.Context) error { + cfg := defaultConfig() + return func(c *cli.Context) error { + content, err := yaml.Marshal(&cfg) + if err != nil { + return err + } + fmt.Println(string(content)) + return nil + } +} + +func configShowHandler(cfg Config) func(c *cli.Context) error { + return func(c *cli.Context) error { + content, err := yaml.Marshal(&cfg) + if err != nil { + return err + } + fmt.Println(string(content)) + return nil + } +} + func currentVersionHandler(git sv.Git) func(c *cli.Context) error { return func(c *cli.Context) error { describe := git.Describe() @@ -241,12 +266,12 @@ func tagHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) func(c *c func commitHandler(cfg Config, git sv.Git, messageProcessor sv.MessageProcessor) func(c *cli.Context) error { return func(c *cli.Context) error { - ctype, err := promptType() + ctype, err := promptType(cfg.CommitMessage.Types) if err != nil { return err } - scope, err := promptScope() + scope, err := promptScope(cfg.CommitMessage.Scope.Values) if err != nil { return err } @@ -273,7 +298,7 @@ func commitHandler(cfg Config, git sv.Git, messageProcessor sv.MessageProcessor) if err != nil { return err } - issue, err := promptIssueID(cfg.IssueKeyName, cfg.IssueRegex, branchIssue) + issue, err := promptIssueID(cfg.CommitMessage.IssueFooterConfig().Key, cfg.CommitMessage.Issue.Regex, branchIssue) if err != nil { return err } @@ -290,7 +315,7 @@ func commitHandler(cfg Config, git sv.Git, messageProcessor sv.MessageProcessor) } } - header, body, footer := messageProcessor.Format(ctype.Type, scope, subject, fullBody.String(), issue, breakingChanges) + header, body, footer := messageProcessor.Format(sv.NewCommitMessage(ctype.Type, scope, subject, fullBody.String(), issue, breakingChanges)) err = git.Commit(header, body, footer) if err != nil { @@ -351,7 +376,7 @@ func validateCommitMessageHandler(git sv.Git, messageProcessor sv.MessageProcess return nil } - filepath := fmt.Sprintf("%s/%s", c.String("path"), c.String("file")) + filepath := filepath.Join(c.String("path"), c.String("file")) commitMessage, err := readFile(filepath) if err != nil { diff --git a/cmd/git-sv/main.go b/cmd/git-sv/main.go index 43894ee..d39f5c5 100644 --- a/cmd/git-sv/main.go +++ b/cmd/git-sv/main.go @@ -3,30 +3,78 @@ package main import ( "log" "os" + "path/filepath" "sv4git/sv" + "github.com/imdario/mergo" "github.com/urfave/cli/v2" ) // Version for git-sv var Version = "" +const ( + configFilename = "config.yml" + repoConfigFilename = ".sv4git.yml" +) + func main() { log.SetFlags(0) - cfg := loadConfig() + envCfg := loadEnvConfig() - 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) + cfg := defaultConfig() + + if envCfg.Home != "" { + if homeCfg, err := loadConfig(filepath.Join(envCfg.Home, configFilename)); err == nil { + if merr := mergo.Merge(&cfg, homeCfg, mergo.WithOverride); merr != nil { + log.Fatal(merr) + } + } + } + + repoPath, rerr := getRepoPath() + if rerr != nil { + log.Fatal(rerr) + } + + if repoCfg, err := loadConfig(filepath.Join(repoPath, repoConfigFilename)); err == nil { + if merr := mergo.Merge(&cfg, repoCfg, mergo.WithOverride); merr != nil { + log.Fatal(merr) + } + if len(repoCfg.ReleaseNotes.Headers) > 0 { // mergo is merging maps, headers will be overwritten + cfg.ReleaseNotes.Headers = repoCfg.ReleaseNotes.Headers + } + } + + messageProcessor := sv.NewMessageProcessor(cfg.CommitMessage, cfg.Branches) + git := sv.NewGit(messageProcessor, cfg.Tag) + semverProcessor := sv.NewSemVerCommitsProcessor(cfg.Versioning, cfg.CommitMessage) + releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotes) outputFormatter := sv.NewOutputFormatter() - messageProcessor := sv.NewMessageProcessor(cfg.ValidateMessageSkipBranches, cfg.CommitMessageTypes, cfg.IssueKeyName, cfg.BranchIssueRegex, cfg.IssueRegex) app := cli.NewApp() app.Name = "sv" app.Version = Version app.Usage = "semantic version for git" app.Commands = []*cli.Command{ + { + Name: "config", + Aliases: []string{"cfg"}, + Usage: "cli configuration", + Subcommands: []*cli.Command{ + { + Name: "default", + Usage: "show default config", + Action: configDefaultHandler(), + }, + { + Name: "show", + Usage: "show current config", + Action: configShowHandler(cfg), + }, + }, + }, { Name: "current-version", Aliases: []string{"cv"}, diff --git a/cmd/git-sv/prompt.go b/cmd/git-sv/prompt.go index 0f935fd..b43166e 100644 --- a/cmd/git-sv/prompt.go +++ b/cmd/git-sv/prompt.go @@ -14,18 +14,28 @@ type commitType struct { Example string } -func promptType() (commitType, error) { - items := []commitType{ - {Type: "build", Description: "changes that affect the build system or external dependencies", Example: "gradle, maven, go mod, npm"}, - {Type: "ci", Description: "changes to our CI configuration files and scripts", Example: "Circle, BrowserStack, SauceLabs"}, - {Type: "chore", Description: "update something without impacting the user", Example: "gitignore"}, - {Type: "docs", Description: "documentation only changes"}, - {Type: "feat", Description: "a new feature"}, - {Type: "fix", Description: "a bug fix"}, - {Type: "perf", Description: "a code change that improves performance"}, - {Type: "refactor", Description: "a code change that neither fixes a bug nor adds a feature"}, - {Type: "style", Description: "changes that do not affect the meaning of the code", Example: "white-space, formatting, missing semi-colons, etc"}, - {Type: "test", Description: "adding missing tests or correcting existing tests"}, +func promptType(types []string) (commitType, error) { + defaultTypes := map[string]commitType{ + "build": {Type: "build", Description: "changes that affect the build system or external dependencies", Example: "gradle, maven, go mod, npm"}, + "ci": {Type: "ci", Description: "changes to our CI configuration files and scripts", Example: "Circle, BrowserStack, SauceLabs"}, + "chore": {Type: "chore", Description: "update something without impacting the user", Example: "gitignore"}, + "docs": {Type: "docs", Description: "documentation only changes"}, + "feat": {Type: "feat", Description: "a new feature"}, + "fix": {Type: "fix", Description: "a bug fix"}, + "perf": {Type: "perf", Description: "a code change that improves performance"}, + "refactor": {Type: "refactor", Description: "a code change that neither fixes a bug nor adds a feature"}, + "style": {Type: "style", Description: "changes that do not affect the meaning of the code", Example: "white-space, formatting, missing semi-colons, etc"}, + "test": {Type: "test", Description: "adding missing tests or correcting existing tests"}, + "revert": {Type: "revert", Description: "revert a single commit"}, + } + + var items []commitType + for _, t := range types { + if v, exists := defaultTypes[t]; exists { + items = append(items, v) + } else { + items = append(items, commitType{Type: t}) + } } template := &promptui.SelectTemplates{ @@ -46,7 +56,14 @@ func promptType() (commitType, error) { return items[i], nil } -func promptScope() (string, error) { +func promptScope(values []string) (string, error) { + if len(values) > 0 { + selected, err := promptSelect("scope", values, nil) + if err != nil { + return "", err + } + return values[selected], nil + } return promptText("scope", "^[a-z0-9-]*$", "") } diff --git a/go.mod b/go.mod index d39381e..3f98cf5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.15 require ( github.com/Masterminds/semver/v3 v3.1.1 github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect + github.com/imdario/mergo v0.3.11 github.com/kelseyhightower/envconfig v1.4.0 github.com/kr/text v0.2.0 // indirect github.com/lunixbochs/vtclean v1.0.0 // indirect @@ -14,4 +15,5 @@ require ( github.com/urfave/cli/v2 v2.3.0 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/go.sum b/go.sum index b040cff..d0a91e1 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= +github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= @@ -61,3 +63,7 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sv/config.go b/sv/config.go new file mode 100644 index 0000000..cba5234 --- /dev/null +++ b/sv/config.go @@ -0,0 +1,70 @@ +package sv + +// ==== Message ==== + +// CommitMessageConfig config a commit message. +type CommitMessageConfig struct { + Types []string `yaml:"types"` + Scope CommitMessageScopeConfig `yaml:"scope"` + Footer map[string]CommitMessageFooterConfig `yaml:"footer"` + Issue CommitMessageIssueConfig `yaml:"issue"` +} + +// IssueFooterConfig config for issue. +func (c CommitMessageConfig) IssueFooterConfig() CommitMessageFooterConfig { + if v, exists := c.Footer[issueMetadataKey]; exists { + return v + } + return CommitMessageFooterConfig{} +} + +// CommitMessageScopeConfig config scope preferences. +type CommitMessageScopeConfig struct { + Values []string `yaml:"values"` +} + +// CommitMessageFooterConfig config footer metadata. +type CommitMessageFooterConfig struct { + Key string `yaml:"key"` + KeySynonyms []string `yaml:"key-synonyms"` + UseHash bool `yaml:"use-hash"` +} + +// CommitMessageIssueConfig issue preferences. +type CommitMessageIssueConfig struct { + Regex string `yaml:"regex"` +} + +// ==== Branches ==== + +// BranchesConfig branches preferences. +type BranchesConfig struct { + PrefixRegex string `yaml:"prefix"` + SuffixRegex string `yaml:"suffix"` + DisableIssue bool `yaml:"disable-issue"` + Skip []string `yaml:"skip"` +} + +// ==== Versioning ==== + +// VersioningConfig versioning preferences. +type VersioningConfig struct { + UpdateMajor []string `yaml:"update-major"` + UpdateMinor []string `yaml:"update-minor"` + UpdatePatch []string `yaml:"update-patch"` + IgnoreUnknown bool `yaml:"ignore-unknown"` +} + +// ==== Tag ==== + +// TagConfig tag preferences. +type TagConfig struct { + Pattern string `yaml:"pattern"` +} + +// ==== Release Notes ==== + +// ReleaseNotesConfig release notes preferences. +type ReleaseNotesConfig struct { + Headers map[string]string `yaml:"headers"` +} diff --git a/sv/formatter.go b/sv/formatter.go index c0d7e05..45d32c9 100644 --- a/sv/formatter.go +++ b/sv/formatter.go @@ -10,7 +10,7 @@ type releaseNoteTemplateVariables struct { Version string Date string Sections map[string]ReleaseNoteSection - BreakingChanges []string + BreakingChanges BreakingChangeSection } const ( @@ -22,7 +22,7 @@ const ( {{- end}} ` - rnSectionItem = "- {{if .Scope}}**{{.Scope}}:** {{end}}{{.Subject}} ({{.Hash}}){{if .Metadata.issueid}} ({{.Metadata.issueid}}){{end}}" + rnSectionItem = "- {{if .Message.Scope}}**{{.Message.Scope}}:** {{end}}{{.Message.Description}} ({{.Hash}}){{if .Message.Metadata.issue}} ({{.Message.Metadata.issue}}){{end}}" rnSection = `{{- if .}} @@ -32,10 +32,10 @@ const ( {{- end}} {{- end}}` - rnSectionBreakingChanges = `{{- if .}} + rnSectionBreakingChanges = `{{- if ne .Name ""}} -### Breaking Changes -{{range $k,$v := .}} +### {{.Name}} +{{range $k,$v := .Messages}} - {{$v}} {{- end}} {{- end}}` diff --git a/sv/git.go b/sv/git.go index 0fceca2..0a130c7 100644 --- a/sv/git.go +++ b/sv/git.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "os/exec" - "regexp" "strings" "time" @@ -16,11 +15,6 @@ import ( const ( logSeparator = "##" endLine = "~~" - - // BreakingChangesKey key to breaking change metadata - BreakingChangesKey = "breakingchange" - // IssueIDKey key to issue id metadata - IssueIDKey = "issueid" ) // Git commands @@ -35,13 +29,9 @@ type Git interface { // GitCommitLog description of a single commit log type GitCommitLog struct { - Date string `json:"date,omitempty"` - Hash string `json:"hash,omitempty"` - Type string `json:"type,omitempty"` - Scope string `json:"scope,omitempty"` - Subject string `json:"subject,omitempty"` - Body string `json:"body,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` + Date string `json:"date,omitempty"` + Hash string `json:"hash,omitempty"` + Message CommitMessage `json:"message,omitempty"` } // GitTag git tag info @@ -74,15 +64,15 @@ func NewLogRange(t LogRangeType, start, end string) LogRange { // GitImpl git command implementation type GitImpl struct { - messageMetadata map[string][]string - tagPattern string + messageProcessor MessageProcessor + tagCfg TagConfig } // NewGit constructor -func NewGit(breakinChangePrefixes, issueIDPrefixes []string, tagPattern string) *GitImpl { +func NewGit(messageProcessor MessageProcessor, cfg TagConfig) *GitImpl { return &GitImpl{ - messageMetadata: map[string][]string{BreakingChangesKey: breakinChangePrefixes, IssueIDKey: issueIDPrefixes}, - tagPattern: tagPattern, + messageProcessor: messageProcessor, + tagCfg: cfg, } } @@ -119,7 +109,7 @@ func (g GitImpl) Log(lr LogRange) ([]GitCommitLog, error) { if err != nil { return nil, combinedOutputErr(err, out) } - return parseLogOutput(g.messageMetadata, string(out)), nil + return parseLogOutput(g.messageProcessor, string(out)), nil } // Commit runs git commit @@ -132,7 +122,7 @@ func (g GitImpl) Commit(header, body, footer string) error { // Tag create a git tag func (g GitImpl) Tag(version semver.Version) error { - tag := fmt.Sprintf(g.tagPattern, version.Major(), version.Minor(), version.Patch()) + tag := fmt.Sprintf(g.tagCfg.Pattern, version.Major(), version.Minor(), version.Patch()) tagMsg := fmt.Sprintf("Version %d.%d.%d", version.Major(), version.Minor(), version.Patch()) tagCommand := exec.Command("git", "tag", "-a", tag, "-m", tagMsg) @@ -177,61 +167,28 @@ func parseTagsOutput(input string) ([]GitTag, error) { return result, nil } -func parseLogOutput(messageMetadata map[string][]string, log string) []GitCommitLog { +func parseLogOutput(messageProcessor MessageProcessor, log string) []GitCommitLog { scanner := bufio.NewScanner(strings.NewReader(log)) scanner.Split(splitAt([]byte(endLine))) var logs []GitCommitLog for scanner.Scan() { if text := strings.TrimSpace(strings.Trim(scanner.Text(), "\"")); text != "" { - logs = append(logs, parseCommitLog(messageMetadata, text)) + logs = append(logs, parseCommitLog(messageProcessor, text)) } } return logs } -func parseCommitLog(messageMetadata map[string][]string, commit string) GitCommitLog { +func parseCommitLog(messageProcessor MessageProcessor, commit string) GitCommitLog { content := strings.Split(strings.Trim(commit, "\""), logSeparator) - commitType, scope, subject := parseCommitLogMessage(content[2]) - - metadata := make(map[string]string) - for key, prefixes := range messageMetadata { - for _, prefix := range prefixes { - if tagValue := extractTag(prefix, content[3]); tagValue != "" { - metadata[key] = tagValue - break - } - } - } return GitCommitLog{ - Date: content[0], - Hash: content[1], - Type: commitType, - Scope: scope, - Subject: subject, - Body: content[3], - Metadata: metadata, + Date: content[0], + Hash: content[1], + Message: messageProcessor.Parse(content[2], content[3]), } } -func parseCommitLogMessage(message string) (string, string, string) { - regex := regexp.MustCompile("([a-z]+)(\\((.*)\\))?: (.*)") - result := regex.FindStringSubmatch(message) - if len(result) != 5 { - return "", "", message - } - return result[1], result[3], strings.TrimSpace(result[4]) -} - -func extractTag(tag, text string) string { - regex := regexp.MustCompile(tag + " (.*)") - result := regex.FindStringSubmatch(text) - if len(result) < 2 { - return "" - } - return result[1] -} - func splitAt(b []byte) func(data []byte, atEOF bool) (advance int, token []byte, err error) { return func(data []byte, atEOF bool) (advance int, token []byte, err error) { dataLen := len(data) diff --git a/sv/helpers_test.go b/sv/helpers_test.go index f2a3b61..e4fa880 100644 --- a/sv/helpers_test.go +++ b/sv/helpers_test.go @@ -11,20 +11,31 @@ func version(v string) semver.Version { return *r } -func commitlog(t string, metadata map[string]string) GitCommitLog { +func commitlog(ctype string, metadata map[string]string) GitCommitLog { + breaking := false + if _, found := metadata[breakingChangeMetadataKey]; found { + breaking = true + } return GitCommitLog{ - Type: t, - Subject: "subject text", - Metadata: metadata, + Message: CommitMessage{ + Type: ctype, + Description: "subject text", + IsBreakingChange: breaking, + Metadata: metadata, + }, } } func releaseNote(version *semver.Version, date time.Time, sections map[string]ReleaseNoteSection, breakingChanges []string) ReleaseNote { + var bchanges BreakingChangeSection + if len(breakingChanges) > 0 { + bchanges = BreakingChangeSection{Name: "Breaking Changes", Messages: breakingChanges} + } return ReleaseNote{ Version: version, Date: date.Truncate(time.Minute), Sections: sections, - BreakingChanges: breakingChanges, + BreakingChanges: bchanges, } } diff --git a/sv/message.go b/sv/message.go index 25b75e2..2208b06 100644 --- a/sv/message.go +++ b/sv/message.go @@ -2,12 +2,49 @@ package sv import ( "bufio" + "errors" "fmt" "regexp" "strings" ) -const breakingChangeKey = "BREAKING CHANGE" +const ( + breakingChangeFooterKey = "BREAKING CHANGE" + breakingChangeMetadataKey = "breaking-change" + issueMetadataKey = "issue" +) + +// CommitMessage is a message using conventional commits. +type CommitMessage struct { + Type string `json:"type,omitempty"` + Scope string `json:"scope,omitempty"` + Description string `json:"description,omitempty"` + Body string `json:"body,omitempty"` + IsBreakingChange bool `json:"isBreakingChange,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// NewCommitMessage commit message constructor +func NewCommitMessage(ctype, scope, description, body, issue, breakingChanges string) CommitMessage { + metadata := make(map[string]string) + if issue != "" { + metadata[issueMetadataKey] = issue + } + if breakingChanges != "" { + metadata[breakingChangeMetadataKey] = breakingChanges + } + return CommitMessage{Type: ctype, Scope: scope, Description: description, Body: body, IsBreakingChange: breakingChanges != "", Metadata: metadata} +} + +// Issue return issue from metadata. +func (m CommitMessage) Issue() string { + return m.Metadata[issueMetadataKey] +} + +// BreakingMessage return breaking change message from metadata. +func (m CommitMessage) BreakingMessage() string { + return m.Metadata[breakingChangeMetadataKey] +} // MessageProcessor interface. type MessageProcessor interface { @@ -15,49 +52,52 @@ type MessageProcessor interface { Validate(message string) error Enhance(branch string, message string) (string, error) IssueID(branch string) (string, error) - Format(ctype, scope, subject, body, issue, breakingChanges string) (string, string, string) + Format(msg CommitMessage) (string, string, string) + Parse(subject, body string) CommitMessage } // NewMessageProcessor MessageProcessorImpl constructor -func NewMessageProcessor(skipBranches, supportedTypes []string, issueKeyName, branchIssueRegex, issueRegex string) *MessageProcessorImpl { +func NewMessageProcessor(mcfg CommitMessageConfig, bcfg BranchesConfig) *MessageProcessorImpl { return &MessageProcessorImpl{ - skipBranches: skipBranches, - supportedTypes: supportedTypes, - issueKeyName: issueKeyName, - branchIssueRegex: branchIssueRegex, - issueRegex: issueRegex, + messageCfg: mcfg, + branchesCfg: bcfg, } } // MessageProcessorImpl process validate message hook. type MessageProcessorImpl struct { - skipBranches []string - supportedTypes []string - issueKeyName string - branchIssueRegex string - issueRegex string + messageCfg CommitMessageConfig + branchesCfg BranchesConfig } // SkipBranch check if branch should be ignored. func (p MessageProcessorImpl) SkipBranch(branch string) bool { - return contains(branch, p.skipBranches) + return contains(branch, p.branchesCfg.Skip) } // Validate commit message. func (p MessageProcessorImpl) Validate(message string) error { - valid, err := regexp.MatchString("^("+strings.Join(p.supportedTypes, "|")+")(\\(.+\\))?!?: .*$", firstLine(message)) - if err != nil { - return err + subject, body := splitCommitMessageContent(message) + msg := p.Parse(subject, body) + + if !regexp.MustCompile("^[a-z+]+(\\(.+\\))?!?: .+$").MatchString(subject) { + return errors.New("message should be valid according with conventional commits") } - if !valid { - return fmt.Errorf("message should contain type: %v, and should be valid according with conventional commits", p.supportedTypes) + + if msg.Type == "" || !contains(msg.Type, p.messageCfg.Types) { + return fmt.Errorf("message type should be one of [%v]", strings.Join(p.messageCfg.Types, ", ")) } + + if len(p.messageCfg.Scope.Values) > 0 && !contains(msg.Scope, p.messageCfg.Scope.Values) { + return fmt.Errorf("message scope should one of [%v]", strings.Join(p.messageCfg.Scope.Values, ", ")) + } + return nil } // Enhance add metadata on commit message. func (p MessageProcessorImpl) Enhance(branch string, message string) (string, error) { - if p.branchIssueRegex == "" || p.issueKeyName == "" || hasIssueID(message, p.issueKeyName) { + if p.branchesCfg.DisableIssue || p.messageCfg.IssueFooterConfig().Key == "" || hasIssueID(message, p.messageCfg.IssueFooterConfig()) { return "", nil //enhance disabled } @@ -69,7 +109,7 @@ func (p MessageProcessorImpl) Enhance(branch string, message string) (string, er return "", fmt.Errorf("could not find issue id using configured regex") } - footer := fmt.Sprintf("%s: %s", p.issueKeyName, issue) + footer := fmt.Sprintf("%s: %s", p.messageCfg.IssueFooterConfig().Key, issue) if !hasFooter(message) { return "\n" + footer, nil @@ -78,11 +118,12 @@ func (p MessageProcessorImpl) Enhance(branch string, message string) (string, er return footer, nil } -// IssueID try to extract issue id from branch, return empty if not found +// IssueID try to extract issue id from branch, return empty if not found. func (p MessageProcessorImpl) IssueID(branch string) (string, error) { - r, err := regexp.Compile(p.branchIssueRegex) + rstr := fmt.Sprintf("^%s(%s)%s$", p.branchesCfg.PrefixRegex, p.messageCfg.Issue.Regex, p.branchesCfg.SuffixRegex) + r, err := regexp.Compile(rstr) if err != nil { - return "", fmt.Errorf("could not compile issue regex: %s, error: %v", p.branchIssueRegex, err.Error()) + return "", fmt.Errorf("could not compile issue regex: %s, error: %v", rstr, err.Error()) } groups := r.FindStringSubmatch(branch) @@ -92,32 +133,89 @@ func (p MessageProcessorImpl) IssueID(branch string) (string, error) { return groups[2], nil } -// Format format commit message to header, body and footer -func (p MessageProcessorImpl) Format(ctype, scope, subject, body, issue, breakingChanges string) (string, string, string) { +// Format a commit message returning header, body and footer. +func (p MessageProcessorImpl) Format(msg CommitMessage) (string, string, string) { var header strings.Builder - header.WriteString(ctype) - if scope != "" { - header.WriteString("(" + scope + ")") + header.WriteString(msg.Type) + if msg.Scope != "" { + header.WriteString("(" + msg.Scope + ")") } header.WriteString(": ") - header.WriteString(subject) + header.WriteString(msg.Description) var footer strings.Builder - if breakingChanges != "" { - footer.WriteString(fmt.Sprintf("%s: %s", breakingChangeKey, breakingChanges)) + if msg.BreakingMessage() != "" { + footer.WriteString(fmt.Sprintf("%s: %s", breakingChangeFooterKey, msg.BreakingMessage())) } - if issue != "" { + if issue, exists := msg.Metadata[issueMetadataKey]; exists { if footer.Len() > 0 { footer.WriteString("\n") } - footer.WriteString(fmt.Sprintf("%s: %s", p.issueKeyName, issue)) + if p.messageCfg.IssueFooterConfig().UseHash { + footer.WriteString(fmt.Sprintf("%s #%s", p.messageCfg.IssueFooterConfig().Key, strings.TrimPrefix(issue, "#"))) + } else { + footer.WriteString(fmt.Sprintf("%s: %s", p.messageCfg.IssueFooterConfig().Key, issue)) + } } - return header.String(), body, footer.String() + return header.String(), msg.Body, footer.String() +} + +// Parse a commit message. +func (p MessageProcessorImpl) Parse(subject, body string) CommitMessage { + commitType, scope, description, hasBreakingChange := parseSubjectMessage(subject) + + metadata := make(map[string]string) + for key, mdCfg := range p.messageCfg.Footer { + prefixes := append([]string{mdCfg.Key}, mdCfg.KeySynonyms...) + for _, prefix := range prefixes { + if tagValue := extractFooterMetadata(prefix, body, mdCfg.UseHash); tagValue != "" { + metadata[key] = tagValue + break + } + } + } + if tagValue := extractFooterMetadata(breakingChangeFooterKey, body, false); tagValue != "" { + metadata[breakingChangeMetadataKey] = tagValue + hasBreakingChange = true + } + + return CommitMessage{ + Type: commitType, + Scope: scope, + Description: description, + Body: body, + IsBreakingChange: hasBreakingChange, + Metadata: metadata, + } +} + +func parseSubjectMessage(message string) (string, string, string, bool) { + regex := regexp.MustCompile("([a-z]+)(\\((.*)\\))?(!)?: (.*)") + result := regex.FindStringSubmatch(message) + if len(result) != 6 { + return "", "", message, false + } + return result[1], result[3], strings.TrimSpace(result[5]), result[4] == "!" +} + +func extractFooterMetadata(key, text string, useHash bool) string { + var regex *regexp.Regexp + if useHash { + regex = regexp.MustCompile(key + " (#.*)") + } else { + regex = regexp.MustCompile(key + ": (.*)") + } + + result := regex.FindStringSubmatch(text) + if len(result) < 2 { + return "" + } + return result[1] } func hasFooter(message string) bool { - r := regexp.MustCompile("^[a-zA-Z-]+: .*|^[a-zA-Z-]+ #.*|^" + breakingChangeKey + ": .*") + r := regexp.MustCompile("^[a-zA-Z-]+: .*|^[a-zA-Z-]+ #.*|^" + breakingChangeFooterKey + ": .*") scanner := bufio.NewScanner(strings.NewReader(message)) lines := 0 @@ -131,8 +229,13 @@ func hasFooter(message string) bool { return false } -func hasIssueID(message, issueKeyName string) bool { - r := regexp.MustCompile(fmt.Sprintf("(?m)^%s: .+$", issueKeyName)) +func hasIssueID(message string, issueConfig CommitMessageFooterConfig) bool { + var r *regexp.Regexp + if issueConfig.UseHash { + r = regexp.MustCompile(fmt.Sprintf("(?m)^%s #.+$", issueConfig.Key)) + } else { + r = regexp.MustCompile(fmt.Sprintf("(?m)^%s: .+$", issueConfig.Key)) + } return r.MatchString(message) } @@ -145,6 +248,21 @@ func contains(value string, content []string) bool { return false } -func firstLine(value string) string { - return strings.Split(value, "\n")[0] +func splitCommitMessageContent(content string) (string, string) { + scanner := bufio.NewScanner(strings.NewReader(content)) + + scanner.Scan() + subject := scanner.Text() + + var body strings.Builder + first := true + for scanner.Scan() { + if !first { + body.WriteString("\n") + } + body.WriteString(scanner.Text()) + first = false + } + + return subject, body.String() } diff --git a/sv/message_test.go b/sv/message_test.go index e14fc02..2bdeb2a 100644 --- a/sv/message_test.go +++ b/sv/message_test.go @@ -1,13 +1,35 @@ package sv import ( + "reflect" "testing" ) -const ( - branchIssueRegex = "^([a-z]+\\/)?([A-Z]+-[0-9]+)(-.*)?" - issueRegex = "[A-Z]+-[0-9]+" -) +var ccfg = CommitMessageConfig{ + Types: []string{"feat", "fix"}, + Scope: CommitMessageScopeConfig{}, + Footer: map[string]CommitMessageFooterConfig{ + "issue": {Key: "jira", KeySynonyms: []string{"Jira"}}, + "refs": {Key: "Refs", UseHash: true}, + }, + Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"}, +} + +var ccfgWithScope = CommitMessageConfig{ + Types: []string{"feat", "fix"}, + Scope: CommitMessageScopeConfig{Values: []string{"", "scope"}}, + Footer: map[string]CommitMessageFooterConfig{ + "issue": {Key: "jira", KeySynonyms: []string{"Jira"}}, + "refs": {Key: "Refs", UseHash: true}, + }, + Issue: CommitMessageIssueConfig{Regex: "[A-Z]+-[0-9]+"}, +} + +var bcfg = BranchesConfig{ + PrefixRegex: "([a-z]+\\/)?", + SuffixRegex: "(-.*)?", + Skip: []string{"develop", "master"}, +} // messages samples start var fullMessage = `fix: correct minor typos in code @@ -46,31 +68,33 @@ BREAKING CHANGE: refactor to use JavaScript features not available in Node 6.` // multiline samples end func TestMessageProcessorImpl_Validate(t *testing.T) { - p := NewMessageProcessor([]string{"develop", "master"}, []string{"feat", "fix"}, "jira", branchIssueRegex, issueRegex) - tests := []struct { name string + cfg CommitMessageConfig message string wantErr bool }{ - {"single line valid message", "feat: add something", false}, - {"single line valid message with scope", "feat(scope): add something", false}, - {"single line invalid type message", "something: add something", true}, - {"single line invalid type message", "feat?: add something", true}, + {"single line valid message", ccfg, "feat: add something", false}, + {"single line valid message with scope", ccfg, "feat(scope): add something", false}, + {"single line valid scope from list", ccfgWithScope, "feat(scope): add something", false}, + {"single line invalid scope from list", ccfgWithScope, "feat(invalid): add something", true}, + {"single line invalid type message", ccfg, "something: add something", true}, + {"single line invalid type message", ccfg, "feat?: add something", true}, - {"multi line valid message", `feat: add something + {"multi line valid message", ccfg, `feat: add something team: x`, false}, - {"multi line invalid message", `feat add something + {"multi line invalid message", ccfg, `feat add something team: x`, true}, - {"support ! for breaking change", "feat!: add something", false}, - {"support ! with scope for breaking change", "feat(scope)!: add something", false}, + {"support ! for breaking change", ccfg, "feat!: add something", false}, + {"support ! with scope for breaking change", ccfg, "feat(scope)!: add something", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + p := NewMessageProcessor(tt.cfg, bcfg) if err := p.Validate(tt.message); (err != nil) != tt.wantErr { t.Errorf("MessageProcessorImpl.Validate() error = %v, wantErr %v", err, tt.wantErr) } @@ -79,7 +103,7 @@ func TestMessageProcessorImpl_Validate(t *testing.T) { } func TestMessageProcessorImpl_Enhance(t *testing.T) { - p := NewMessageProcessor([]string{"develop", "master"}, []string{"feat", "fix"}, "jira", branchIssueRegex, issueRegex) + p := NewMessageProcessor(ccfg, bcfg) tests := []struct { name string @@ -112,7 +136,7 @@ func TestMessageProcessorImpl_Enhance(t *testing.T) { } func TestMessageProcessorImpl_IssueID(t *testing.T) { - p := NewMessageProcessor([]string{"develop", "master"}, []string{"feat", "fix"}, "jira", branchIssueRegex, issueRegex) + p := NewMessageProcessor(ccfg, bcfg) tests := []struct { name string @@ -149,89 +173,33 @@ c` jira: JIRA-123` ) -func TestMessageProcessorImpl_Format(t *testing.T) { - p := NewMessageProcessor([]string{"develop", "master"}, []string{"feat", "fix"}, "jira", branchIssueRegex, issueRegex) - - type args struct { - ctype string - scope string - subject string - body string - issue string - breakingChanges string - } - tests := []struct { - name string - args args - wantHeader string - wantBody string - wantFooter string - }{ - {"type and subject", args{"feat", "", "subject", "", "", ""}, "feat: subject", "", ""}, - {"type, scope and subject", args{"feat", "scope", "subject", "", "", ""}, "feat(scope): subject", "", ""}, - {"type, scope, subject and issue", args{"feat", "scope", "subject", "", "JIRA-123", ""}, "feat(scope): subject", "", "jira: JIRA-123"}, - {"type, scope, subject and breaking change", args{"feat", "scope", "subject", "", "", "breaks"}, "feat(scope): subject", "", "BREAKING CHANGE: breaks"}, - {"full message", args{"feat", "scope", "subject", multilineBody, "JIRA-123", "breaks"}, "feat(scope): subject", multilineBody, fullFooter}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - header, body, footer := p.Format(tt.args.ctype, tt.args.scope, tt.args.subject, tt.args.body, tt.args.issue, tt.args.breakingChanges) - if header != tt.wantHeader { - t.Errorf("MessageProcessorImpl.Format() header = %v, want %v", header, tt.wantHeader) - } - if body != tt.wantBody { - t.Errorf("MessageProcessorImpl.Format() body = %v, want %v", body, tt.wantBody) - } - if footer != tt.wantFooter { - t.Errorf("MessageProcessorImpl.Format() footer = %v, want %v", footer, tt.wantFooter) - } - }) - } -} - -func Test_firstLine(t *testing.T) { - tests := []struct { - name string - value string - want string - }{ - {"empty string", "", ""}, - - {"single line string", "single line", "single line"}, - - {"multi line string", `first line - last line`, "first line"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := firstLine(tt.value); got != tt.want { - t.Errorf("firstLine() = %v, want %v", got, tt.want) - } - }) - } -} - func Test_hasIssueID(t *testing.T) { + cfgColon := CommitMessageFooterConfig{Key: "jira"} + cfgHash := CommitMessageFooterConfig{Key: "jira", UseHash: true} + tests := []struct { - name string - message string - issueKeyName string - want bool + name string + message string + issueCfg CommitMessageFooterConfig + want bool }{ - {"single line without issue", "feat: something", "jira", false}, + {"single line without issue", "feat: something", cfgColon, false}, {"multi line without issue", `feat: something -yay`, "jira", false}, +yay`, cfgColon, false}, {"multi line without jira issue", `feat: something -jira1: JIRA-123`, "jira", false}, +jira1: JIRA-123`, cfgColon, false}, {"multi line with issue", `feat: something -jira: JIRA-123`, "jira", true}, +jira: JIRA-123`, cfgColon, true}, + {"multi line with issue and hash", `feat: something + +jira #JIRA-123`, cfgHash, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := hasIssueID(tt.message, tt.issueKeyName); got != tt.want { + if got := hasIssueID(tt.message, tt.issueCfg); got != tt.want { t.Errorf("hasIssueID() = %v, want %v", got, tt.want) } }) @@ -258,3 +226,149 @@ func Test_hasFooter(t *testing.T) { }) } } + +// conventional commit tests + +var completeBody = `some descriptions + +jira: JIRA-123 +BREAKING CHANGE: this change breaks everything` + +var issueOnlyBody = `some descriptions + +jira: JIRA-456` + +var issueSynonymsBody = `some descriptions + +Jira: JIRA-789` + +var hashMetadataBody = `some descriptions + +Jira: JIRA-999 +Refs #123` + +func TestMessageProcessorImpl_Parse(t *testing.T) { + p := NewMessageProcessor(ccfg, bcfg) + + tests := []struct { + name string + subject string + body string + want CommitMessage + }{ + {"simple message", "feat: something awesome", "", CommitMessage{Type: "feat", Scope: "", Description: "something awesome", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}}, + {"message with scope", "feat(scope): something awesome", "", CommitMessage{Type: "feat", Scope: "scope", Description: "something awesome", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}}, + {"unmapped type", "unkn: something unknown", "", CommitMessage{Type: "unkn", Scope: "", Description: "something unknown", Body: "", IsBreakingChange: false, Metadata: map[string]string{}}}, + {"jira and breaking change metadata", "feat: something new", completeBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: completeBody, IsBreakingChange: true, Metadata: map[string]string{issueMetadataKey: "JIRA-123", breakingChangeMetadataKey: "this change breaks everything"}}}, + {"jira only metadata", "feat: something new", issueOnlyBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: issueOnlyBody, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-456"}}}, + {"jira synonyms metadata", "feat: something new", issueSynonymsBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: issueSynonymsBody, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-789"}}}, + {"breaking change with exclamation mark", "feat!: something new", "", CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: "", IsBreakingChange: true, Metadata: map[string]string{}}}, + {"hash metadata", "feat: something new", hashMetadataBody, CommitMessage{Type: "feat", Scope: "", Description: "something new", Body: hashMetadataBody, IsBreakingChange: false, Metadata: map[string]string{issueMetadataKey: "JIRA-999", "refs": "#123"}}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := p.Parse(tt.subject, tt.body); !reflect.DeepEqual(got, tt.want) { + t.Errorf("MessageProcessorImpl.Parse() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMessageProcessorImpl_Format(t *testing.T) { + p := NewMessageProcessor(ccfg, bcfg) + + tests := []struct { + name string + msg CommitMessage + wantHeader string + wantBody string + wantFooter string + }{ + {"simple message", NewCommitMessage("feat", "", "something", "", "", ""), "feat: something", "", ""}, + {"with issue", NewCommitMessage("feat", "", "something", "", "JIRA-123", ""), "feat: something", "", "jira: JIRA-123"}, + {"with breaking change", NewCommitMessage("feat", "", "something", "", "", "breaks"), "feat: something", "", "BREAKING CHANGE: breaks"}, + {"with scope", NewCommitMessage("feat", "scope", "something", "", "", ""), "feat(scope): something", "", ""}, + {"with body", NewCommitMessage("feat", "", "something", "body", "", ""), "feat: something", "body", ""}, + {"with multiline body", NewCommitMessage("feat", "", "something", multilineBody, "", ""), "feat: something", multilineBody, ""}, + {"full message", NewCommitMessage("feat", "scope", "something", multilineBody, "JIRA-123", "breaks"), "feat(scope): something", multilineBody, fullFooter}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1, got2 := p.Format(tt.msg) + if got != tt.wantHeader { + t.Errorf("MessageProcessorImpl.Format() header got = %v, want %v", got, tt.wantHeader) + } + if got1 != tt.wantBody { + t.Errorf("MessageProcessorImpl.Format() body got = %v, want %v", got1, tt.wantBody) + } + if got2 != tt.wantFooter { + t.Errorf("MessageProcessorImpl.Format() footer got = %v, want %v", got2, tt.wantFooter) + } + }) + } +} + +var expectedBodyFullMessage = ` +see the issue for details + +on typos fixed. + +Reviewed-by: Z +Refs #133` + +func Test_splitCommitMessageContent(t *testing.T) { + tests := []struct { + name string + content string + wantSubject string + wantBody string + }{ + {"single line commit", "feat: something", "feat: something", ""}, + {"multi line commit", fullMessage, "fix: correct minor typos in code", expectedBodyFullMessage}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1 := splitCommitMessageContent(tt.content) + if got != tt.wantSubject { + t.Errorf("splitCommitMessageContent() subject got = %v, want %v", got, tt.wantSubject) + } + if got1 != tt.wantBody { + t.Errorf("splitCommitMessageContent() body got1 = [%v], want [%v]", got1, tt.wantBody) + } + }) + } +} + +//commitType, scope, description, hasBreakingChange +func Test_parseSubjectMessage(t *testing.T) { + tests := []struct { + name string + message string + wantType string + wantScope string + wantDescription string + wantHasBreakingChange bool + }{ + {"valid commit", "feat: something", "feat", "", "something", false}, + {"valid commit with scope", "feat(scope): something", "feat", "scope", "something", false}, + {"valid commit with breaking change", "feat(scope)!: something", "feat", "scope", "something", true}, + {"missing description", "feat: ", "feat", "", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctype, scope, description, hasBreakingChange := parseSubjectMessage(tt.message) + if ctype != tt.wantType { + t.Errorf("parseSubjectMessage() type got = %v, want %v", ctype, tt.wantType) + } + if scope != tt.wantScope { + t.Errorf("parseSubjectMessage() scope got = %v, want %v", scope, tt.wantScope) + } + if description != tt.wantDescription { + t.Errorf("parseSubjectMessage() description got = %v, want %v", description, tt.wantDescription) + } + if hasBreakingChange != tt.wantHasBreakingChange { + t.Errorf("parseSubjectMessage() hasBreakingChange got = %v, want %v", hasBreakingChange, tt.wantHasBreakingChange) + } + }) + } +} diff --git a/sv/releasenotes.go b/sv/releasenotes.go index f75194e..e02c291 100644 --- a/sv/releasenotes.go +++ b/sv/releasenotes.go @@ -13,12 +13,12 @@ type ReleaseNoteProcessor interface { // ReleaseNoteProcessorImpl release note based on commit log. type ReleaseNoteProcessorImpl struct { - tags map[string]string + cfg ReleaseNotesConfig } // NewReleaseNoteProcessor ReleaseNoteProcessor constructor. -func NewReleaseNoteProcessor(tags map[string]string) *ReleaseNoteProcessorImpl { - return &ReleaseNoteProcessorImpl{tags: tags} +func NewReleaseNoteProcessor(cfg ReleaseNotesConfig) *ReleaseNoteProcessorImpl { + return &ReleaseNoteProcessorImpl{cfg: cfg} } // Create create a release note based on commits. @@ -26,20 +26,25 @@ func (p ReleaseNoteProcessorImpl) Create(version *semver.Version, date time.Time sections := make(map[string]ReleaseNoteSection) var breakingChanges []string for _, commit := range commits { - if name, exists := p.tags[commit.Type]; exists { - section, sexists := sections[commit.Type] + if name, exists := p.cfg.Headers[commit.Message.Type]; exists { + section, sexists := sections[commit.Message.Type] if !sexists { section = ReleaseNoteSection{Name: name} } section.Items = append(section.Items, commit) - sections[commit.Type] = section + sections[commit.Message.Type] = section } - if value, exists := commit.Metadata[BreakingChangesKey]; exists { - breakingChanges = append(breakingChanges, value) + if commit.Message.BreakingMessage() != "" { + // TODO: if no message found, should use description instead? + breakingChanges = append(breakingChanges, commit.Message.BreakingMessage()) } } - return ReleaseNote{Version: version, Date: date.Truncate(time.Minute), Sections: sections, BreakingChanges: breakingChanges} + var breakingChangeSection BreakingChangeSection + if name, exists := p.cfg.Headers[breakingChangeMetadataKey]; exists && len(breakingChanges) > 0 { + breakingChangeSection = BreakingChangeSection{Name: name, Messages: breakingChanges} + } + return ReleaseNote{Version: version, Date: date.Truncate(time.Minute), Sections: sections, BreakingChanges: breakingChangeSection} } // ReleaseNote release note. @@ -47,7 +52,13 @@ type ReleaseNote struct { Version *semver.Version Date time.Time Sections map[string]ReleaseNoteSection - BreakingChanges []string + BreakingChanges BreakingChangeSection +} + +// BreakingChangeSection breaking change section +type BreakingChangeSection struct { + Name string + Messages []string } // ReleaseNoteSection release note section. diff --git a/sv/releasenotes_test.go b/sv/releasenotes_test.go index f47fff7..9bf51f9 100644 --- a/sv/releasenotes_test.go +++ b/sv/releasenotes_test.go @@ -36,13 +36,13 @@ func TestReleaseNoteProcessorImpl_Create(t *testing.T) { 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"})}, + commits: []GitCommitLog{commitlog("t1", map[string]string{}), commitlog("unmapped", map[string]string{"breaking-change": "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"}) + p := NewReleaseNoteProcessor(ReleaseNotesConfig{Headers: map[string]string{"t1": "Tag 1", "t2": "Tag 2", "breaking-change": "Breaking Changes"}}) 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 faa840c..c3648e1 100644 --- a/sv/semver.go +++ b/sv/semver.go @@ -34,16 +34,18 @@ type SemVerCommitsProcessorImpl struct { MajorVersionTypes map[string]struct{} MinorVersionTypes map[string]struct{} PatchVersionTypes map[string]struct{} + KnownTypes []string IncludeUnknownTypeAsPatch bool } // NewSemVerCommitsProcessor SemanticVersionCommitsProcessorImpl constructor -func NewSemVerCommitsProcessor(unknownAsPatch bool, majorTypes, minorTypes, patchTypes []string) *SemVerCommitsProcessorImpl { +func NewSemVerCommitsProcessor(vcfg VersioningConfig, mcfg CommitMessageConfig) *SemVerCommitsProcessorImpl { return &SemVerCommitsProcessorImpl{ - IncludeUnknownTypeAsPatch: unknownAsPatch, - MajorVersionTypes: toMap(majorTypes), - MinorVersionTypes: toMap(minorTypes), - PatchVersionTypes: toMap(patchTypes), + IncludeUnknownTypeAsPatch: !vcfg.IgnoreUnknown, + MajorVersionTypes: toMap(vcfg.UpdateMajor), + MinorVersionTypes: toMap(vcfg.UpdateMinor), + PatchVersionTypes: toMap(vcfg.UpdatePatch), + KnownTypes: mcfg.Types, } } @@ -69,19 +71,19 @@ func (p SemVerCommitsProcessorImpl) NextVersion(version semver.Version, commits } func (p SemVerCommitsProcessorImpl) versionTypeToUpdate(commit GitCommitLog) versionType { - if _, exists := commit.Metadata[BreakingChangesKey]; exists { + if commit.Message.IsBreakingChange { return major } - if _, exists := p.MajorVersionTypes[commit.Type]; exists { + if _, exists := p.MajorVersionTypes[commit.Message.Type]; exists { return major } - if _, exists := p.MinorVersionTypes[commit.Type]; exists { + if _, exists := p.MinorVersionTypes[commit.Message.Type]; exists { return minor } - if _, exists := p.PatchVersionTypes[commit.Type]; exists { + if _, exists := p.PatchVersionTypes[commit.Message.Type]; exists { return patch } - if p.IncludeUnknownTypeAsPatch { + if !contains(commit.Message.Type, p.KnownTypes) && p.IncludeUnknownTypeAsPatch { return patch } return none diff --git a/sv/semver_test.go b/sv/semver_test.go index 1f18479..c1a2678 100644 --- a/sv/semver_test.go +++ b/sv/semver_test.go @@ -9,23 +9,24 @@ import ( func TestSemVerCommitsProcessorImpl_NextVersion(t *testing.T) { tests := []struct { - name string - unknownAsPatch bool - version semver.Version - commits []GitCommitLog - want semver.Version + name string + ignoreUnknown bool + version semver.Version + commits []GitCommitLog + want semver.Version }{ - {"no update", false, version("0.0.0"), []GitCommitLog{}, version("0.0.0")}, - {"no update on unknown type", false, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{})}, version("0.0.0")}, - {"update patch on unknown type", true, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{})}, version("0.0.1")}, + {"no update", true, version("0.0.0"), []GitCommitLog{}, version("0.0.0")}, + {"no update on unknown type", true, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{})}, version("0.0.0")}, + {"no update on unmapped known type", false, version("0.0.0"), []GitCommitLog{commitlog("none", map[string]string{})}, version("0.0.0")}, + {"update patch on unknown type", false, version("0.0.0"), []GitCommitLog{commitlog("a", map[string]string{})}, version("0.0.1")}, {"patch update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{})}, version("0.0.1")}, {"minor update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("minor", map[string]string{})}, version("0.1.0")}, {"major update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("major", map[string]string{})}, version("1.0.0")}, - {"breaking change update", false, version("0.0.0"), []GitCommitLog{commitlog("patch", map[string]string{}), commitlog("patch", map[string]string{"breakingchange": "break"})}, version("1.0.0")}, + {"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")}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := NewSemVerCommitsProcessor(tt.unknownAsPatch, []string{"major"}, []string{"minor"}, []string{"patch"}) + p := NewSemVerCommitsProcessor(VersioningConfig{UpdateMajor: []string{"major"}, UpdateMinor: []string{"minor"}, UpdatePatch: []string{"patch"}, IgnoreUnknown: tt.ignoreUnknown}, CommitMessageConfig{Types: []string{"major", "minor", "patch", "none"}}) if got := p.NextVersion(tt.version, tt.commits); !reflect.DeepEqual(got, tt.want) { t.Errorf("SemVerCommitsProcessorImpl.NextVersion() = %v, want %v", got, tt.want) }