git-chglog/commit_parser.go
David Alpert 2a7ca9ecb0 feat: recognize Jira issue keys in commit body
As outlined in #183 it can be useful when following the
conventional commit syntax to separate conventional
commit type in the subject from work item references
which often appear in the commit body or commit footer

this commit:

- adds a new config option 'jira.issue.key_pattern' which
  is an optional regex used to match issue keys in the
  commit subject and body

- ensures that the Jira Issue Type does not overwrite a
  Conventional Commit Type; the 'jira.issue.type_maps'
  map is only used as a backup when the Conventional
  Commit Type parsed out of the Subject header is empty

- runs the 'jira.issue.key_pattern' regex against both
  the commit Subject header and the entire commit Body

NOTE: the current code assumes that only one Jira work
      item will be bound to a single commit, and this
      appears consistent with the spirit of Conventional
      Commits. I have sometimes found it useful to write
      a Conventional Commit where one 'fix' can close
      multiple Jira work items, so a future issue/change
      might consider updating the model to support multiple
      Jira work items in a single commit message.
2023-11-20 17:37:36 -06:00

514 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
reJiraKey *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, "|")
p := 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),
}
if opts.JiraKeyPattern != "" {
p.reJiraKey = regexp.MustCompile(opts.JiraKeyPattern)
}
return &p
}
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)
}
}
// Jira
if commit.JiraIssueID != "" {
p.processJiraIssue(commit, commit.JiraIssueID)
}
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:])
}
p.populateJiraIssueIDByJiraKeyRegex(commit, input)
// 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)
}
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
p.populateJiraIssueIDByJiraKeyRegex(commit, 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 (p *commitParser) populateJiraIssueIDByJiraKeyRegex(commit *Commit, input string) {
if p.reJiraKey != nil {
m := p.reJiraKey.FindAllString(input, -1)
if len(m) > 0 {
// NOTE: a git commit may reference multiple jira issues... here we capture only the first
// a future change may resolve this by updating the commit structure to support
// multiple issues
commit.JiraIssueID = m[0]
}
}
}
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
}
if commit.Type == "" {
// don't override an explicit Conventional Commit Type
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
}
}
}
}