0
0
mirror of https://github.com/thegeeklab/git-sv.git synced 2024-11-24 21:20:40 +00:00

Merge pull request #10 from bvieira/2.x

Feature: yaml config
This commit is contained in:
Beatriz Vieira 2021-02-17 21:30:13 -03:00 committed by GitHub
commit a07a355164
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 861 additions and 302 deletions

21
.sv4git.yml Normal file
View File

@ -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]+'

165
README.md
View File

@ -1,32 +1,130 @@
# sv4git
Semantic version for git
<p align="center">
<h1 align="center">sv4git</h1>
<p align="center">semantic version for git</p>
<p align="center">
<a href="https://github.com/bvieira/sv4git/releases/latest"><img alt="Release" src="https://img.shields.io/github/release/bvieira/sv4git.svg?style=for-the-badge"></a>
<a href="https://github.com/bvieira/sv4git/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/bvieira/sv4git?style=for-the-badge"></a>
<a href="/LICENSE"><img alt="Software License" src="https://img.shields.io/badge/license-MIT-informational.svg?style=for-the-badge"></a>
<a href="https://github.com/bvieira/sv4git/actions?workflow=ci"><img alt="GitHub Actions" src="https://img.shields.io/github/workflow/status/bvieira/sv4git/ci?style=for-the-badge"></a>
<a href="https://goreportcard.com/report/github.com/bvieira/sv4git"><img alt="Go Report Card" src="https://goreportcard.com/badge/github.com/bvieira/sv4git?style=for-the-badge"></a>
<a href="https://conventionalcommits.org"><img alt="Software License" src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-informational.svg?style=for-the-badge"></a>
</p>
</p>
## 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 :<space> separator, if true, use <space># 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

View File

@ -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]+"},
},
}
}

View File

@ -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 {

View File

@ -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"},

View File

@ -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-]*$", "")
}

2
go.mod
View File

@ -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
)

6
go.sum
View File

@ -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=

70
sv/config.go Normal file
View File

@ -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"`
}

View File

@ -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}}`

View File

@ -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)

View File

@ -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,
}
}

View File

@ -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()
}

View File

@ -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)
}
})
}
}

View File

@ -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.

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}