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.
This commit is contained in:
David Alpert 2023-11-20 15:10:49 -06:00
parent d93ef22384
commit 2a7ca9ecb0
6 changed files with 98 additions and 32 deletions

View file

@ -574,25 +574,41 @@ This sample pattern can match both forms of commit headers:
The following is a sample:
```yaml
jira:
info:
username: u
token: p
url: https://jira.com
issue:
type_maps:
Task: fix
Story: feat
description_pattern: "<changelog>(.*)</changelog>"
options:
jira:
info:
username: u
token: p
url: https://jira.com
issue:
key_pattern: "\\b(JIRA-\\d+)"
type_maps:
Task: fix
Story: feat
description_pattern: "<changelog>(.*)</changelog>"
```
Here you need to define Jira URL, access username and token (password). If you
don't want to write your Jira access credential in configure file, you may define
them with environment variables: `JIRA_URL`, `JIRA_USERNAME` and `JIRA_TOKEN`.
#### `options.jira.info`
You also needs to define a issue type map. In above sample, Jira issue type `Task`
Options to configure a Jira API client:
| Key | Required | Type | Default | Description |
|:-----------|:---------|:-------|:--------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `username` | N | String | none | Jira username; can also be read as an env var from `JIRA_USERNAME` |
| `token` | N | String | none | Jira [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/); can also be read as an env var from `JIRA_TOKEN` |
| `url` | N | String | none | Base URL for your Jira instance (e.g. https://myorg.atlassian.net/);<br/>can also be read as an env var from `JIRA_URL` |
#### `options.jira.issue`
You can also define an issue type map. In the above sample, Jira issue type `Task`
will be mapped to `fix` and `Story` will be mapped to `feat`.
| Key | Required | Type | Default | Description |
|:----------------------|:---------|:--------------------|:--------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `key_pattern` | N | String | none | A golang-compatible regular expression used to match against commit header and body to detect related Jira issues. |
| `type_maps` | N | `map[string]string` | none | A dictionary-style map used to infer the Conventional Commit Type from the Jira Issue Type, used when the Conventional Commit Subject does not provide a Commit Type. |
| `description_pattern` | N | String | none | A golang-compatible regular expression used to select out of a Jira Issue's Description field the subset of text to select for `.JiraIssue.Description` |
As a Jira story's description could be very long, you might not want to include
the entire description into change log. In that case, you may define `description_pattern`
like above, so that only content embraced with `<changelog> ... </changelog>`
@ -608,17 +624,16 @@ data. For example:
### {{ .Title }}
{{ range .Commits -}}
- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
{{ if .JiraIssue }} {{ .JiraIssue.Description }}
{{ end }}
{{ if .JiraIssue }} {{ .JiraIssue.Description }} {{ end }}
{{ end }}
{{ end -}}
```
Within a `Commit`, the following Jira data can be used in template:
- `.JiraIssue.Summary` - Summary of the Jira story
- `.JiraIssue.Description` - Description of the Jira story
- `.JiraIssue.Type` - Original type of the Jira story, and `.Type` will be mapped type.
- `.JiraIssue.Summary` - Summary of the Jira issue
- `.JiraIssue.Description` - Description of the Jira issue
- `.JiraIssue.Type` - Original type of the Jira issue (also available as `.Type` unless the commit provided a Conventional Commit Type.
- `.JiraIssue.Labels` - A list of strings, each is a Jira label.
## FAQ

View file

@ -39,6 +39,7 @@ type Options struct {
RevertPatternMaps []string // Similar to `HeaderPatternMaps`
NoteKeywords []string // Keyword list to find `Note`. A semicolon is a separator, like `<keyword>:` (e.g. `BREAKING CHANGE`)
JiraUsername string
JiraKeyPattern string // Optional regex to match jira issue keys from commit message body
JiraToken string
JiraURL string
JiraTypeMaps map[string]string

View file

@ -59,6 +59,7 @@ type JiraClientInfoOptions struct {
// JiraIssueOptions ...
type JiraIssueOptions struct {
KeyPattern string `yaml:"key_pattern"`
TypeMaps map[string]string `yaml:"type_maps"`
DescriptionPattern string `yaml:"description_pattern"`
}
@ -326,6 +327,7 @@ func (config *Config) Convert(ctx *CLIContext) *chglog.Config {
RevertPattern: opts.Reverts.Pattern,
RevertPatternMaps: opts.Reverts.PatternMaps,
NoteKeywords: opts.Notes.Keywords,
JiraKeyPattern: opts.Jira.Issue.KeyPattern,
JiraUsername: orValue(ctx.JiraUsername, opts.Jira.ClintInfo.Username),
JiraToken: orValue(ctx.JiraToken, opts.Jira.ClintInfo.Token),
JiraURL: orValue(ctx.JiraURL, opts.Jira.ClintInfo.URL),

View file

@ -61,6 +61,7 @@ type commitParser struct {
reMention *regexp.Regexp
reSignOff *regexp.Regexp
reCoAuthor *regexp.Regexp
reJiraKey *regexp.Regexp
reJiraIssueDescription *regexp.Regexp
}
@ -71,7 +72,7 @@ func newCommitParser(logger *Logger, client gitcmd.Client, jiraClient JiraClient
joinedIssuePrefix := joinAndQuoteMeta(opts.IssuePrefix, "|")
joinedNoteKeywords := joinAndQuoteMeta(opts.NoteKeywords, "|")
return &commitParser{
p := commitParser{
logger: logger,
client: client,
jiraClient: jiraClient,
@ -87,6 +88,12 @@ func newCommitParser(logger *Logger, client gitcmd.Client, jiraClient JiraClient
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) {
@ -153,6 +160,11 @@ func (p *commitParser) parseCommit(input string) *Commit {
}
}
// Jira
if commit.JiraIssueID != "" {
p.processJiraIssue(commit, commit.JiraIssueID)
}
commit.Refs = p.uniqRefs(commit.Refs)
commit.Mentions = p.uniqMentions(commit.Mentions)
@ -206,6 +218,8 @@ func (p *commitParser) processHeader(commit *Commit, input string) {
assignDynamicValues(commit, opts.HeaderPatternMaps, res[0][1:])
}
p.populateJiraIssueIDByJiraKeyRegex(commit, input)
// Merge
res = p.reMerge.FindAllStringSubmatch(input, -1)
if len(res) > 0 {
@ -225,11 +239,6 @@ func (p *commitParser) processHeader(commit *Commit, input string) {
// 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 {
@ -268,6 +277,8 @@ func (p *commitParser) processBody(commit *Commit, input string) {
// body
commit.Body = input
p.populateJiraIssueIDByJiraKeyRegex(commit, input)
// notes & refs & mentions
commit.Notes = []*Note{}
inNote := false
@ -314,6 +325,18 @@ func (p *commitParser) processBody(commit *Commit, input string) {
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)
@ -432,7 +455,10 @@ func (p *commitParser) processJiraIssue(commit *Commit, issueID string) {
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]
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,

View file

@ -398,6 +398,7 @@ func TestCommitParserParseWithJira(t *testing.T) {
"JiraIssueID",
"Subject",
},
JiraKeyPattern: "\\b(JIRA-\\d+)",
JiraTypeMaps: map[string]string{
"Story": "feat",
},
@ -406,11 +407,28 @@ func TestCommitParserParseWithJira(t *testing.T) {
commits, err := parser.Parse("HEAD")
assert.Nil(err)
assert.Equal(2, len(commits))
commit := commits[0]
assert.Equal(commit.JiraIssueID, "JIRA-1111")
assert.Equal(commit.JiraIssue.Type, "Story")
assert.Equal(commit.JiraIssue.Summary, "summary of JIRA-1111")
assert.Equal(commit.JiraIssue.Description, "description of JIRA-1111")
assert.Equal(commit.JiraIssue.Labels, []string{"GA"})
assert.Equal(commit.Type, "feat")
assert.Equal("JIRA-1111", commit.JiraIssueID)
if commit.JiraIssue == nil {
t.Fatal("did not load jira issue JIRA-1111")
}
assert.Equal("Story", commit.JiraIssue.Type)
assert.Equal("summary of JIRA-1111", commit.JiraIssue.Summary)
assert.Equal("description of JIRA-1111", commit.JiraIssue.Description)
assert.Equal([]string{"GA"}, commit.JiraIssue.Labels)
assert.Equal("feat", commit.Type)
commit = commits[1]
assert.Equal("JIRA-1112", commit.JiraIssueID)
if commit.JiraIssue == nil {
t.Fatal("did not load jira issue JIRA-1112")
}
assert.Equal("Story", commit.JiraIssue.Type)
assert.Equal("summary of JIRA-1112", commit.JiraIssue.Summary)
assert.Equal("description of JIRA-1112", commit.JiraIssue.Description)
assert.Equal([]string{"GA"}, commit.JiraIssue.Labels)
assert.Equal("fix", commit.Type)
}

View file

@ -1 +1,5 @@
@@__CHGLOG__@@HASH:65cf1add9735dcc4810dda3312b0792236c97c4e 65cf1add@@__CHGLOG_DELIMITER__@@AUTHOR:tsuyoshi wada mail@example.com 1514808000@@__CHGLOG_DELIMITER__@@COMMITTER:tsuyoshi wada mail@example.com 1514808000@@__CHGLOG_DELIMITER__@@SUBJECT:[JIRA-1111]: Add new feature #123@@__CHGLOG_DELIMITER__@@BODY: This is body message.
@@__CHGLOG__@@HASH:14ef0b6d386c5432af9292eab3c8314fa3001bc7 14ef0b6d@@__CHGLOG_DELIMITER__@@AUTHOR:tsuyoshi wada mail@example.com 1515153600@@__CHGLOG_DELIMITER__@@COMMITTER:tsuyoshi wada mail@example.com 1515153600@@__CHGLOG_DELIMITER__@@SUBJECT:fix: Feature didn't work right@@__CHGLOG_DELIMITER__@@BODY:This is body message.
Fixes JIRA-1112