From 2a7ca9ecb0fab9e1fb15b0b1d908f8e0be8dc133 Mon Sep 17 00:00:00 2001 From: David Alpert Date: Mon, 20 Nov 2023 15:10:49 -0600 Subject: [PATCH] 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. --- README.md | 53 ++++++++++++++++++++++++++-------------- chglog.go | 1 + cmd/git-chglog/config.go | 2 ++ commit_parser.go | 40 ++++++++++++++++++++++++------ commit_parser_test.go | 30 ++++++++++++++++++----- testdata/gitlog_jira.txt | 4 +++ 6 files changed, 98 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3e767100..c9eda7ce 100644 --- a/README.md +++ b/README.md @@ -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: "(.*)" + 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: "(.*)" ``` -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/);
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 ` ... ` @@ -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 diff --git a/chglog.go b/chglog.go index fc5cf9e3..4f510265 100644 --- a/chglog.go +++ b/chglog.go @@ -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 `:` (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 diff --git a/cmd/git-chglog/config.go b/cmd/git-chglog/config.go index abfe82e8..66486504 100644 --- a/cmd/git-chglog/config.go +++ b/cmd/git-chglog/config.go @@ -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), diff --git a/commit_parser.go b/commit_parser.go index 0bb67f83..4d5a4290 100644 --- a/commit_parser.go +++ b/commit_parser.go @@ -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, diff --git a/commit_parser_test.go b/commit_parser_test.go index 600d1163..00ea851e 100644 --- a/commit_parser_test.go +++ b/commit_parser_test.go @@ -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) } diff --git a/testdata/gitlog_jira.txt b/testdata/gitlog_jira.txt index f535f2e1..e3d66705 100644 --- a/testdata/gitlog_jira.txt +++ b/testdata/gitlog_jira.txt @@ -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