0
0
mirror of https://github.com/thegeeklab/git-sv.git synced 2024-11-24 11:10:39 +00:00

feat: add support for current-version, next-version, commit-log and release-notes

BREAKING CHANGE: first release
This commit is contained in:
Beatriz Vieira 2019-11-17 13:17:24 -03:00
parent d101a63bdb
commit 9374b3addc
9 changed files with 580 additions and 0 deletions

43
Makefile Normal file
View File

@ -0,0 +1,43 @@
.PHONY: usage build run test
OK_COLOR=\033[32;01m
NO_COLOR=\033[0m
ERROR_COLOR=\033[31;01m
WARN_COLOR=\033[33;01m
PKGS = $(shell go list ./...)
BIN = git-sv
ECHOFLAGS ?=
BUILDOS ?= linux
BUILDARCH ?= amd64
BUILDENVS ?= CGO_ENABLED=0 GOOS=$(BUILDOS) GOARCH=$(BUILDARCH)
BUILDFLAGS ?= -a -installsuffix cgo --ldflags '-extldflags "-lm -lstdc++ -static"'
usage: Makefile
@echo $(ECHOFLAGS) "to use make call:"
@echo $(ECHOFLAGS) " make <action>"
@echo $(ECHOFLAGS) ""
@echo $(ECHOFLAGS) "list of available actions:"
@sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /'
## build: build git-sv
build: test
@echo $(ECHOFLAGS) "$(OK_COLOR)==> Building binary ($(BUILDOS)/$(BUILDARCH)/$(BIN))...$(NO_COLOR)"
@$(BUILDENVS) go build -v $(BUILDFLAGS) -o bin/$(BUILDOS)_$(BUILDARCH)/$(BIN) ./cmd/git-sv
## test: run unit tests
test:
@echo $(ECHOFLAGS) "$(OK_COLOR)==> Running tests...$(NO_COLOR)"
@go test $(PKGS)
## run: run gitlabels-cli
run:
@echo $(ECHOFLAGS) "$(OK_COLOR)==> Running bin/$(BUILDOS)_$(BUILDARCH)/$(BIN)...$(NO_COLOR)"
@./bin/$(BUILDOS)_$(BUILDARCH)/$(BIN) $(args)
## tidy: execute go mod tidy
tidy:
@echo $(ECHOFLAGS) "$(OK_COLOR)==> runing tidy"
@go mod tidy

27
cmd/git-sv/config.go Normal file
View File

@ -0,0 +1,27 @@
package main
import (
"log"
"github.com/kelseyhightower/envconfig"
)
// 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,docs,fix,perf,refactor,style,test"`
IncludeUnknownTypeAsPatch bool `envconfig:"INCLUDE_UNKNOWN_TYPE_AS_PATCH" default:"true"`
CommitMessageMetadata map[string]string `envconfig:"COMMIT_MESSAGE_METADATA" default:"breakingchange:BREAKING CHANGE,issueid:jira"`
TagPattern string `envconfig:"TAG_PATTERN" default:"v%d.%d.%d"`
ReleaseNotesTags map[string]string `envconfig:"RELEASE_NOTES_TAGS" default:"fix:Bug Fixes,feat:Features"`
}
func loadConfig() Config {
var c Config
err := envconfig.Process("SV", &c)
if err != nil {
log.Fatal(err.Error())
}
return c
}

85
cmd/git-sv/handlers.go Normal file
View File

@ -0,0 +1,85 @@
package main
import (
"encoding/json"
"fmt"
"sv4git/sv"
"github.com/urfave/cli"
)
func currentVersionHandler(git sv.Git) func(c *cli.Context) error {
return func(c *cli.Context) error {
describe := git.Describe()
currentVer, err := sv.ToVersion(describe)
if err != nil {
return err
}
fmt.Printf("%d.%d.%d\n", currentVer.Major(), currentVer.Minor(), currentVer.Patch())
return nil
}
}
func nextVersionHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) func(c *cli.Context) error {
return func(c *cli.Context) error {
describe := git.Describe()
currentVer, err := sv.ToVersion(describe)
if err != nil {
return fmt.Errorf("error parsing version: %s from describe, message: %v", describe, err)
}
commits, err := git.Log(describe)
if err != nil {
return fmt.Errorf("error getting git log, message: %v", err)
}
nextVer := semverProcessor.NexVersion(currentVer, commits)
fmt.Printf("%d.%d.%d\n", nextVer.Major(), nextVer.Minor(), nextVer.Patch())
return nil
}
}
func commitLogHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor) func(c *cli.Context) error {
return func(c *cli.Context) error {
describe := git.Describe()
commits, err := git.Log(describe)
if err != nil {
return fmt.Errorf("error getting git log, message: %v", err)
}
for _, commit := range commits {
content, err := json.Marshal(commit)
if err != nil {
return err
}
fmt.Println(string(content))
}
return nil
}
}
func releaseNotesHandler(git sv.Git, semverProcessor sv.SemVerCommitsProcessor, rnProcessor sv.ReleaseNoteProcessor) func(c *cli.Context) error {
return func(c *cli.Context) error {
describe := git.Describe()
currentVer, err := sv.ToVersion(describe)
if err != nil {
return fmt.Errorf("error parsing version: %s from describe, message: %v", describe, err)
}
commits, err := git.Log(describe)
if err != nil {
return fmt.Errorf("error getting git log, message: %v", err)
}
nextVer := semverProcessor.NexVersion(currentVer, commits)
releasenote := rnProcessor.Get(commits)
fmt.Println(rnProcessor.Format(releasenote, nextVer))
return nil
}
}

52
cmd/git-sv/main.go Normal file
View File

@ -0,0 +1,52 @@
package main
import (
"log"
"os"
"sv4git/sv"
"github.com/urfave/cli"
)
func main() {
cfg := loadConfig()
git := sv.NewGit(cfg.CommitMessageMetadata, cfg.TagPattern)
semverProcessor := sv.NewSemVerCommitsProcessor(cfg.IncludeUnknownTypeAsPatch, cfg.MajorVersionTypes, cfg.MinorVersionTypes, cfg.PatchVersionTypes)
releasenotesProcessor := sv.NewReleaseNoteProcessor(cfg.ReleaseNotesTags)
app := cli.NewApp()
app.Name = "sv"
app.Usage = "semantic version for git"
app.Commands = []cli.Command{
{
Name: "current-version",
Aliases: []string{"cv"},
Usage: "get last released version from git",
Action: currentVersionHandler(git),
},
{
Name: "next-version",
Aliases: []string{"nv"},
Usage: "generate the next version based on git commit messages",
Action: nextVersionHandler(git, semverProcessor),
},
{
Name: "commit-log",
Aliases: []string{"cl"},
Usage: "list all commit logs since last version as jsons",
Action: commitLogHandler(git, semverProcessor),
},
{
Name: "release-notes",
Aliases: []string{"rn"},
Usage: "generate release notes",
Action: releaseNotesHandler(git, semverProcessor, releasenotesProcessor),
},
}
apperr := app.Run(os.Args)
if apperr != nil {
log.Fatal(apperr)
}
}

10
go.mod Normal file
View File

@ -0,0 +1,10 @@
module sv4git
go 1.13
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 v1.22.1
)

