Merge pull request #8 from git-chglog/feat/0.0.3

v0.0.3
This commit is contained in:
tsuyoshi wada 2018-02-25 16:08:18 +09:00 committed by GitHub
commit 29102c1d91
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1201 additions and 480 deletions

View file

@ -11,6 +11,7 @@ clean:
rm -rf ./dist/
rm -rf ./git-chglog
rm -rf $(GOPATH)/bin/git-chglog
rm -rf cover.out
.PHONY: bulid
build:
@ -20,10 +21,12 @@ build:
test:
go test -v `go list ./... | grep -v /vendor/`
.PHONY: coverage
coverage:
goverage -coverprofile=cover.out `go list ./... | grep -v /vendor/`
go tool cover -func=cover.out
@rm -rf cover.out
.PHONY: install
install:
go install ./cmd/git-chglog
.PHONY: chglog
chglog:
git-chglog -c ./.chglog/config.yml

View file

@ -0,0 +1,6 @@
package main
// Builder ...
type Builder interface {
Build(*Answer) (string, error)
}

View file

@ -12,7 +12,7 @@ import (
// CLI ...
type CLI struct {
ctx *Context
ctx *CLIContext
fs FileSystem
logger *Logger
configLoader ConfigLoader
@ -22,8 +22,7 @@ type CLI struct {
// NewCLI ...
func NewCLI(
ctx *Context,
fs FileSystem,
ctx *CLIContext, fs FileSystem,
configLoader ConfigLoader,
generator Generator,
) *CLI {

View file

@ -40,7 +40,7 @@ func TestCLIForStdout(t *testing.T) {
}
c := NewCLI(
&Context{
&CLIContext{
WorkingDir: "/",
ConfigPath: "/.chglog/config.yml",
OutputPath: "",
@ -108,7 +108,7 @@ func TestCLIForFile(t *testing.T) {
}
c := NewCLI(
&Context{
&CLIContext{
WorkingDir: "/",
ConfigPath: "/.chglog/config.yml",
OutputPath: "/dir/to/CHANGELOG.tpl",

View file

@ -70,7 +70,7 @@ type Config struct {
}
// Normalize ...
func (config *Config) Normalize(ctx *Context) error {
func (config *Config) Normalize(ctx *CLIContext) error {
err := mergo.Merge(config, &Config{
Bin: "git",
Template: "CHANGELOG.tpl.md",
@ -148,7 +148,7 @@ func (config *Config) normalizeStyleOfGitHub() {
}
// Convert ...
func (config *Config) Convert(ctx *Context) *chglog.Config {
func (config *Config) Convert(ctx *CLIContext) *chglog.Config {
info := config.Info
opts := config.Options

View file

@ -0,0 +1,73 @@
package main
import (
"fmt"
"strings"
)
// ConfigBuilder ...
type ConfigBuilder interface {
Builder
}
type configBuilderImpl struct{}
// NewConfigBuilder ...
func NewConfigBuilder() ConfigBuilder {
return &configBuilderImpl{}
}
// Build ...
func (*configBuilderImpl) Build(ans *Answer) (string, error) {
var msgFormat *CommitMessageFormat
for _, f := range formats {
if f.Display == ans.CommitMessageFormat {
msgFormat = f
break
}
}
if msgFormat == nil {
return "", fmt.Errorf("\"%s\" is an invalid commit message format", ans.CommitMessageFormat)
}
repoURL := strings.TrimRight(ans.RepositoryURL, "/")
if repoURL == "" {
repoURL = "\"\""
}
config := fmt.Sprintf(`style: %s
template: %s
info:
title: CHANGELOG
repository_url: %s
options:
commits:
# filters:
# Type:
# - feat
# - fix
# - perf
# - refactor
commit_groups:
# title_maps:
# feat: Features
# fix: Bug Fixes
# perf: Performance Improvements
# refactor: Code Refactoring
header:
pattern: "%s"
pattern_maps:%s
notes:
keywords:
- BREAKING CHANGE`,
ans.Style,
defaultTemplateFilename,
repoURL,
msgFormat.Pattern,
msgFormat.PatternMapString(),
)
return config, nil
}

View file

@ -0,0 +1,9 @@
package main
type mockConfigBuilderImpl struct {
ReturnBuild func(*Answer) (string, error)
}
func (m *mockConfigBuilderImpl) Build(ans *Answer) (string, error) {
return m.ReturnBuild(ans)
}

View file

@ -0,0 +1,62 @@
package main
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfigBulider(t *testing.T) {
assert := assert.New(t)
builder := NewConfigBuilder()
out, err := builder.Build(&Answer{
RepositoryURL: "https://github.com/git-chglog/git-chglog/git-chglog/",
Style: styleNone,
CommitMessageFormat: fmtGitBasic.Display,
Template: tplStandard,
})
assert.Nil(err)
assert.Contains(out, "style: none")
assert.Contains(out, "template: CHANGELOG.tpl.md")
assert.Contains(out, " repository_url: https://github.com/git-chglog/git-chglog/git-chglog")
assert.Contains(out, fmt.Sprintf(" pattern: \"%s\"", fmtGitBasic.Pattern))
assert.Contains(out, fmt.Sprintf(
` pattern_maps:
- %s
- %s`,
fmtGitBasic.PatternMaps[0],
fmtGitBasic.PatternMaps[1],
))
}
func TestConfigBuliderEmptyRepoURL(t *testing.T) {
assert := assert.New(t)
builder := NewConfigBuilder()
out, err := builder.Build(&Answer{
RepositoryURL: "",
Style: styleNone,
CommitMessageFormat: fmtGitBasic.Display,
Template: tplStandard,
})
assert.Nil(err)
assert.Contains(out, " repository_url: \"\"")
}
func TestConfigBuliderInvalidFormat(t *testing.T) {
assert := assert.New(t)
builder := NewConfigBuilder()
_, err := builder.Build(&Answer{
RepositoryURL: "",
Style: styleNone,
CommitMessageFormat: "",
Template: tplStandard,
})
assert.Contains(err.Error(), "invalid commit message format")
}

View file

@ -16,7 +16,7 @@ func TestConfigNormalize(t *testing.T) {
},
}
err := config.Normalize(&Context{
err := config.Normalize(&CLIContext{
ConfigPath: "/test/config.yml",
})
@ -30,7 +30,7 @@ func TestConfigNormalize(t *testing.T) {
Template: "/CHANGELOG.tpl.md",
}
err = config.Normalize(&Context{
err = config.Normalize(&CLIContext{
ConfigPath: "/test/config.yml",
})

View file

@ -4,8 +4,8 @@ import (
"io"
)
// Context ...
type Context struct {
// CLIContext ...
type CLIContext struct {
WorkingDir string
Stdout io.Writer
Stderr io.Writer
@ -16,3 +16,10 @@ type Context struct {
NoEmoji bool
Query string
}
// InitContext ...
type InitContext struct {
WorkingDir string
Stdout io.Writer
Stderr io.Writer
}

View file

@ -2,13 +2,16 @@ package main
import (
"io"
"io/ioutil"
"os"
)
// FileSystem ...
type FileSystem interface {
Exists(path string) bool
MkdirP(path string) error
Create(name string) (File, error)
WriteFile(path string, content []byte) error
}
// File ...
@ -25,6 +28,11 @@ var fs = &osFileSystem{}
type osFileSystem struct{}
func (*osFileSystem) Exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func (*osFileSystem) MkdirP(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
return os.MkdirAll(path, os.ModePerm)
@ -35,3 +43,7 @@ func (*osFileSystem) MkdirP(path string) error {
func (*osFileSystem) Create(name string) (File, error) {
return os.Create(name)
}
func (*osFileSystem) WriteFile(path string, content []byte) error {
return ioutil.WriteFile(path, content, os.ModePerm)
}

View file

@ -1,8 +1,14 @@
package main
type mockFileSystem struct {
ReturnMkdirP func(string) error
ReturnCreate func(string) (File, error)
ReturnExists func(string) bool
ReturnMkdirP func(string) error
ReturnCreate func(string) (File, error)
ReturnWriteFile func(string, []byte) error
}
func (m *mockFileSystem) Exists(path string) bool {
return m.ReturnExists(path)
}
func (m *mockFileSystem) MkdirP(path string) error {
@ -13,6 +19,10 @@ func (m *mockFileSystem) Create(name string) (File, error) {
return m.ReturnCreate(name)
}
func (m *mockFileSystem) WriteFile(path string, content []byte) error {
return m.ReturnWriteFile(path, content)
}
type mockFile struct {
File
ReturnWrite func([]byte) (int, error)

View file

@ -1,419 +1,95 @@
package main
import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"github.com/fatih/color"
gitcmd "github.com/tsuyoshiwada/go-gitcmd"
survey "gopkg.in/AlecAivazis/survey.v1"
emoji "gopkg.in/kyokomi/emoji.v1"
)
var (
defaultConfigFilename = "config.yml"
defaultTemplateFilename = "CHANGELOG.tpl.md"
styleGitHub = "github"
styleNone = "none"
changelogStyles = []string{
styleGitHub,
styleNone,
}
fmtTypeScopeSubject = "<type>(<scope>): <subject> -- feat(core) Add new feature"
fmtTypeSubject = "<type>: <subject> -- feat: Add new feature"
fmtGitBasic = "<<type> subject> -- Add new feature"
fmtSubject = "<subject> -- Add new feature (Not detect `type` field)"
commitMessageFormats = []string{
fmtTypeScopeSubject,
fmtTypeSubject,
fmtGitBasic,
fmtSubject,
}
tplStandard = "standard"
tplCool = "cool"
templateStyles = []string{
tplStandard,
tplCool,
}
)
// Answer ...
type Answer struct {
RepositoryURL string `survey:"repository_url"`
Style string `survey:"style"`
CommitMessageFormat string `survey:"commit_message_format"`
Template string `survey:"template"`
IncludeMerges bool `survey:"include_merges"`
IncludeReverts bool `survey:"include_reverts"`
ConfigDir string `survey:"config_dir"`
}
// Initializer ...
type Initializer struct {
client gitcmd.Client
ctx *InitContext
client gitcmd.Client
fs FileSystem
logger *Logger
questioner Questioner
configBuilder ConfigBuilder
templateBuilder TemplateBuilder
}
// NewInitializer ...
func NewInitializer() *Initializer {
func NewInitializer(
ctx *InitContext,
fs FileSystem,
questioner Questioner,
configBuilder ConfigBuilder,
templateBuilder TemplateBuilder,
) *Initializer {
return &Initializer{
client: gitcmd.New(&gitcmd.Config{
Bin: "git",
}),
ctx: ctx,
fs: fs,
logger: NewLogger(ctx.Stdout, ctx.Stderr, false, false),
questioner: questioner,
configBuilder: configBuilder,
templateBuilder: templateBuilder,
}
}
// Run ...
func (init *Initializer) Run() int {
answer, err := init.ask()
ans, err := init.questioner.Ask()
if err != nil {
init.logger.Error(err.Error())
return ExitCodeError
}
err = init.generateConfigure(answer)
if err != nil {
if err = init.fs.MkdirP(filepath.Join(init.ctx.WorkingDir, ans.ConfigDir)); err != nil {
init.logger.Error(err.Error())
return ExitCodeError
}
if err = init.generateConfig(ans); err != nil {
init.logger.Error(err.Error())
return ExitCodeError
}
if err = init.generateTemplate(ans); err != nil {
init.logger.Error(err.Error())
return ExitCodeError
}
success := color.CyanString("✔")
emoji.Fprintf(os.Stdout, `
init.logger.Log(fmt.Sprintf(`
:sparkles: %s
%s %s
%s %s
`,
color.GreenString("Configuration file and template generation completed!"),
success,
filepath.Join(answer.ConfigDir, defaultConfigFilename),
filepath.Join(ans.ConfigDir, defaultConfigFilename),
success,
filepath.Join(answer.ConfigDir, defaultTemplateFilename),
)
filepath.Join(ans.ConfigDir, defaultTemplateFilename),
))
return ExitCodeOK
}
func (init *Initializer) ask() (*Answer, error) {
answer := &Answer{}
qs := init.createQuestions()
err := survey.Ask(qs, answer)
if err != nil {
return nil, err
}
return answer, nil
}
func (init *Initializer) createQuestions() []*survey.Question {
originURL := init.getRepositoryURL()
return []*survey.Question{
{
Name: "repository_url",
Prompt: &survey.Input{
Message: "What is the URL of your repository?",
Default: originURL,
},
},
{
Name: "style",
Prompt: &survey.Select{
Message: "What is your favorite style?",
Options: changelogStyles,
Default: changelogStyles[0],
},
},
{
Name: "commit_message_format",
Prompt: &survey.Select{
Message: "Choose the format of your favorite commit message",
Options: commitMessageFormats,
Default: commitMessageFormats[0],
},
},
{
Name: "template",
Prompt: &survey.Select{
Message: "What is your favorite template style?",
Options: templateStyles,
Default: templateStyles[0],
},
},
{
Name: "include_merges",
Prompt: &survey.Confirm{
Message: "Do you include Merge Commit in CHANGELOG?",
Default: true,
},
},
{
Name: "include_reverts",
Prompt: &survey.Confirm{
Message: "Do you include Revert Commit in CHANGELOG?",
Default: true,
},
},
{
Name: "config_dir",
Prompt: &survey.Input{
Message: "In which directory do you output configuration files and templates?",
Default: ".chglog",
},
},
}
}
func (init *Initializer) getRepositoryURL() string {
if init.client.CanExec() != nil || init.client.InsideWorkTree() != nil {
return ""
}
rawurl, err := init.client.Exec("config", "--get", "remote.origin.url")
if err != nil {
return ""
}
return remoteOriginURLToHTTP(rawurl)
}
func (init *Initializer) generateConfigure(answer *Answer) error {
var err error
err = fs.MkdirP(answer.ConfigDir)
func (init *Initializer) generateConfig(ans *Answer) error {
s, err := init.configBuilder.Build(ans)
if err != nil {
return err
}
config := init.createConfigYamlContent(answer)
tpl := init.createTemplate(answer)
return init.fs.WriteFile(filepath.Join(init.ctx.WorkingDir, ans.ConfigDir, defaultConfigFilename), []byte(s))
}
configPath := filepath.Join(answer.ConfigDir, defaultConfigFilename)
templatePath := filepath.Join(answer.ConfigDir, defaultTemplateFilename)
err = init.createFileWithConfirm(configPath, config)
func (init *Initializer) generateTemplate(ans *Answer) error {
s, err := init.templateBuilder.Build(ans)
if err != nil {
return err
}
err = init.createFileWithConfirm(templatePath, tpl)
if err != nil {
return err
}
return nil
}
func (*Initializer) createFileWithConfirm(path, content string) error {
if _, err := os.Stat(path); err == nil {
answer := struct {
OK bool
}{}
err := survey.Ask([]*survey.Question{
{
Name: "ok",
Prompt: &survey.Confirm{
Message: fmt.Sprintf("\"%s\" already exists. Do you want to overwrite?", path),
Default: true,
},
},
}, &answer)
if err != nil {
return err
}
if !answer.OK {
return errors.New("creation of the file was interrupted")
}
}
err := ioutil.WriteFile(path, []byte(content), os.ModePerm)
if err != nil {
return err
}
return nil
}
func (init *Initializer) createConfigYamlContent(answer *Answer) string {
var (
style = answer.Style
template = defaultTemplateFilename
repositoryURL = answer.RepositoryURL
headerPattern string
headerPatternMaps string
)
switch answer.CommitMessageFormat {
case fmtTypeScopeSubject:
headerPattern = `^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$`
headerPatternMaps = `
- Type
- Scope
- Subject`
case fmtTypeSubject:
headerPattern = `^(\\w*)\\:\\s(.*)$`
headerPatternMaps = `
- Type
- Subject`
case fmtGitBasic:
headerPattern = `^((\\w+)\\s.*)$`
headerPatternMaps = `
- Subject
- Type`
case fmtSubject:
headerPattern = `^(.*)$`
headerPatternMaps = `
- Subject`
}
config := fmt.Sprintf(`style: %s
template: %s
info:
title: CHANGELOG
repository_url: %s
options:
commits:
# filters:
# Type:
# - feat
# - fix
# - perf
# - refactor
commit_groups:
# title_maps:
# feat: Features
# fix: Bug Fixes
# perf: Performance Improvements
# refactor: Code Refactoring
header:
pattern: "%s"
pattern_maps:%s
notes:
keywords:
- BREAKING CHANGE`,
style,
template,
repositoryURL,
headerPattern,
headerPatternMaps,
)
return config
}
func (init *Initializer) createTemplate(answer *Answer) string {
tpl := "{{range .Versions}}\n"
// versions
tpl += init.versionHeader(answer.Style, answer.Template)
// commits
tpl += init.commits(answer.Style, answer.Template, answer.CommitMessageFormat)
// revert
if answer.IncludeReverts {
tpl += `{{if .RevertCommits}}
### Reverts
{{range .RevertCommits}}
* {{.Revert.Header}}{{end}}
{{end}}`
}
// merge
if answer.IncludeMerges {
tpl += fmt.Sprintf(`{{if .MergeCommits}}
### %s
{{range .MergeCommits}}
* {{.Header}}{{end}}
{{end}}`, init.mergeTitle(answer.Style))
}
tpl += `{{range .NoteGroups}}
### {{.Title}}
{{range .Notes}}
{{.Body}}
{{end}}
{{end}}
{{end}}`
return tpl
}
func (*Initializer) versionHeader(style, template string) string {
var (
tpl string
tagName string
date = "{{datetime \"2006-01-02\" .Tag.Date}}"
)
// parts
switch style {
case styleGitHub:
tpl = "<a name=\"{{.Tag.Name}}\"></a>\n"
tagName = "{{if .Tag.Previous}}[{{.Tag.Name}}]({{$.Info.RepositoryURL}}/compare/{{.Tag.Previous.Name}}...{{.Tag.Name}}){{else}}{{.Tag.Name}}{{end}}"
default:
tagName = "{{.Tag.Name}}"
}
// format
switch template {
case tplStandard:
tpl = fmt.Sprintf("%s## %s (%s)\n",
tpl,
tagName,
date,
)
case tplCool:
tpl = fmt.Sprintf("%s## %s\n\n> %s\n",
tpl,
tagName,
date,
)
}
return tpl
}
func (*Initializer) commits(style, template, format string) string {
var (
header string
body string
)
switch format {
case fmtSubject:
body = `{{range .Commits}}
* {{.Header}}{{end}}
`
default:
if format == fmtTypeScopeSubject {
header = "{{if ne .Scope \"\"}}**{{.Scope}}:** {{end}}{{.Subject}}"
} else {
header = "{{.Subject}}"
}
body = fmt.Sprintf(`### {{.Title}}
{{range .Commits}}
* %s{{end}}
`, header)
}
return fmt.Sprintf(`{{range .CommitGroups}}
%s{{end}}`, body)
}
func (*Initializer) mergeTitle(style string) string {
switch style {
case styleGitHub:
return "Pull Requests"
default:
return "Merges"
}
return init.fs.WriteFile(filepath.Join(init.ctx.WorkingDir, ans.ConfigDir, defaultTemplateFilename), []byte(s))
}

View file

@ -0,0 +1,73 @@
package main
import (
"bytes"
"errors"
"testing"
"github.com/stretchr/testify/assert"
)
func TestInitializer(t *testing.T) {
assert := assert.New(t)
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
mockFs := &mockFileSystem{
ReturnMkdirP: func(path string) error {
if path == "/test/config" {
return nil
}
return errors.New("")
},
ReturnWriteFile: func(path string, content []byte) error {
if path == "/test/config/config.yml" || path == "/test/config/CHANGELOG.tpl.md" {
return nil
}
return errors.New("")
},
}
questioner := &mockQuestionerImpl{
ReturnAsk: func() (*Answer, error) {
return &Answer{
ConfigDir: "config",
}, nil
},
}
configBuilder := &mockConfigBuilderImpl{
ReturnBuild: func(ans *Answer) (string, error) {
if ans.ConfigDir == "config" {
return "config", nil
}
return "", errors.New("")
},
}
tplBuilder := &mockTemplateBuilderImpl{
ReturnBuild: func(ans *Answer) (string, error) {
if ans.ConfigDir == "config" {
return "template", nil
}
return "", errors.New("")
},
}
init := NewInitializer(
&InitContext{
WorkingDir: "/test",
Stdout: stdout,
Stderr: stderr,
},
mockFs,
questioner,
configBuilder,
tplBuilder,
)
assert.Equal(ExitCodeOK, init.Run())
assert.Equal("", stderr.String())
assert.Contains(stdout.String(), "Configuration file and template")
}

View file

@ -5,6 +5,7 @@ import (
"os"
"github.com/fatih/color"
gitcmd "github.com/tsuyoshiwada/go-gitcmd"
"github.com/urfave/cli"
)
@ -111,29 +112,44 @@ func main() {
wd, err := os.Getwd()
if err != nil {
fmt.Fprintln(os.Stderr, "failed to get working directory", err)
os.Exit(1)
os.Exit(ExitCodeError)
}
// initializer
if c.Bool("init") {
os.Exit(NewInitializer().Run())
initializer := NewInitializer(
&InitContext{
WorkingDir: wd,
Stdout: os.Stdout,
Stderr: os.Stderr,
},
fs,
NewQuestioner(
gitcmd.New(&gitcmd.Config{
Bin: "git",
}),
fs,
),
NewConfigBuilder(),
NewTemplateBuilder(),
)
os.Exit(initializer.Run())
}
// chglog
ctx := &Context{
WorkingDir: wd,
Stdout: os.Stdout,
Stderr: os.Stderr,
ConfigPath: c.String("config"),
OutputPath: c.String("output"),
Silent: c.Bool("silent"),
NoColor: c.Bool("no-color"),
NoEmoji: c.Bool("no-emoji"),
Query: c.Args().First(),
}
chglogCLI := NewCLI(
ctx,
&CLIContext{
WorkingDir: wd,
Stdout: os.Stdout,
Stderr: os.Stderr,
ConfigPath: c.String("config"),
OutputPath: c.String("output"),
Silent: c.Bool("silent"),
NoColor: c.Bool("no-color"),
NoEmoji: c.Bool("no-emoji"),
Query: c.Args().First(),
},
fs,
NewConfigLoader(),
NewGenerator(),

View file

@ -0,0 +1,188 @@
package main
import (
"errors"
"fmt"
"path/filepath"
"strings"
gitcmd "github.com/tsuyoshiwada/go-gitcmd"
survey "gopkg.in/AlecAivazis/survey.v1"
)
// Answer ...
type Answer struct {
RepositoryURL string `survey:"repository_url"`
Style string `survey:"style"`
CommitMessageFormat string `survey:"commit_message_format"`
Template string `survey:"template"`
IncludeMerges bool `survey:"include_merges"`
IncludeReverts bool `survey:"include_reverts"`
ConfigDir string `survey:"config_dir"`
}
// Questioner ...
type Questioner interface {
Ask() (*Answer, error)
}
type questionerImpl struct {
client gitcmd.Client
fs FileSystem
}
// NewQuestioner ...
func NewQuestioner(client gitcmd.Client, fs FileSystem) Questioner {
return &questionerImpl{
client: client,
fs: fs,
}
}
// Ask ...
func (q *questionerImpl) Ask() (*Answer, error) {
ans, err := q.ask()
if err != nil {
return nil, err
}
config := filepath.Join(ans.ConfigDir, defaultConfigFilename)
tpl := filepath.Join(ans.ConfigDir, defaultTemplateFilename)
c := q.fs.Exists(config)
t := q.fs.Exists(tpl)
msg := ""
if c && t {
msg = fmt.Sprintf("\"%s\" and \"%s\" already exists. Do you want to overwrite?", config, tpl)
} else if c {
msg = fmt.Sprintf("\"%s\" already exists. Do you want to overwrite?", config)
} else if t {
msg = fmt.Sprintf("\"%s\" already exists. Do you want to overwrite?", tpl)
}
if msg != "" {
overwrite := false
err = survey.AskOne(&survey.Confirm{
Message: msg,
Default: true,
}, &overwrite, nil)
if err != nil || !overwrite {
return nil, errors.New("creation of the file was interrupted")
}
}
return ans, nil
}
func (q *questionerImpl) ask() (*Answer, error) {
ans := &Answer{}
fmts := q.getFormats()
questions := []*survey.Question{
{
Name: "repository_url",
Prompt: &survey.Input{
Message: "What is the URL of your repository?",
Default: q.getRepositoryURL(),
},
},
{
Name: "style",
Prompt: &survey.Select{
Message: "What is your favorite style?",
Options: styles,
Default: styles[0],
},
},
{
Name: "commit_message_format",
Prompt: &survey.Select{
Message: "Choose the format of your favorite commit message",
Options: fmts,
Default: fmts[0],
},
Transform: func(ans interface{}) (newAns interface{}) {
if s, ok := ans.(string); ok {
newAns = q.parseFormat(s)
}
return
},
},
{
Name: "template",
Prompt: &survey.Select{
Message: "What is your favorite template style?",
Options: templates,
Default: templates[0],
},
},
{
Name: "include_merges",
Prompt: &survey.Confirm{
Message: "Do you include Merge Commit in CHANGELOG?",
Default: true,
},
},
{
Name: "include_reverts",
Prompt: &survey.Confirm{
Message: "Do you include Revert Commit in CHANGELOG?",
Default: true,
},
},
{
Name: "config_dir",
Prompt: &survey.Input{
Message: "In which directory do you output configuration files and templates?",
Default: defaultConfigDir,
},
},
}
err := survey.Ask(questions, ans)
if err != nil {
return nil, err
}
return ans, nil
}
func (*questionerImpl) getFormats() []string {
arr := make([]string, len(formats))
max := 0
for _, f := range formats {
l := len(f.Display)
if max < l {
max = l
}
}
for i, f := range formats {
arr[i] = fmt.Sprintf(
"%s -- %s",
f.Display+strings.Repeat(" ", max-len(f.Display)),
f.Preview,
)
}
return arr
}
func (*questionerImpl) parseFormat(input string) string {
return strings.TrimSpace(strings.Split(input, "--")[0])
}
func (q *questionerImpl) getRepositoryURL() string {
if q.client.CanExec() != nil || q.client.InsideWorkTree() != nil {
return ""
}
rawurl, err := q.client.Exec("config", "--get", "remote.origin.url")
if err != nil {
return ""
}
return remoteOriginURLToHTTP(rawurl)
}

View file

@ -0,0 +1,9 @@
package main
type mockQuestionerImpl struct {
ReturnAsk func() (*Answer, error)
}
func (m *mockQuestionerImpl) Ask() (*Answer, error) {
return m.ReturnAsk()
}

View file

@ -0,0 +1,143 @@
package main
import "fmt"
// TemplateBuilder ...
type TemplateBuilder interface {
Builder
}
type templateBuilderImpl struct{}
// NewTemplateBuilder ...
func NewTemplateBuilder() TemplateBuilder {
return &templateBuilderImpl{}
}
// Build ...
func (t *templateBuilderImpl) Build(ans *Answer) (string, error) {
// versions
tpl := "{{range .Versions}}\n"
// version header
tpl += t.versionHeader(ans.Style, ans.Template)
// commits
tpl += t.commits(ans.Template, ans.CommitMessageFormat)
// revert
if ans.IncludeReverts {
tpl += t.reverts()
}
// merges
if ans.IncludeMerges {
tpl += t.merges(ans.Style)
}
// notes
tpl += t.notes()
// versions end
tpl += "\n{{end}}"
return tpl, nil
}
func (*templateBuilderImpl) versionHeader(style, template string) string {
var (
tpl string
tagName string
date = "{{datetime \"2006-01-02\" .Tag.Date}}"
)
// parts
switch style {
case styleGitHub:
tpl = "<a name=\"{{.Tag.Name}}\"></a>\n"
tagName = "{{if .Tag.Previous}}[{{.Tag.Name}}]({{$.Info.RepositoryURL}}/compare/{{.Tag.Previous.Name}}...{{.Tag.Name}}){{else}}{{.Tag.Name}}{{end}}"
default:
tagName = "{{.Tag.Name}}"
}
// format
switch template {
case tplStandard:
tpl = fmt.Sprintf("%s## %s (%s)\n",
tpl,
tagName,
date,
)
case tplCool:
tpl = fmt.Sprintf("%s## %s\n\n> %s\n",
tpl,
tagName,
date,
)
}
return tpl
}
func (*templateBuilderImpl) commits(template, format string) string {
var (
header string
body string
)
switch format {
case fmtSubject.Display:
body = `{{range .Commits}}
* {{.Header}}{{end}}
`
default:
if format == fmtTypeScopeSubject.Display {
header = "{{if ne .Scope \"\"}}**{{.Scope}}:** {{end}}{{.Subject}}"
} else {
header = "{{.Subject}}"
}
body = fmt.Sprintf(`### {{.Title}}
{{range .Commits}}
* %s{{end}}
`, header)
}
return fmt.Sprintf(`{{range .CommitGroups}}
%s{{end}}`, body)
}
func (*templateBuilderImpl) reverts() string {
return `{{if .RevertCommits}}
### Reverts
{{range .RevertCommits}}
* {{.Revert.Header}}{{end}}
{{end}}`
}
func (t *templateBuilderImpl) merges(style string) string {
var title string
switch style {
case styleGitHub:
title = "Pull Requests"
default:
title = "Merges"
}
return fmt.Sprintf(`{{if .MergeCommits}}
### %s
{{range .MergeCommits}}
* {{.Header}}{{end}}
{{end}}`, title)
}
func (*templateBuilderImpl) notes() string {
return `{{range .NoteGroups}}
### {{.Title}}
{{range .Notes}}
{{.Body}}
{{end}}
{{end}}`
}

View file

@ -0,0 +1,9 @@
package main
type mockTemplateBuilderImpl struct {
ReturnBuild func(*Answer) (string, error)
}
func (m *mockTemplateBuilderImpl) Build(ans *Answer) (string, error) {
return m.ReturnBuild(ans)
}

View file

@ -0,0 +1,253 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestTemplateBuilderDefault(t *testing.T) {
assert := assert.New(t)
builder := NewTemplateBuilder()
out, err := builder.Build(&Answer{
Style: styleGitHub,
CommitMessageFormat: fmtTypeScopeSubject.Display,
Template: tplStandard,
IncludeMerges: true,
IncludeReverts: true,
})
assert.Nil(err)
assert.Equal(`{{range .Versions}}
<a name="{{.Tag.Name}}"></a>
## {{if .Tag.Previous}}[{{.Tag.Name}}]({{$.Info.RepositoryURL}}/compare/{{.Tag.Previous.Name}}...{{.Tag.Name}}){{else}}{{.Tag.Name}}{{end}} ({{datetime "2006-01-02" .Tag.Date}})
{{range .CommitGroups}}
### {{.Title}}
{{range .Commits}}
* {{if ne .Scope ""}}**{{.Scope}}:** {{end}}{{.Subject}}{{end}}
{{end}}{{if .RevertCommits}}
### Reverts
{{range .RevertCommits}}
* {{.Revert.Header}}{{end}}
{{end}}{{if .MergeCommits}}
### Pull Requests
{{range .MergeCommits}}
* {{.Header}}{{end}}
{{end}}{{range .NoteGroups}}
### {{.Title}}
{{range .Notes}}
{{.Body}}
{{end}}
{{end}}
{{end}}`, out)
}
func TestTemplateBuilderNone(t *testing.T) {
assert := assert.New(t)
builder := NewTemplateBuilder()
out, err := builder.Build(&Answer{
Style: styleNone,
CommitMessageFormat: fmtTypeScopeSubject.Display,
Template: tplStandard,
IncludeMerges: true,
IncludeReverts: true,
})
assert.Nil(err)
assert.Equal(`{{range .Versions}}
## {{.Tag.Name}} ({{datetime "2006-01-02" .Tag.Date}})
{{range .CommitGroups}}
### {{.Title}}
{{range .Commits}}
* {{if ne .Scope ""}}**{{.Scope}}:** {{end}}{{.Subject}}{{end}}
{{end}}{{if .RevertCommits}}
### Reverts
{{range .RevertCommits}}
* {{.Revert.Header}}{{end}}
{{end}}{{if .MergeCommits}}
### Merges
{{range .MergeCommits}}
* {{.Header}}{{end}}
{{end}}{{range .NoteGroups}}
### {{.Title}}
{{range .Notes}}
{{.Body}}
{{end}}
{{end}}
{{end}}`, out)
}
func TestTemplateBuilderCool(t *testing.T) {
assert := assert.New(t)
builder := NewTemplateBuilder()
out, err := builder.Build(&Answer{
Style: styleNone,
CommitMessageFormat: fmtTypeScopeSubject.Display,
Template: tplCool,
IncludeMerges: true,
IncludeReverts: true,
})
assert.Nil(err)
assert.Equal(`{{range .Versions}}
## {{.Tag.Name}}
> {{datetime "2006-01-02" .Tag.Date}}
{{range .CommitGroups}}
### {{.Title}}
{{range .Commits}}
* {{if ne .Scope ""}}**{{.Scope}}:** {{end}}{{.Subject}}{{end}}
{{end}}{{if .RevertCommits}}
### Reverts
{{range .RevertCommits}}
* {{.Revert.Header}}{{end}}
{{end}}{{if .MergeCommits}}
### Merges
{{range .MergeCommits}}
* {{.Header}}{{end}}
{{end}}{{range .NoteGroups}}
### {{.Title}}
{{range .Notes}}
{{.Body}}
{{end}}
{{end}}
{{end}}`, out)
}
func TestTemplateBuilderSubjectOnly(t *testing.T) {
assert := assert.New(t)
builder := NewTemplateBuilder()
out, err := builder.Build(&Answer{
Style: styleNone,
CommitMessageFormat: fmtSubject.Display,
Template: tplStandard,
IncludeMerges: true,
IncludeReverts: true,
})
assert.Nil(err)
assert.Equal(`{{range .Versions}}
## {{.Tag.Name}} ({{datetime "2006-01-02" .Tag.Date}})
{{range .CommitGroups}}
{{range .Commits}}
* {{.Header}}{{end}}
{{end}}{{if .RevertCommits}}
### Reverts
{{range .RevertCommits}}
* {{.Revert.Header}}{{end}}
{{end}}{{if .MergeCommits}}
### Merges
{{range .MergeCommits}}
* {{.Header}}{{end}}
{{end}}{{range .NoteGroups}}
### {{.Title}}
{{range .Notes}}
{{.Body}}
{{end}}
{{end}}
{{end}}`, out)
}
func TestTemplateBuilderSubject(t *testing.T) {
assert := assert.New(t)
builder := NewTemplateBuilder()
out, err := builder.Build(&Answer{
Style: styleNone,
CommitMessageFormat: fmtTypeSubject.Display,
Template: tplStandard,
IncludeMerges: true,
IncludeReverts: true,
})
assert.Nil(err)
assert.Equal(`{{range .Versions}}
## {{.Tag.Name}} ({{datetime "2006-01-02" .Tag.Date}})
{{range .CommitGroups}}
### {{.Title}}
{{range .Commits}}
* {{.Subject}}{{end}}
{{end}}{{if .RevertCommits}}
### Reverts
{{range .RevertCommits}}
* {{.Revert.Header}}{{end}}
{{end}}{{if .MergeCommits}}
### Merges
{{range .MergeCommits}}
* {{.Header}}{{end}}
{{end}}{{range .NoteGroups}}
### {{.Title}}
{{range .Notes}}
{{.Body}}
{{end}}
{{end}}
{{end}}`, out)
}
func TestTemplateBuilderIgnoreReverts(t *testing.T) {
assert := assert.New(t)
builder := NewTemplateBuilder()
out, err := builder.Build(&Answer{
Style: styleNone,
CommitMessageFormat: fmtTypeSubject.Display,
Template: tplStandard,
IncludeMerges: true,
IncludeReverts: false,
})
assert.Nil(err)
assert.Equal(`{{range .Versions}}
## {{.Tag.Name}} ({{datetime "2006-01-02" .Tag.Date}})
{{range .CommitGroups}}
### {{.Title}}
{{range .Commits}}
* {{.Subject}}{{end}}
{{end}}{{if .MergeCommits}}
### Merges
{{range .MergeCommits}}
* {{.Header}}{{end}}
{{end}}{{range .NoteGroups}}
### {{.Title}}
{{range .Notes}}
{{.Body}}
{{end}}
{{end}}
{{end}}`, out)
}
func TestTemplateBuilderIgnoreMerges(t *testing.T) {
assert := assert.New(t)
builder := NewTemplateBuilder()
out, err := builder.Build(&Answer{
Style: styleNone,
CommitMessageFormat: fmtTypeSubject.Display,
Template: tplStandard,
IncludeMerges: false,
IncludeReverts: true,
})
assert.Nil(err)
assert.Equal(`{{range .Versions}}
## {{.Tag.Name}} ({{datetime "2006-01-02" .Tag.Date}})
{{range .CommitGroups}}
### {{.Title}}
{{range .Commits}}
* {{.Subject}}{{end}}
{{end}}{{if .RevertCommits}}
### Reverts
{{range .RevertCommits}}
* {{.Revert.Header}}{{end}}
{{end}}{{range .NoteGroups}}
### {{.Title}}
{{range .Notes}}
{{.Body}}
{{end}}
{{end}}
{{end}}`, out)
}

View file

@ -0,0 +1,95 @@
package main
import (
"fmt"
"strings"
)
// Defaults
var (
defaultConfigDir = ".chglog"
defaultConfigFilename = "config.yml"
defaultTemplateFilename = "CHANGELOG.tpl.md"
)
// Styles
var (
styleGitHub = "github"
styleNone = "none"
styles = []string{
styleGitHub,
styleNone,
}
)
// CommitMessageFormat ...
type CommitMessageFormat struct {
Preview string
Display string
Pattern string
PatternMaps []string
}
// PatternMapString ...
func (f *CommitMessageFormat) PatternMapString() string {
s := " []"
l := len(f.PatternMaps)
if l == 0 {
return s
}
arr := make([]string, l)
for i, p := range f.PatternMaps {
arr[i] = fmt.Sprintf(
"%s- %s",
strings.Repeat(" ", 6),
p,
)
}
return fmt.Sprintf("\n%s", strings.Join(arr, "\n"))
}
// Formats
var (
fmtTypeScopeSubject = &CommitMessageFormat{
Preview: "feat(core) Add new feature",
Display: "<type>(<scope>): <subject>",
Pattern: `^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$`,
PatternMaps: []string{"Type", "Scope", "Subject"},
}
fmtTypeSubject = &CommitMessageFormat{
Preview: "feat: Add new feature",
Display: "<type>: <subject>",
Pattern: `^(\\w*)\\:\\s(.*)$`,
PatternMaps: []string{"Type", "Subject"},
}
fmtGitBasic = &CommitMessageFormat{
Preview: "Add new feature",
Display: "<<type> subject>",
Pattern: `^((\\w+)\\s.*)$`,
PatternMaps: []string{"Subject", "Type"},
}
fmtSubject = &CommitMessageFormat{
Preview: "Add new feature (Not detect `type` field)",
Display: "<subject>",
Pattern: `^(.*)$`,
PatternMaps: []string{"Subject"},
}
formats = []*CommitMessageFormat{
fmtTypeScopeSubject,
fmtTypeSubject,
fmtGitBasic,
fmtSubject,
}
)
// Templates
var (
tplStandard = "standard"
tplCool = "cool"
templates = []string{
tplStandard,
tplCool,
}
)

View file

@ -0,0 +1,30 @@
package main
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCommitMessageFormatPatternMaps(t *testing.T) {
assert := assert.New(t)
f := &CommitMessageFormat{
PatternMaps: []string{
"Type",
"Scope",
"Subject",
},
}
assert.Equal(`
- Type
- Scope
- Subject`, f.PatternMapString())
f = &CommitMessageFormat{
PatternMaps: []string{},
}
assert.Equal(" []", f.PatternMapString())
}

View file

@ -80,13 +80,15 @@ type CommitGroup struct {
// If you give `Tag`, the reference hierarchy will be deepened.
// This struct is used to minimize the hierarchy of references
type RelateTag struct {
Name string
Date time.Time
Name string
Subject string
Date time.Time
}
// Tag is data of git-tag
type Tag struct {
Name string
Subject string
Date time.Time
Next *RelateTag
Previous *RelateTag

View file

@ -42,9 +42,15 @@ func (p *GitHubProcessor) ProcessCommit(commit *Commit) *Commit {
commit.Header = p.addLinks(commit.Header)
commit.Subject = p.addLinks(commit.Subject)
commit.Body = p.addLinks(commit.Body)
for _, note := range commit.Notes {
note.Body = p.addLinks(note.Body)
}
if commit.Revert != nil {
commit.Revert.Header = p.addLinks(commit.Revert.Header)
}
return commit
}

View file

@ -51,4 +51,19 @@ gh-56 hoge fuga`,
},
),
)
assert.Equal(
&Commit{
Revert: &Revert{
Header: "revert header [@mention](https://github.com/mention) [#123](https://example.com/issues/123)",
},
},
processor.ProcessCommit(
&Commit{
Revert: &Revert{
Header: "revert header @mention #123",
},
},
),
)
}

View file

@ -2,8 +2,7 @@ package chglog
import (
"fmt"
"regexp"
"strconv"
"sort"
"strings"
"time"
@ -11,24 +10,24 @@ import (
)
type tagReader struct {
client gitcmd.Client
format string
reTag *regexp.Regexp
client gitcmd.Client
format string
separator string
}
func newTagReader(client gitcmd.Client) *tagReader {
return &tagReader{
client: client,
reTag: regexp.MustCompile("tag: ([\\w\\.\\-_]+),?"),
client: client,
separator: "@@__CHGLOG__@@",
}
}
func (r *tagReader) ReadAll() ([]*Tag, error) {
out, err := r.client.Exec(
"log",
"--tags",
"--simplify-by-decoration",
"--pretty=%D\t%at",
"for-each-ref",
"--format",
"%(refname)"+r.separator+"%(subject)"+r.separator+"%(taggerdate)"+r.separator+"%(authordate)",
"refs/tags",
)
tags := []*Tag{}
@ -40,28 +39,49 @@ func (r *tagReader) ReadAll() ([]*Tag, error) {
lines := strings.Split(out, "\n")
for _, line := range lines {
if line == "" {
tokens := strings.Split(line, r.separator)
if len(tokens) != 4 {
continue
}
tokens := strings.Split(line, "\t")
res := r.reTag.FindAllStringSubmatch(tokens[0], -1)
if len(res) == 0 {
continue
}
ts, err := strconv.Atoi(tokens[1])
name := r.parseRefname(tokens[0])
subject := r.parseSubject(tokens[1])
date, err := r.parseDate(tokens[2])
if err != nil {
continue
t, err2 := r.parseDate(tokens[3])
if err2 != nil {
return nil, err2
}
date = t
}
tags = append(tags, &Tag{
Name: res[0][1],
Date: time.Unix(int64(ts), 0),
Name: name,
Subject: subject,
Date: date,
})
}
r.sortTags(tags)
r.assignPreviousAndNextTag(tags)
return tags, nil
}
func (*tagReader) parseRefname(input string) string {
return strings.Replace(input, "refs/tags/", "", 1)
}
func (*tagReader) parseSubject(input string) string {
return strings.TrimSpace(input)
}
func (*tagReader) parseDate(input string) (time.Time, error) {
return time.Parse("Mon Jan 2 15:04:05 2006 -0700", input)
}
func (*tagReader) assignPreviousAndNextTag(tags []*Tag) {
total := len(tags)
for i, tag := range tags {
@ -72,21 +92,27 @@ func (r *tagReader) ReadAll() ([]*Tag, error) {
if i > 0 {
next = &RelateTag{
Name: tags[i-1].Name,
Date: tags[i-1].Date,
Name: tags[i-1].Name,
Subject: tags[i-1].Subject,
Date: tags[i-1].Date,
}
}
if i+1 < total {
prev = &RelateTag{
Name: tags[i+1].Name,
Date: tags[i+1].Date,
Name: tags[i+1].Name,
Subject: tags[i+1].Subject,
Date: tags[i+1].Date,
}
}
tag.Next = next
tag.Previous = prev
}
return tags, nil
}
func (*tagReader) sortTags(tags []*Tag) {
sort.Slice(tags, func(i, j int) bool {
return !tags[i].Date.Before(tags[j].Date)
})
}

View file

@ -13,19 +13,17 @@ func TestTagReader(t *testing.T) {
assert := assert.New(t)
client := &mockClient{
ReturnExec: func(subcmd string, args ...string) (string, error) {
if subcmd != "log" {
if subcmd != "for-each-ref" {
return "", errors.New("")
}
return strings.Join([]string{
"",
"tag: v5.2.0-beta.1, origin/labs/router\t1518023112",
"tag: 2.0.0\t1517875200",
"tag: v2.0.4-rc.1\t1517788800",
"tag: 2.0.4-beta.1\t1517702400",
"tag: hoge_fuga\t1517616000",
"tag: 1.9.29-alpha.0\t1517529600",
"hoge\t0",
"foo\t0",
"refs/tags/v2.0.4-beta.1@@__CHGLOG__@@Release v2.0.4-beta.1@@__CHGLOG__@@Thu Feb 1 00:00:00 2018 +0000@@__CHGLOG__@@",
"refs/tags/4.4.3@@__CHGLOG__@@This is tag subject@@__CHGLOG__@@@@__CHGLOG__@@Fri Feb 2 00:00:00 2018 +0000",
"refs/tags/4.4.4@@__CHGLOG__@@Release 4.4.4@@__CHGLOG__@@Fri Feb 2 10:00:40 2018 +0000@@__CHGLOG__@@",
"refs/tags/5.0.0-rc.0@@__CHGLOG__@@Release 5.0.0-rc.0@@__CHGLOG__@@Sat Feb 3 12:30:10 2018 +0000@@__CHGLOG__@@",
"refs/tags/hoge_fuga@@__CHGLOG__@@Invalid semver tag name@@__CHGLOG__@@Mon Mar 12 12:30:10 2018 +0000@@__CHGLOG__@@",
"hoge@@__CHGLOG__@@",
}, "\n"), nil
},
}
@ -36,68 +34,69 @@ func TestTagReader(t *testing.T) {
assert.Equal(
[]*Tag{
&Tag{
Name: "v5.2.0-beta.1",
Date: time.Unix(1518023112, 0),
Next: nil,
Name: "hoge_fuga",
Subject: "Invalid semver tag name",
Date: time.Date(2018, 3, 12, 12, 30, 10, 0, time.UTC),
Next: nil,
Previous: &RelateTag{
Name: "2.0.0",
Date: time.Unix(1517875200, 0),
Name: "5.0.0-rc.0",
Subject: "Release 5.0.0-rc.0",
Date: time.Date(2018, 2, 3, 12, 30, 10, 0, time.UTC),
},
},
&Tag{
Name: "2.0.0",
Date: time.Unix(1517875200, 0),
Name: "5.0.0-rc.0",
Subject: "Release 5.0.0-rc.0",
Date: time.Date(2018, 2, 3, 12, 30, 10, 0, time.UTC),
Next: &RelateTag{
Name: "v5.2.0-beta.1",
Date: time.Unix(1518023112, 0),
Name: "hoge_fuga",
Subject: "Invalid semver tag name",
Date: time.Date(2018, 3, 12, 12, 30, 10, 0, time.UTC),
},
Previous: &RelateTag{
Name: "v2.0.4-rc.1",
Date: time.Unix(1517788800, 0),
Name: "4.4.4",
Subject: "Release 4.4.4",
Date: time.Date(2018, 2, 2, 10, 0, 40, 0, time.UTC),
},
},
&Tag{
Name: "v2.0.4-rc.1",
Date: time.Unix(1517788800, 0),
Name: "4.4.4",
Subject: "Release 4.4.4",
Date: time.Date(2018, 2, 2, 10, 0, 40, 0, time.UTC),
Next: &RelateTag{
Name: "2.0.0",
Date: time.Unix(1517875200, 0),
Name: "5.0.0-rc.0",
Subject: "Release 5.0.0-rc.0",
Date: time.Date(2018, 2, 3, 12, 30, 10, 0, time.UTC),
},
Previous: &RelateTag{
Name: "2.0.4-beta.1",
Date: time.Unix(1517702400, 0),
Name: "4.4.3",
Subject: "This is tag subject",
Date: time.Date(2018, 2, 2, 0, 0, 0, 0, time.UTC),
},
},
&Tag{
Name: "2.0.4-beta.1",
Date: time.Unix(1517702400, 0),
Name: "4.4.3",
Subject: "This is tag subject",
Date: time.Date(2018, 2, 2, 0, 0, 0, 0, time.UTC),
Next: &RelateTag{
Name: "v2.0.4-rc.1",
Date: time.Unix(1517788800, 0),
Name: "4.4.4",
Subject: "Release 4.4.4",
Date: time.Date(2018, 2, 2, 10, 0, 40, 0, time.UTC),
},
Previous: &RelateTag{
Name: "hoge_fuga",
Date: time.Unix(1517616000, 0),
Name: "v2.0.4-beta.1",
Subject: "Release v2.0.4-beta.1",
Date: time.Date(2018, 2, 1, 0, 0, 0, 0, time.UTC),
},
},
&Tag{
Name: "hoge_fuga",
Date: time.Unix(1517616000, 0),
Name: "v2.0.4-beta.1",
Subject: "Release v2.0.4-beta.1",
Date: time.Date(2018, 2, 1, 0, 0, 0, 0, time.UTC),
Next: &RelateTag{
Name: "2.0.4-beta.1",
Date: time.Unix(1517702400, 0),
},
Previous: &RelateTag{
Name: "1.9.29-alpha.0",
Date: time.Unix(1517529600, 0),
},
},
&Tag{
Name: "1.9.29-alpha.0",
Date: time.Unix(1517529600, 0),
Next: &RelateTag{
Name: "hoge_fuga",
Date: time.Unix(1517616000, 0),
Name: "4.4.3",
Subject: "This is tag subject",
Date: time.Date(2018, 2, 2, 0, 0, 0, 0, time.UTC),
},
Previous: nil,
},