diff --git a/README.md b/README.md index b57ef85..8b3883a 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,21 @@ download the latest release and add the binary on your path you can config using the environment variables -| Variable | description | default | -| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | -| MAJOR_VERSION_TYPES | types used to bump major version | | -| MINOR_VERSION_TYPES | types used to bump minor version | feat | -| PATCH_VERSION_TYPES | types used to bump patch version | build,ci,docs,fix,perf,refactor,style,test | -| INCLUDE_UNKNOWN_TYPE_AS_PATCH | force patch bump on unknown type | true | -| BRAKING_CHANGE_PREFIXES | list of prefixes that will be used to identify a breaking change | BREAKING CHANGE:,BREAKING CHANGES: | -| ISSUEID_PREFIXES | list of prefixes that will be used to identify an issue id | jira:,JIRA:,Jira: | -| TAG_PATTERN | tag version pattern | %d.%d.%d | -| RELEASE_NOTES_TAGS | release notes headers for each visible type | fix:Bug Fixes,feat:Features | -| VALIDATE_MESSAGE_SKIP_BRANCHES | ignore branches from this list on validate commit message | master,develop | -| COMMIT_MESSAGE_TYPES | list of valid commit types for commit message | build,ci,chore,docs,feat,fix,perf,refactor,revert,style,test | -| ISSUE_KEY_NAME | metadata key name used on validate commit message hook to enhance footer, if blank footer will not be added | jira | -| 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]+)(-.*)? | +| 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]+)(-.*)? | ### Running @@ -67,8 +68,9 @@ git-sv rn -h | 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: | +| help, h | shows a list of commands or help for one command | :x: | ##### Use validate-commit-message as prepare-commit-msg hook diff --git a/cmd/git-sv/config.go b/cmd/git-sv/config.go index f7c9dae..51421bb 100644 --- a/cmd/git-sv/config.go +++ b/cmd/git-sv/config.go @@ -10,7 +10,7 @@ import ( 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,docs,fix,perf,refactor,style,test"` + 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:"` @@ -19,12 +19,13 @@ type Config struct { 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"` - BranchIssueRegex string `envconfig:"BRANCH_ISSUE_REGEX" default:"^([a-z]+\\/)?([A-Z]+-[0-9]+)(-.*)?"` + 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 } func loadConfig() Config { var c Config - err := envconfig.Process("SV", &c) + err := envconfig.Process("SV4GIT", &c) if err != nil { log.Fatal(err.Error()) } diff --git a/cmd/git-sv/handlers.go b/cmd/git-sv/handlers.go index 25eb19a..a9f5319 100644 --- a/cmd/git-sv/handlers.go +++ b/cmd/git-sv/handlers.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "os" "sort" + "strings" "sv4git/sv" "time" @@ -188,6 +189,68 @@ 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() + if err != nil { + return err + } + + scope, err := promptScope() + if err != nil { + return err + } + + subject, err := promptSubject() + if err != nil { + return err + } + + var fullBody strings.Builder + for body, err := promptBody(); body != "" || err != nil; body, err = promptBody() { + if err != nil { + return err + } + if fullBody.Len() > 0 { + fullBody.WriteString("\n") + } + if body != "" { + fullBody.WriteString(body) + } + } + + branchIssue, err := messageProcessor.IssueID(git.Branch()) + if err != nil { + return err + } + issue, err := promptIssueID(cfg.IssueKeyName, cfg.IssueRegex, branchIssue) + if err != nil { + return err + } + + hasBreakingChanges, err := promptConfirm("has breaking changes?") + if err != nil { + return err + } + breakingChanges := "" + if hasBreakingChanges { + breakingChanges, err = promptBreakingChanges() + if err != nil { + return err + } + } + + header, body, footer := messageProcessor.Format(ctype.Type, scope, subject, fullBody.String(), issue, breakingChanges) + + err = git.Commit(header, body, footer) + if err != nil { + return fmt.Errorf("error executing git commit, message: %v", err) + } + return nil + } +} + func changelogHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor, formatter sv.OutputFormatter) func(c *cli.Context) error { return func(c *cli.Context) error { @@ -231,10 +294,10 @@ func changelogHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnP } } -func validateCommitMessageHandler(git sv.Git, validateMessageProcessor sv.ValidateMessageProcessor) func(c *cli.Context) error { +func validateCommitMessageHandler(git sv.Git, messageProcessor sv.MessageProcessor) func(c *cli.Context) error { return func(c *cli.Context) error { branch := git.Branch() - if validateMessageProcessor.SkipBranch(branch) { + if messageProcessor.SkipBranch(branch) { warn("commit message validation skipped, branch in ignore list...") return nil } @@ -246,11 +309,11 @@ func validateCommitMessageHandler(git sv.Git, validateMessageProcessor sv.Valida return fmt.Errorf("failed to read commit message, error: %s", err.Error()) } - if err := validateMessageProcessor.Validate(commitMessage); err != nil { + if err := messageProcessor.Validate(commitMessage); err != nil { return fmt.Errorf("invalid commit message, error: %s", err.Error()) } - msg, err := validateMessageProcessor.Enhance(branch, commitMessage) + msg, err := messageProcessor.Enhance(branch, commitMessage) if err != nil { warn("could not enhance commit message, %s", err.Error()) return nil diff --git a/cmd/git-sv/main.go b/cmd/git-sv/main.go index fd5b896..bb10f57 100644 --- a/cmd/git-sv/main.go +++ b/cmd/git-sv/main.go @@ -12,13 +12,15 @@ import ( var Version = "" func main() { + log.SetFlags(0) + cfg := loadConfig() git := sv.NewGit(cfg.BreakingChangePrefixes, cfg.IssueIDPrefixes, cfg.TagPattern) semverProcessor := sv.NewSemVerCommitsProcessor(cfg.IncludeUnknownTypeAsPatch, cfg.MajorVersionTypes, cfg.MinorVersionTypes, cfg.PatchVersionTypes) releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotesTags) outputFormatter := sv.NewOutputFormatter() - validateMessageProcessor := sv.NewValidateMessageProcessor(cfg.ValidateMessageSkipBranches, cfg.CommitMessageTypes, cfg.IssueKeyName, cfg.BranchIssueRegex) + messageProcessor := sv.NewMessageProcessor(cfg.ValidateMessageSkipBranches, cfg.CommitMessageTypes, cfg.IssueKeyName, cfg.BranchIssueRegex, cfg.IssueRegex) app := cli.NewApp() app.Name = "sv" @@ -67,11 +69,17 @@ func main() { Usage: "generate tag with version based on git commit messages", Action: tagHandler(git, semverProcessor), }, + { + Name: "commit", + Aliases: []string{"cmt"}, + Usage: "execute git commit with convetional commit message helper", + Action: commitHandler(cfg, git, messageProcessor), + }, { Name: "validate-commit-message", Aliases: []string{"vcm"}, Usage: "use as prepare-commit-message hook to validate message", - Action: validateCommitMessageHandler(git, validateMessageProcessor), + Action: validateCommitMessageHandler(git, messageProcessor), Flags: []cli.Flag{ &cli.StringFlag{Name: "path", Required: true, Usage: "git working directory"}, &cli.StringFlag{Name: "file", Required: true, Usage: "name of the file that contains the commit log message"}, diff --git a/cmd/git-sv/prompt.go b/cmd/git-sv/prompt.go new file mode 100644 index 0000000..0f935fd --- /dev/null +++ b/cmd/git-sv/prompt.go @@ -0,0 +1,109 @@ +package main + +import ( + "fmt" + "reflect" + "regexp" + + "github.com/manifoldco/promptui" +) + +type commitType struct { + Type string + Description string + 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"}, + } + + template := &promptui.SelectTemplates{ + Label: "{{ . }}", + Active: "> {{ .Type | white }} - {{ .Description | faint }}", + Inactive: " {{ .Type | white }} - {{ .Description | faint }}", + Selected: `{{ "type:" | faint }} {{ .Type | white }}`, + Details: ` +{{ "Type:" | faint }} {{ .Type }} +{{ "Description:" | faint }} {{ .Description }} +{{ "Example:" | faint }} {{ .Example }}`, + } + + i, err := promptSelect("type", items, template) + if err != nil { + return commitType{}, err + } + return items[i], nil +} + +func promptScope() (string, error) { + return promptText("scope", "^[a-z0-9-]*$", "") +} + +func promptSubject() (string, error) { + return promptText("subject", "^[a-z].+$", "") +} + +func promptBody() (string, error) { + return promptText("body (leave empty to finish)", "^.*$", "") +} + +func promptIssueID(issueLabel, issueRegex, defaultValue string) (string, error) { + return promptText(issueLabel, "^("+issueRegex+")?$", defaultValue) +} + +func promptBreakingChanges() (string, error) { + return promptText("Breaking changes description", "[a-z].+", "") +} + +func promptSelect(label string, items interface{}, template *promptui.SelectTemplates) (int, error) { + if items == nil || reflect.TypeOf(items).Kind() != reflect.Slice { + return 0, fmt.Errorf("items %v is not a slice", items) + } + + prompt := promptui.Select{ + Label: label, + Size: reflect.ValueOf(items).Len(), + Items: items, + Templates: template, + } + + index, _, err := prompt.Run() + return index, err +} + +func promptText(label, regex, defaultValue string) (string, error) { + validate := func(input string) error { + regex := regexp.MustCompile(regex) + if !regex.MatchString(input) { + return fmt.Errorf("invalid value, expected: %s", regex) + } + return nil + } + + prompt := promptui.Prompt{ + Label: label, + Default: defaultValue, + Validate: validate, + } + + return prompt.Run() +} + +func promptConfirm(label string) (bool, error) { + r, err := promptText(label+" [y/n]", "^y|n$", "") + if err != nil { + return false, err + } + return r == "y", nil +} diff --git a/go.mod b/go.mod index 3d7c5d6..476ee75 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,6 @@ require ( github.com/Masterminds/semver v1.5.0 github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/kelseyhightower/envconfig v1.4.0 - github.com/urfave/cli/v2 v2.2.0 + github.com/manifoldco/promptui v0.8.0 + github.com/urfave/cli/v2 v2.3.0 ) diff --git a/go.sum b/go.sum index 3894527..4f6f6fc 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,44 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 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/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= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= +github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo= +github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= +github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= +github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/urfave/cli/v2 v2.2.0 h1:JTTnM6wKzdA0Jqodd966MVj4vWbbquZykeX1sKbe2C4= -github.com/urfave/cli/v2 v2.2.0/go.mod h1:SE9GqnLQmjVa0iPEY0f1w3ygNIYcIJ0OKPMoW2caLfQ= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/sv/git.go b/sv/git.go index b649f35..c5c8cbe 100644 --- a/sv/git.go +++ b/sv/git.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "fmt" + "os" "os/exec" "regexp" "strings" @@ -26,6 +27,7 @@ const ( type Git interface { Describe() string Log(initialTag, endTag string) ([]GitCommitLog, error) + Commit(header, body, footer string) error Tag(version semver.Version) error Tags() ([]GitTag, error) Branch() string @@ -92,6 +94,14 @@ func (g GitImpl) Log(initialTag, endTag string) ([]GitCommitLog, error) { return parseLogOutput(g.messageMetadata, string(out)), nil } +// Commit runs git commit +func (g GitImpl) Commit(header, body, footer string) error { + cmd := exec.Command("git", "commit", "-m", header, "-m", "", "-m", body, "-m", "", "-m", footer) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + // Tag create a git tag func (g GitImpl) Tag(version semver.Version) error { tag := fmt.Sprintf(g.tagPattern, version.Major(), version.Minor(), version.Patch()) diff --git a/sv/message.go b/sv/message.go new file mode 100644 index 0000000..25b75e2 --- /dev/null +++ b/sv/message.go @@ -0,0 +1,150 @@ +package sv + +import ( + "bufio" + "fmt" + "regexp" + "strings" +) + +const breakingChangeKey = "BREAKING CHANGE" + +// MessageProcessor interface. +type MessageProcessor interface { + SkipBranch(branch string) bool + 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) +} + +// NewMessageProcessor MessageProcessorImpl constructor +func NewMessageProcessor(skipBranches, supportedTypes []string, issueKeyName, branchIssueRegex, issueRegex string) *MessageProcessorImpl { + return &MessageProcessorImpl{ + skipBranches: skipBranches, + supportedTypes: supportedTypes, + issueKeyName: issueKeyName, + branchIssueRegex: branchIssueRegex, + issueRegex: issueRegex, + } +} + +// MessageProcessorImpl process validate message hook. +type MessageProcessorImpl struct { + skipBranches []string + supportedTypes []string + issueKeyName string + branchIssueRegex string + issueRegex string +} + +// SkipBranch check if branch should be ignored. +func (p MessageProcessorImpl) SkipBranch(branch string) bool { + return contains(branch, p.skipBranches) +} + +// 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 + } + if !valid { + return fmt.Errorf("message should contain type: %v, and should be valid according with conventional commits", p.supportedTypes) + } + 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) { + return "", nil //enhance disabled + } + + issue, err := p.IssueID(branch) + if err != nil { + return "", err + } + if issue == "" { + return "", fmt.Errorf("could not find issue id using configured regex") + } + + footer := fmt.Sprintf("%s: %s", p.issueKeyName, issue) + + if !hasFooter(message) { + return "\n" + footer, nil + } + + return footer, nil +} + +// 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) + if err != nil { + return "", fmt.Errorf("could not compile issue regex: %s, error: %v", p.branchIssueRegex, err.Error()) + } + + groups := r.FindStringSubmatch(branch) + if len(groups) != 4 { + return "", nil + } + 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) { + var header strings.Builder + header.WriteString(ctype) + if scope != "" { + header.WriteString("(" + scope + ")") + } + header.WriteString(": ") + header.WriteString(subject) + + var footer strings.Builder + if breakingChanges != "" { + footer.WriteString(fmt.Sprintf("%s: %s", breakingChangeKey, breakingChanges)) + } + if issue != "" { + if footer.Len() > 0 { + footer.WriteString("\n") + } + footer.WriteString(fmt.Sprintf("%s: %s", p.issueKeyName, issue)) + } + + return header.String(), body, footer.String() +} + +func hasFooter(message string) bool { + r := regexp.MustCompile("^[a-zA-Z-]+: .*|^[a-zA-Z-]+ #.*|^" + breakingChangeKey + ": .*") + + scanner := bufio.NewScanner(strings.NewReader(message)) + lines := 0 + for scanner.Scan() { + if lines > 0 && r.MatchString(scanner.Text()) { + return true + } + lines++ + } + + return false +} + +func hasIssueID(message, issueKeyName string) bool { + r := regexp.MustCompile(fmt.Sprintf("(?m)^%s: .+$", issueKeyName)) + return r.MatchString(message) +} + +func contains(value string, content []string) bool { + for _, v := range content { + if value == v { + return true + } + } + return false +} + +func firstLine(value string) string { + return strings.Split(value, "\n")[0] +} diff --git a/sv/validatemessage_test.go b/sv/message_test.go similarity index 55% rename from sv/validatemessage_test.go rename to sv/message_test.go index c852e87..e14fc02 100644 --- a/sv/validatemessage_test.go +++ b/sv/message_test.go @@ -4,7 +4,10 @@ import ( "testing" ) -var issueRegex = "^([a-z]+\\/)?([A-Z]+-[0-9]+)(-.*)?" +const ( + branchIssueRegex = "^([a-z]+\\/)?([A-Z]+-[0-9]+)(-.*)?" + issueRegex = "[A-Z]+-[0-9]+" +) // messages samples start var fullMessage = `fix: correct minor typos in code @@ -42,8 +45,8 @@ BREAKING CHANGE: refactor to use JavaScript features not available in Node 6.` // multiline samples end -func TestValidateMessageProcessorImpl_Validate(t *testing.T) { - p := NewValidateMessageProcessor([]string{"develop", "master"}, []string{"feat", "fix"}, "jira", issueRegex) +func TestMessageProcessorImpl_Validate(t *testing.T) { + p := NewMessageProcessor([]string{"develop", "master"}, []string{"feat", "fix"}, "jira", branchIssueRegex, issueRegex) tests := []struct { name string @@ -69,14 +72,14 @@ func TestValidateMessageProcessorImpl_Validate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if err := p.Validate(tt.message); (err != nil) != tt.wantErr { - t.Errorf("ValidateMessageProcessorImpl.Validate() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("MessageProcessorImpl.Validate() error = %v, wantErr %v", err, tt.wantErr) } }) } } -func TestValidateMessageProcessorImpl_Enhance(t *testing.T) { - p := NewValidateMessageProcessor([]string{"develop", "master"}, []string{"feat", "fix"}, "jira", issueRegex) +func TestMessageProcessorImpl_Enhance(t *testing.T) { + p := NewMessageProcessor([]string{"develop", "master"}, []string{"feat", "fix"}, "jira", branchIssueRegex, issueRegex) tests := []struct { name string @@ -98,11 +101,89 @@ func TestValidateMessageProcessorImpl_Enhance(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := p.Enhance(tt.branch, tt.message) if (err != nil) != tt.wantErr { - t.Errorf("ValidateMessageProcessorImpl.Enhance() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("MessageProcessorImpl.Enhance() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { - t.Errorf("ValidateMessageProcessorImpl.Enhance() = %v, want %v", got, tt.want) + t.Errorf("MessageProcessorImpl.Enhance() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMessageProcessorImpl_IssueID(t *testing.T) { + p := NewMessageProcessor([]string{"develop", "master"}, []string{"feat", "fix"}, "jira", branchIssueRegex, issueRegex) + + tests := []struct { + name string + branch string + want string + wantErr bool + }{ + {"simple branch", "JIRA-123", "JIRA-123", false}, + {"branch with prefix", "feature/JIRA-123", "JIRA-123", false}, + {"branch with prefix and posfix", "feature/JIRA-123-some-description", "JIRA-123", false}, + {"branch not found", "feature/wrong123-some-description", "", false}, + {"empty branch", "", "", false}, + {"unexpected branch name", "feature /JIRA-123", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := p.IssueID(tt.branch) + if (err != nil) != tt.wantErr { + t.Errorf("MessageProcessorImpl.IssueID() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("MessageProcessorImpl.IssueID() = %v, want %v", got, tt.want) + } + }) + } +} + +const ( + multilineBody = `a +b +c` + fullFooter = `BREAKING CHANGE: breaks +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) } }) } diff --git a/sv/validatemessage.go b/sv/validatemessage.go deleted file mode 100644 index 5b0e60e..0000000 --- a/sv/validatemessage.go +++ /dev/null @@ -1,108 +0,0 @@ -package sv - -import ( - "bufio" - "fmt" - "regexp" - "strings" -) - -// ValidateMessageProcessor interface. -type ValidateMessageProcessor interface { - SkipBranch(branch string) bool - Validate(message string) error - Enhance(branch string, message string) (string, error) -} - -// NewValidateMessageProcessor ValidateMessageProcessorImpl constructor -func NewValidateMessageProcessor(skipBranches, supportedTypes []string, issueKeyName, branchIssueRegex string) *ValidateMessageProcessorImpl { - return &ValidateMessageProcessorImpl{ - skipBranches: skipBranches, - supportedTypes: supportedTypes, - issueKeyName: issueKeyName, - branchIssueRegex: branchIssueRegex, - } -} - -// ValidateMessageProcessorImpl process validate message hook. -type ValidateMessageProcessorImpl struct { - skipBranches []string - supportedTypes []string - issueKeyName string - branchIssueRegex string -} - -// SkipBranch check if branch should be ignored. -func (p ValidateMessageProcessorImpl) SkipBranch(branch string) bool { - return contains(branch, p.skipBranches) -} - -// Validate commit message. -func (p ValidateMessageProcessorImpl) Validate(message string) error { - valid, err := regexp.MatchString("^("+strings.Join(p.supportedTypes, "|")+")(\\(.+\\))?!?: .*$", firstLine(message)) - if err != nil { - return err - } - if !valid { - return fmt.Errorf("message should contain type: %v, and should be valid according with conventional commits", p.supportedTypes) - } - return nil -} - -// Enhance add metadata on commit message. -func (p ValidateMessageProcessorImpl) Enhance(branch string, message string) (string, error) { - if p.branchIssueRegex == "" || p.issueKeyName == "" || hasIssueID(message, p.issueKeyName) { - return "", nil //enhance disabled - } - - r, err := regexp.Compile(p.branchIssueRegex) - if err != nil { - return "", fmt.Errorf("could not compile issue regex: %s, error: %v", p.branchIssueRegex, err.Error()) - } - - groups := r.FindStringSubmatch(branch) - if len(groups) != 4 { - return "", fmt.Errorf("could not find issue id group with configured regex") - } - - footer := fmt.Sprintf("%s: %s", p.issueKeyName, groups[2]) - - if !hasFooter(message) { - return "\n" + footer, nil - } - - return footer, nil -} - -func hasFooter(message string) bool { - r := regexp.MustCompile("^[a-zA-Z-]+: .*|^[a-zA-Z-]+ #.*|^BREAKING CHANGE: .*") - - scanner := bufio.NewScanner(strings.NewReader(message)) - lines := 0 - for scanner.Scan() { - if lines > 0 && r.MatchString(scanner.Text()) { - return true - } - lines++ - } - - return false -} - -func hasIssueID(message, issueKeyName string) bool { - r := regexp.MustCompile(fmt.Sprintf("(?m)^%s: .+$", issueKeyName)) - return r.MatchString(message) -} - -func contains(value string, content []string) bool { - for _, v := range content { - if value == v { - return true - } - } - return false -} - -func firstLine(value string) string { - return strings.Split(value, "\n")[0] -}