diff --git a/chglog.go b/chglog.go index 68c8b3f3..76a8fbec 100644 --- a/chglog.go +++ b/chglog.go @@ -343,6 +343,14 @@ func (gen *Generator) render(w io.Writer, unreleased *Unreleased, versions []*Ve } return "" }, + // indent all lines of s n spaces + "indent": func(s string, n int) string { + if len(s) == 0 { + return "" + } + pad := strings.Repeat(" ", n) + return pad + strings.ReplaceAll(s, "\n", "\n"+pad) + }, } fname := filepath.Base(gen.config.Template) diff --git a/chglog_test.go b/chglog_test.go index 3c1486bc..864a7434 100644 --- a/chglog_test.go +++ b/chglog_test.go @@ -463,3 +463,82 @@ func TestGeneratorWithTagFiler(t *testing.T) { [Unreleased]: https://github.com/git-chglog/git-chglog/compare/v1.0.0...HEAD`, strings.TrimSpace(buf.String())) } + +func TestGeneratorWithTimmedBody(t *testing.T) { + assert := assert.New(t) + testName := "trimmed_body" + + setup(testName, func(commit commitFunc, tag tagFunc, _ gitcmd.Client) { + commit("2018-01-01 00:00:00", "feat: single line commit", "") + commit("2018-01-01 00:01:00", "feat: multi-line commit", ` +More details about the change and why it went in. + +BREAKING CHANGE: + +When using .TrimmedBody Notes are not included and can only appear in the Notes section. + +Signed-off-by: First Last + +Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com>`) + + commit("2018-01-01 00:00:00", "feat: another single line commit", "") + tag("1.0.0") + }) + + gen := NewGenerator(NewLogger(os.Stdout, os.Stderr, false, true), + &Config{ + Bin: "git", + WorkingDir: filepath.Join(testRepoRoot, testName), + Template: filepath.Join(cwd, "testdata", testName+".md"), + Info: &Info{ + Title: "CHANGELOG Example", + RepositoryURL: "https://github.com/git-chglog/git-chglog", + }, + Options: &Options{ + CommitFilters: map[string][]string{ + "Type": { + "feat", + }, + }, + CommitSortBy: "Scope", + CommitGroupBy: "Type", + CommitGroupSortBy: "Title", + CommitGroupTitleMaps: map[string]string{ + "feat": "Features", + }, + HeaderPattern: "^(\\w*)(?:\\(([\\w\\$\\.\\-\\*\\s]*)\\))?\\:\\s(.*)$", + HeaderPatternMaps: []string{ + "Type", + "Scope", + "Subject", + }, + NoteKeywords: []string{ + "BREAKING CHANGE", + }, + }, + }) + + buf := &bytes.Buffer{} + err := gen.Generate(buf, "") + output := strings.ReplaceAll(strings.TrimSpace(buf.String()), "\r\n", "\n") + + assert.Nil(err) + assert.Equal(` +## [Unreleased] + + + +## 1.0.0 - 2018-01-01 +### Features +- another single line commit +- multi-line commit + More details about the change and why it went in. +- single line commit + +### BREAKING CHANGE + +When using .TrimmedBody Notes are not included and can only appear in the Notes section. + + +[Unreleased]: https://github.com/git-chglog/git-chglog/compare/1.0.0...HEAD`, output) +} diff --git a/commit_parser.go b/commit_parser.go index 8c62c981..0bb67f83 100644 --- a/commit_parser.go +++ b/commit_parser.go @@ -59,6 +59,8 @@ type commitParser struct { reIssue *regexp.Regexp reNotes *regexp.Regexp reMention *regexp.Regexp + reSignOff *regexp.Regexp + reCoAuthor *regexp.Regexp reJiraIssueDescription *regexp.Regexp } @@ -81,6 +83,8 @@ func newCommitParser(logger *Logger, client gitcmd.Client, jiraClient JiraClient reIssue: regexp.MustCompile("(?:" + joinedIssuePrefix + ")(\\d+)"), reNotes: regexp.MustCompile("^(?i)\\s*(" + joinedNoteKeywords + ")[:\\s]+(.*)"), reMention: regexp.MustCompile(`@([\w-]+)`), + reSignOff: regexp.MustCompile(`Signed-off-by:\s+([\p{L}\s\-\[\]]+)\s+<([\w+\-\[\].@]+)>`), + reCoAuthor: regexp.MustCompile(`Co-authored-by:\s+([\p{L}\s\-\[\]]+)\s+<([\w+\-\[\].@]+)>`), reJiraIssueDescription: regexp.MustCompile(opts.JiraIssueDescriptionPattern), } } @@ -228,6 +232,36 @@ func (p *commitParser) processHeader(commit *Commit, input string) { } } +func (p *commitParser) extractLineMetadata(commit *Commit, line string) bool { + meta := false + + refs := p.parseRefs(line) + if len(refs) > 0 { + meta = true + commit.Refs = append(commit.Refs, refs...) + } + + mentions := p.parseMentions(line) + if len(mentions) > 0 { + meta = true + commit.Mentions = append(commit.Mentions, mentions...) + } + + coAuthors := p.parseCoAuthors(line) + if len(coAuthors) > 0 { + meta = true + commit.CoAuthors = append(commit.CoAuthors, coAuthors...) + } + + signers := p.parseSigners(line) + if len(signers) > 0 { + meta = true + commit.Signers = append(commit.Signers, signers...) + } + + return meta +} + func (p *commitParser) processBody(commit *Commit, input string) { input = convNewline(input, "\n") @@ -237,30 +271,29 @@ func (p *commitParser) processBody(commit *Commit, input string) { // notes & refs & mentions commit.Notes = []*Note{} inNote := false + trim := false fenceDetector := newMdFenceDetector() lines := strings.Split(input, "\n") + // body without notes & refs & mentions + trimmedBody := make([]string, 0, len(lines)) + for _, line := range lines { + if !inNote { + trim = false + } fenceDetector.Update(line) - if !fenceDetector.InCodeblock() { - refs := p.parseRefs(line) - if len(refs) > 0 { - inNote = false - commit.Refs = append(commit.Refs, refs...) - } - - mentions := p.parseMentions(line) - if len(mentions) > 0 { - inNote = false - commit.Mentions = append(commit.Mentions, mentions...) - } + if !fenceDetector.InCodeblock() && p.extractLineMetadata(commit, line) { + trim = true + inNote = false } - + // Q: should this check also only be outside of code blocks? res := p.reNotes.FindAllStringSubmatch(line, -1) if len(res) > 0 { inNote = true + trim = true for _, r := range res { commit.Notes = append(commit.Notes, &Note{ Title: r[1], @@ -271,8 +304,13 @@ func (p *commitParser) processBody(commit *Commit, input string) { last := commit.Notes[len(commit.Notes)-1] last.Body = last.Body + "\n" + line } + + if !trim { + trimmedBody = append(trimmedBody, line) + } } + commit.TrimmedBody = strings.TrimSpace(strings.Join(trimmedBody, "\n")) p.trimSpaceInNotes(commit) } @@ -317,6 +355,30 @@ func (p *commitParser) parseRefs(input string) []*Ref { return refs } +func (p *commitParser) parseSigners(input string) []Contact { + res := p.reSignOff.FindAllStringSubmatch(input, -1) + contacts := make([]Contact, len(res)) + + for i, r := range res { + contacts[i].Name = r[1] + contacts[i].Email = r[2] + } + + return contacts +} + +func (p *commitParser) parseCoAuthors(input string) []Contact { + res := p.reCoAuthor.FindAllStringSubmatch(input, -1) + contacts := make([]Contact, len(res)) + + for i, r := range res { + contacts[i].Name = r[1] + contacts[i].Email = r[2] + } + + return contacts +} + func (p *commitParser) parseMentions(input string) []string { res := p.reMention.FindAllStringSubmatch(input, -1) mentions := make([]string, len(res)) diff --git a/commit_parser_test.go b/commit_parser_test.go index b81aca71..93eec18b 100644 --- a/commit_parser_test.go +++ b/commit_parser_test.go @@ -103,13 +103,14 @@ func TestCommitParserParse(t *testing.T) { Source: "", }, }, - Notes: []*Note{}, - Mentions: []string{}, - Header: "feat(*): Add new feature #123", - Type: "feat", - Scope: "*", - Subject: "Add new feature #123", - Body: "", + Notes: []*Note{}, + Mentions: []string{}, + Header: "feat(*): Add new feature #123", + Type: "feat", + Scope: "*", + Subject: "Add new feature #123", + Body: "", + TrimmedBody: "", }, { Hash: &Hash{ @@ -166,6 +167,7 @@ Fixes #3 Closes #1 BREAKING CHANGE: This is breaking point message.`, + TrimmedBody: `This is body message.`, }, { Hash: &Hash{ @@ -200,6 +202,7 @@ BREAKING CHANGE: This is breaking point message.`, @tsuyoshiwada @hogefuga @FooBarBaz`, + TrimmedBody: `Has mention body`, }, { Hash: &Hash{ @@ -280,6 +283,7 @@ class MyController extends Controller { Fixes #123 Closes username/repository#456`, "```", "```"), + TrimmedBody: `This mixed body message.`, }, { Hash: &Hash{ @@ -300,14 +304,15 @@ Closes username/repository#456`, "```", "```"), Revert: &Revert{ Header: "fix(core): commit message", }, - Refs: []*Ref{}, - Notes: []*Note{}, - Mentions: []string{}, - Header: "Revert \"fix(core): commit message\"", - Type: "", - Scope: "", - Subject: "", - Body: "This reverts commit f755db78dcdf461dc42e709b3ab728ceba353d1d.", + Refs: []*Ref{}, + Notes: []*Note{}, + Mentions: []string{}, + Header: "Revert \"fix(core): commit message\"", + Type: "", + Scope: "", + Subject: "", + Body: "This reverts commit f755db78dcdf461dc42e709b3ab728ceba353d1d.", + TrimmedBody: "This reverts commit f755db78dcdf461dc42e709b3ab728ceba353d1d.", }, }, commits) } diff --git a/fields.go b/fields.go index 213bc719..2d68b885 100644 --- a/fields.go +++ b/fields.go @@ -8,6 +8,12 @@ type Hash struct { Short string } +// Contact of co-authors and signers +type Contact struct { + Name string + Email string +} + // Author of commit type Author struct { Name string @@ -70,6 +76,8 @@ type Commit struct { Refs []*Ref Notes []*Note Mentions []string // Name of the user included in the commit header or body + CoAuthors []Contact // (e.g. `Co-authored-by: user `) + Signers []Contact // (e.g. `Signed-off-by: user `) JiraIssue *JiraIssue // If no issue id found in header, `nil` is assigned Header string // (e.g. `feat(core)[RNWY-310]: Add new feature`) Type string // (e.g. `feat`) @@ -77,6 +85,7 @@ type Commit struct { Subject string // (e.g. `Add new feature`) JiraIssueID string // (e.g. `RNWY-310`) Body string + TrimmedBody string // Body without any Notes/Refs/Mentions/CoAuthors/Signers } // CommitGroup is a collection of commits grouped according to the `CommitGroupBy` option diff --git a/testdata/trimmed_body.md b/testdata/trimmed_body.md new file mode 100644 index 00000000..047a1581 --- /dev/null +++ b/testdata/trimmed_body.md @@ -0,0 +1,48 @@ +{{ if .Versions -}} + +## [Unreleased] + +{{ if .Unreleased.CommitGroups -}} +{{ range .Unreleased.CommitGroups -}} +### {{ .Title }} +{{ range .Commits -}} +- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} +{{ if .TrimmedBody -}} +{{ indent .TrimmedBody 2 }} +{{ end -}} +{{ end }} +{{ end -}} +{{ end -}} +{{ end -}} + +{{ range .Versions }} + +## {{ if .Tag.Previous }}[{{ .Tag.Name }}]{{ else }}{{ .Tag.Name }}{{ end }} - {{ datetime "2006-01-02" .Tag.Date }} +{{ range .CommitGroups -}} +### {{ .Title }} +{{ range .Commits -}} +- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }} +{{ if .TrimmedBody -}} +{{ indent .TrimmedBody 2 }} +{{ end -}} +{{ end }} +{{ end -}} + +{{- if .NoteGroups -}} +{{ range .NoteGroups -}} +### {{ .Title }} +{{ range .Notes }} +{{ .Body }} +{{ end }} +{{ end -}} +{{ end -}} +{{ end -}} + +{{- if .Versions }} +[Unreleased]: {{ .Info.RepositoryURL }}/compare/{{ $latest := index .Versions 0 }}{{ $latest.Tag.Name }}...HEAD +{{ range .Versions -}} +{{ if .Tag.Previous -}} +[{{ .Tag.Name }}]: {{ $.Info.RepositoryURL }}/compare/{{ .Tag.Previous.Name }}...{{ .Tag.Name }} +{{ end -}} +{{ end -}} +{{ end -}}