From 9a0d5847458a53f499200b3538db68e54ee5d910 Mon Sep 17 00:00:00 2001 From: "Mason J. Katz" Date: Mon, 22 Mar 2021 14:04:57 -0700 Subject: [PATCH] feat: add support for rendering .Body after .Subject as part of list (#121) When attempting to render a commit body below the summary line of the commit there are two problems: 1) The text needs to be indented two spaces to appear as part of the list. 2) Notes (e.g. BREAKING CHANGE) are included in the body and end up being repeating in a Notes section (if this is part of your template). To address #1 add an `indent` func to the template parsing. To address #2 add a `TrimmedBody` to the `Commit` fields. The `TrimmedBody` will include everything in `Body` but not any `Ref`s, `Note`s, `Mention`s, `CoAuthors`, or `Signers`. Both the CoAuthors and Signers are now first class in the Commit struct. With both of these a template block like: ``` {{ if .TrimmedBody -}} {{ indent .TrimmedBody 2 }} {{ end -}} ``` Will render the trimmed down body section as intended. See TestGeneratorWithTimmedBody for example of desired output. --- chglog.go | 8 ++++ chglog_test.go | 79 ++++++++++++++++++++++++++++++++++++ commit_parser.go | 88 ++++++++++++++++++++++++++++++++++------ commit_parser_test.go | 35 +++++++++------- fields.go | 9 ++++ testdata/trimmed_body.md | 48 ++++++++++++++++++++++ 6 files changed, 239 insertions(+), 28 deletions(-) create mode 100644 testdata/trimmed_body.md 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 -}}