19
go.sum Normal file
View File

@ -0,0 +1,19 @@
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/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/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
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 v1.22.1 h1:+mkCCcOFKPnCmVYVcURKps1Xe+3zP90gSYGNfRkjoIY=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
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=

150
sv/git.go Normal file
View File

@ -0,0 +1,150 @@
package sv
import (
"bufio"
"bytes"
"fmt"
"os/exec"
"regexp"
"strings"
"github.com/Masterminds/semver"
)
const (
logSeparator = "##"
endLine = "~~"
breakingChangesTag = "BREAKING CHANGE:"
issueIDTag = "jira:"
)
// Git commands
type Git interface {
Describe() string
Log(lastTag string) ([]GitCommitLog, error)
Tag(version semver.Version) error
}
// GitCommitLog description of a single commit log
type GitCommitLog struct {
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"`
}
// GitImpl git command implementation
type GitImpl struct {
messageMetadata map[string]string
tagPattern string
}
// NewGit constructor
func NewGit(messageMetadata map[string]string, tagPattern string) *GitImpl {
return &GitImpl{messageMetadata: messageMetadata, tagPattern: tagPattern}
}
// Describe runs git describe, it no tag found, return empty
func (GitImpl) Describe() string {
cmd := exec.Command("git", "describe", "--abbrev=0")
out, err := cmd.CombinedOutput()
if err != nil {
return ""
}
return strings.TrimSpace(strings.Trim(string(out), "\n"))
}
// Log return git log
func (g GitImpl) Log(lastTag string) ([]GitCommitLog, error) {
format := "--pretty=format:\"%h" + logSeparator + "%s" + logSeparator + "%b" + endLine + "\""
cmd := exec.Command("git", "log", format)
if lastTag != "" {
cmd = exec.Command("git", "log", lastTag+"..HEAD", format)
}
out, err := cmd.CombinedOutput()
if err != nil {
return nil, err
}
return parseLogOutput(g.messageMetadata, string(out)), nil
}
// Tag create a git tag
func (g GitImpl) Tag(version semver.Version) error {
tagMsg := fmt.Sprintf("-v \"Version %d.%d.%d\"", version.Major(), version.Minor(), version.Patch())
cmd := exec.Command("git", "tag", "-a "+fmt.Sprintf(g.tagPattern, version.Major(), version.Minor(), version.Patch()), tagMsg)
return cmd.Run()
}
func parseLogOutput(messageMetadata map[string]string, 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))
}
}
return logs
}
func parseCommitLog(messageMetadata map[string]string, commit string) GitCommitLog {
content := strings.Split(strings.Trim(commit, "\""), logSeparator)
commitType, scope, subject := parseCommitLogMessage(content[1])
metadata := make(map[string]string)
for k, v := range messageMetadata {
if tagValue := extractTag(v, content[2]); tagValue != "" {
metadata[k] = tagValue
}
}
return GitCommitLog{
Hash: content[0],
Type: commitType,
Scope: scope,
Subject: subject,
Body: content[2],
Metadata: metadata,
}
}
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)
if atEOF && dataLen == 0 {
return 0, nil, nil
}
if i := bytes.Index(data, b); i >= 0 {
return i + len(b), data[0:i], nil
}
if atEOF {
return dataLen, data, nil
}
return 0, nil, nil
}
}

