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
+
+
+
+
+
+
+
+
+
## 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)
}