git-chglog/commit_parser.go
Mason J. Katz 9a0d584745
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.
2021-03-22 16:04:57 -05:00

488 lines
11 KiB
Go

package chglog
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/tsuyoshiwada/go-gitcmd"
)
var (
// constants
separator = "@@__CHGLOG__@@"
delimiter = "@@__CHGLOG_DELIMITER__@@"
// fields
hashField = "HASH"
authorField = "AUTHOR"
committerField = "COMMITTER"
subjectField = "SUBJECT"
bodyField = "BODY"
// formats
hashFormat = hashField + ":%H\t%h"
authorFormat = authorField + ":%an\t%ae\t%at"
committerFormat = committerField + ":%cn\t%ce\t%ct"
subjectFormat = subjectField + ":%s"
bodyFormat = bodyField + ":%b"
// log
logFormat = separator + strings.Join([]string{
hashFormat,
authorFormat,
committerFormat,
subjectFormat,
bodyFormat,
}, delimiter)
)
func joinAndQuoteMeta(list []string, sep string) string {
arr := make([]string, len(list))
for i, s := range list {
arr[i] = regexp.QuoteMeta(s)
}
return strings.Join(arr, sep)
}
type commitParser struct {
logger *Logger
client gitcmd.Client
jiraClient JiraClient
config *Config
reHeader *regexp.Regexp
reMerge *regexp.Regexp
reRevert *regexp.Regexp
reRef *regexp.Regexp
reIssue *regexp.Regexp
reNotes *regexp.Regexp
reMention *regexp.Regexp
reSignOff *regexp.Regexp
reCoAuthor *regexp.Regexp
reJiraIssueDescription *regexp.Regexp
}
func newCommitParser(logger *Logger, client gitcmd.Client, jiraClient JiraClient, config *Config) *commitParser {
opts := config.Options
joinedRefActions := joinAndQuoteMeta(opts.RefActions, "|")
joinedIssuePrefix := joinAndQuoteMeta(opts.IssuePrefix, "|")
joinedNoteKeywords := joinAndQuoteMeta(opts.NoteKeywords, "|")
return &commitParser{
logger: logger,
client: client,
jiraClient: jiraClient,
config: config,
reHeader: regexp.MustCompile(opts.HeaderPattern),
reMerge: regexp.MustCompile(opts.MergePattern),
reRevert: regexp.MustCompile(opts.RevertPattern),
reRef: regexp.MustCompile("(?i)(" + joinedRefActions + ")\\s?([\\w/\\.\\-]+)?(?:" + joinedIssuePrefix + ")(\\d+)"),
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),
}
}
func (p *commitParser) Parse(rev string) ([]*Commit, error) {
paths := p.config.Options.Paths
args := []string{
rev,
"--no-decorate",
"--pretty=" + logFormat,
}
if len(paths) > 0 {
args = append(args, "--")
args = append(args, paths...)
}
out, err := p.client.Exec("log", args...)
if err != nil {
return nil, err
}
processor := p.config.Options.Processor
lines := strings.Split(out, separator)
lines = lines[1:]
commits := make([]*Commit, len(lines))
for i, line := range lines {
commit := p.parseCommit(line)
if processor != nil {
commit = processor.ProcessCommit(commit)
if commit == nil {
continue
}
}
commits[i] = commit
}
return commits, nil
}
func (p *commitParser) parseCommit(input string) *Commit {
commit := &Commit{}
tokens := strings.Split(input, delimiter)
for _, token := range tokens {
firstSep := strings.Index(token, ":")
field := token[0:firstSep]
value := strings.TrimSpace(token[firstSep+1:])
switch field {
case hashField:
commit.Hash = p.parseHash(value)
case authorField:
commit.Author = p.parseAuthor(value)
case committerField:
commit.Committer = p.parseCommitter(value)
case subjectField:
p.processHeader(commit, value)
case bodyField:
p.processBody(commit, value)
}
}
commit.Refs = p.uniqRefs(commit.Refs)
commit.Mentions = p.uniqMentions(commit.Mentions)
return commit
}
func (p *commitParser) parseHash(input string) *Hash {
arr := strings.Split(input, "\t")
return &Hash{
Long: arr[0],
Short: arr[1],
}
}
func (p *commitParser) parseAuthor(input string) *Author {
arr := strings.Split(input, "\t")
ts, err := strconv.Atoi(arr[2])
if err != nil {
ts = 0
}
return &Author{
Name: arr[0],
Email: arr[1],
Date: time.Unix(int64(ts), 0),
}
}
func (p *commitParser) parseCommitter(input string) *Committer {
author := p.parseAuthor(input)
return &Committer{
Name: author.Name,
Email: author.Email,
Date: author.Date,
}
}
func (p *commitParser) processHeader(commit *Commit, input string) {
opts := p.config.Options
// header (raw)
commit.Header = input
var res [][]string
// Type, Scope, Subject etc ...
res = p.reHeader.FindAllStringSubmatch(input, -1)
if len(res) > 0 {
assignDynamicValues(commit, opts.HeaderPatternMaps, res[0][1:])
}
// Merge
res = p.reMerge.FindAllStringSubmatch(input, -1)
if len(res) > 0 {
merge := &Merge{}
assignDynamicValues(merge, opts.MergePatternMaps, res[0][1:])
commit.Merge = merge
}
// Revert
res = p.reRevert.FindAllStringSubmatch(input, -1)
if len(res) > 0 {
revert := &Revert{}
assignDynamicValues(revert, opts.RevertPatternMaps, res[0][1:])
commit.Revert = revert
}
// refs & mentions
commit.Refs = p.parseRefs(input)
commit.Mentions = p.parseMentions(input)
// Jira
if commit.JiraIssueID != "" {
p.processJiraIssue(commit, commit.JiraIssueID)
}
}
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")
// body
commit.Body = input
// 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() && 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],
Body: r[2],
})
}
} else if inNote {
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)
}
func (*commitParser) trimSpaceInNotes(commit *Commit) {
for _, note := range commit.Notes {
note.Body = strings.TrimSpace(note.Body)
}
}
func (p *commitParser) parseRefs(input string) []*Ref {
refs := []*Ref{}
// references
res := p.reRef.FindAllStringSubmatch(input, -1)
for _, r := range res {
refs = append(refs, &Ref{
Action: r[1],
Source: r[2],
Ref: r[3],
})
}
// issues
res = p.reIssue.FindAllStringSubmatch(input, -1)
for _, r := range res {
duplicate := false
for _, ref := range refs {
if ref.Ref == r[1] {
duplicate = true
}
}
if !duplicate {
refs = append(refs, &Ref{
Action: "",
Source: "",
Ref: r[1],
})
}
}
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))
for i, r := range res {
mentions[i] = r[1]
}
return mentions
}
func (p *commitParser) uniqRefs(refs []*Ref) []*Ref {
arr := []*Ref{}
for _, ref := range refs {
exist := false
for _, r := range arr {
if ref.Ref == r.Ref && ref.Action == r.Action && ref.Source == r.Source {
exist = true
}
}
if !exist {
arr = append(arr, ref)
}
}
return arr
}
func (p *commitParser) uniqMentions(mentions []string) []string {
arr := []string{}
for _, mention := range mentions {
exist := false
for _, m := range arr {
if mention == m {
exist = true
}
}
if !exist {
arr = append(arr, mention)
}
}
return arr
}
func (p *commitParser) processJiraIssue(commit *Commit, issueID string) {
issue, err := p.jiraClient.GetJiraIssue(commit.JiraIssueID)
if err != nil {
p.logger.Error(fmt.Sprintf("Failed to parse Jira story %s: %s\n", issueID, err))
return
}
commit.Type = p.config.Options.JiraTypeMaps[issue.Fields.Type.Name]
commit.JiraIssue = &JiraIssue{
Type: issue.Fields.Type.Name,
Summary: issue.Fields.Summary,
Description: issue.Fields.Description,
Labels: issue.Fields.Labels,
}
if p.config.Options.JiraIssueDescriptionPattern != "" {
res := p.reJiraIssueDescription.FindStringSubmatch(commit.JiraIssue.Description)
if len(res) > 1 {
commit.JiraIssue.Description = res[1]
}
}
}
var (
fenceTypes = []string{
"```",
"~~~",
" ",
"\t",
}
)
type mdFenceDetector struct {
fence int
}
func newMdFenceDetector() *mdFenceDetector {
return &mdFenceDetector{
fence: -1,
}
}
func (d *mdFenceDetector) InCodeblock() bool {
return d.fence > -1
}
func (d *mdFenceDetector) Update(input string) {
for i, s := range fenceTypes {
if d.fence < 0 {
if strings.Index(input, s) == 0 {
d.fence = i
break
}
} else {
if strings.Index(input, s) == 0 && i == d.fence {
d.fence = -1
break
}
}
}
}