98
sv/releasenotes.go Normal file
View File

@ -0,0 +1,98 @@
package sv
import (
"bytes"
"fmt"
"text/template"
"time"
"github.com/Masterminds/semver"
)
type releaseNoteTemplate struct {
Version string
Date string
Sections map[string]ReleaseNoteSection
BreakingChanges []string
}
const markdownTemplate = `# v{{.Version}} ({{.Date}})
{{if .Sections.feat}}## {{.Sections.feat.Name}}
{{range $k,$v := .Sections.feat.Items}}
- {{if $v.Scope}}**{{$v.Scope}}:** {{end}}{{$v.Subject}} ({{$v.Hash}}) {{if $v.Metadata.issueid}}({{$v.Metadata.issueid}}){{end}}{{end}}{{end}}
{{if .Sections.fix}}## {{.Sections.fix.Name}}
{{range $k,$v := .Sections.fix.Items}}
- {{if $v.Scope}}**{{$v.Scope}}:** {{end}}{{$v.Subject}} ({{$v.Hash}}) {{if $v.Metadata.issueid}}({{$v.Metadata.issueid}}){{end}}{{end}}{{end}}
{{if .BreakingChanges}}## Breaking Changes
{{range $k,$v := .BreakingChanges}}
- {{$v}}{{end}}
{{end}}`
// ReleaseNoteProcessor release note processor interface.
type ReleaseNoteProcessor interface {
Get(commits []GitCommitLog) ReleaseNote
Format(releasenote ReleaseNote, version semver.Version) string
}
// ReleaseNoteProcessorImpl release note based on commit log.
type ReleaseNoteProcessorImpl struct {
tags map[string]string
template *template.Template
}
// NewReleaseNoteProcessor ReleaseNoteProcessor constructor.
func NewReleaseNoteProcessor(tags map[string]string) *ReleaseNoteProcessorImpl {
template := template.Must(template.New("markdown").Parse(markdownTemplate))
return &ReleaseNoteProcessorImpl{tags: tags, template: template}
}
// Get generate a release note based on commits.
func (p ReleaseNoteProcessorImpl) Get(commits []GitCommitLog) ReleaseNote {
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 !sexists {
section = ReleaseNoteSection{Name: name}
}
section.Items = append(section.Items, commit)
sections[commit.Type] = section
}
if value, exists := commit.Metadata[BreakingChangeTag]; exists {
breakingChanges = append(breakingChanges, value)
}
}
return ReleaseNote{Date: time.Now(), Sections: sections, BreakingChanges: breakingChanges}
}
// Format format a release note.
func (p ReleaseNoteProcessorImpl) Format(releasenote ReleaseNote, version semver.Version) string {
templateVars := releaseNoteTemplate{
Version: fmt.Sprintf("%d.%d.%d", version.Major(), version.Minor(), version.Patch()),
Date: releasenote.Date.Format("2006-01-02"),
Sections: releasenote.Sections,
BreakingChanges: releasenote.BreakingChanges,
}
var b bytes.Buffer
p.template.Execute(&b, templateVars)
return b.String()
}
// ReleaseNote release note.
type ReleaseNote struct {
Date time.Time
Sections map[string]ReleaseNoteSection
BreakingChanges []string
}
// ReleaseNoteSection release note section.
type ReleaseNoteSection struct {
Name string
Items []GitCommitLog
}

96
sv/semver.go Normal file
View File

@ -0,0 +1,96 @@
package sv
import "github.com/Masterminds/semver"
type versionType int
const (
none versionType = iota
patch
minor
major
)
// ToVersion parse string to semver.Version
func ToVersion(value string) (semver.Version, error) {
version := value
if version == "" {
version = "0.0.0"
}
v, err := semver.NewVersion(version)
return *v, err
}
// BreakingChangeTag breaking change tag from commit metadata
const BreakingChangeTag string = "breakingchange"
// SemVerCommitsProcessor interface
type SemVerCommitsProcessor interface {
NexVersion(version semver.Version, commits []GitCommitLog) semver.Version
}
// SemVerCommitsProcessorImpl process versions using commit log
type SemVerCommitsProcessorImpl struct {
MajorVersionTypes map[string]struct{}
MinorVersionTypes map[string]struct{}
PatchVersionTypes map[string]struct{}
IncludeUnknownTypeAsPatch bool
}
// NewSemVerCommitsProcessor SemanticVersionCommitsProcessorImpl constructor
func NewSemVerCommitsProcessor(unknownAsPatch bool, majorTypes, minorTypes, patchTypes []string) *SemVerCommitsProcessorImpl {
return &SemVerCommitsProcessorImpl{
IncludeUnknownTypeAsPatch: unknownAsPatch,
MajorVersionTypes: toMap(majorTypes),
MinorVersionTypes: toMap(minorTypes),
PatchVersionTypes: toMap(patchTypes),
}
}
// NexVersion calculates next version based on commit log
func (p SemVerCommitsProcessorImpl) NexVersion(version semver.Version, commits []GitCommitLog) semver.Version {
var versionToUpdate = none
for _, commit := range commits {
if v := p.versionTypeToUpdate(commit); v > versionToUpdate {
versionToUpdate = v
}
}
switch versionToUpdate {
case major:
return version.IncMajor()
case minor:
return version.IncMinor()
case patch:
return version.IncPatch()
default:
return version
}
}
func (p SemVerCommitsProcessorImpl) versionTypeToUpdate(commit GitCommitLog) versionType {
if _, exists := commit.Metadata[BreakingChangeTag]; exists {
return major
}
if _, exists := p.MajorVersionTypes[commit.Type]; exists {
return major
}
if _, exists := p.MinorVersionTypes[commit.Type]; exists {
return minor
}
if _, exists := p.PatchVersionTypes[commit.Type]; exists {
return patch
}
if p.IncludeUnknownTypeAsPatch {
return patch
}
return none
}
func toMap(values []string) map[string]struct{} {
result := make(map[string]struct{})
for _, v := range values {
result[v] = struct{}{}
}
return result
}