Skip to content

Commit 7898fb6

Browse files
authored
fix: parse multiline breaking changes correctly (#297)
1 parent 909ad60 commit 7898fb6

9 files changed

Lines changed: 100 additions & 42 deletions

File tree

.golangci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ linters:
5050
- makezero
5151
- misspell
5252
- mnd
53+
- modernize
5354
- musttag
5455
- nakedret
5556
- nestif

app/app_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func setupGitRepo(t *testing.T, tr testRepo) string {
6262

6363
testFile := filepath.Join(tmpDir, fmt.Sprintf("file%d.txt", i))
6464

65-
err := os.WriteFile(testFile, []byte(fmt.Sprintf("content %d", i)), 0o644)
65+
err := os.WriteFile(testFile, fmt.Appendf(nil, "content %d", i), 0o644)
6666
if err != nil {
6767
t.Fatalf("Failed to create test file: %v", err)
6868
}

app/commands/prompt.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ func promptBreakingChanges() (string, error) {
127127
return promptText("Breaking change description", "[a-z].+", "")
128128
}
129129

130-
func promptSelect(label string, items interface{}, template *promptui.SelectTemplates) (int, error) {
130+
func promptSelect(label string, items any, template *promptui.SelectTemplates) (int, error) {
131131
if items == nil || reflect.TypeOf(items).Kind() != reflect.Slice {
132132
return 0, fmt.Errorf("%w: %v is not a slice", errInvalidValue, items)
133133
}

app/commands/utils.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func getTags(gsv app.GitSV, tag string) (string, app.Tag, error) {
4141
}
4242

4343
func find(tag string, tags []app.Tag) int {
44-
for i := 0; i < len(tags); i++ {
44+
for i := range tags {
4545
if tag == tags[i].Name {
4646
return i
4747
}

app/config_test.go

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,23 +53,23 @@ func Test_merge(t *testing.T) {
5353

5454
{
5555
name: "overwrite pointer bool false",
56-
dst: Config{Branches: sv.BranchesConfig{SkipDetached: toPtr(false)}},
57-
src: Config{Branches: sv.BranchesConfig{SkipDetached: toPtr(true)}},
58-
want: Config{Branches: sv.BranchesConfig{SkipDetached: toPtr(true)}},
56+
dst: Config{Branches: sv.BranchesConfig{SkipDetached: new(false)}},
57+
src: Config{Branches: sv.BranchesConfig{SkipDetached: new(true)}},
58+
want: Config{Branches: sv.BranchesConfig{SkipDetached: new(true)}},
5959
wantErr: false,
6060
},
6161
{
6262
name: "overwrite pointer bool true",
63-
dst: Config{Branches: sv.BranchesConfig{SkipDetached: toPtr(true)}},
64-
src: Config{Branches: sv.BranchesConfig{SkipDetached: toPtr(false)}},
65-
want: Config{Branches: sv.BranchesConfig{SkipDetached: toPtr(false)}},
63+
dst: Config{Branches: sv.BranchesConfig{SkipDetached: new(true)}},
64+
src: Config{Branches: sv.BranchesConfig{SkipDetached: new(false)}},
65+
want: Config{Branches: sv.BranchesConfig{SkipDetached: new(false)}},
6666
wantErr: false,
6767
},
6868
{
6969
name: "default pointer bool",
70-
dst: Config{Branches: sv.BranchesConfig{SkipDetached: toPtr(true)}},
70+
dst: Config{Branches: sv.BranchesConfig{SkipDetached: new(true)}},
7171
src: Config{Branches: sv.BranchesConfig{SkipDetached: nil}},
72-
want: Config{Branches: sv.BranchesConfig{SkipDetached: toPtr(true)}},
72+
want: Config{Branches: sv.BranchesConfig{SkipDetached: new(true)}},
7373
wantErr: false,
7474
},
7575
{
@@ -117,22 +117,22 @@ func Test_merge(t *testing.T) {
117117
dst: Config{
118118
LogLevel: "info",
119119
Tag: TagConfig{
120-
Pattern: toPtr("something"),
121-
Filter: toPtr("something"),
120+
Pattern: new("something"),
121+
Filter: new("something"),
122122
},
123123
},
124124
src: Config{
125125
LogLevel: "",
126126
Tag: TagConfig{
127-
Pattern: toPtr(""),
128-
Filter: toPtr(""),
127+
Pattern: new(""),
128+
Filter: new(""),
129129
},
130130
},
131131
want: Config{
132132
LogLevel: "info",
133133
Tag: TagConfig{
134-
Pattern: toPtr(""),
135-
Filter: toPtr(""),
134+
Pattern: new(""),
135+
Filter: new(""),
136136
},
137137
},
138138
wantErr: false,
@@ -154,8 +154,3 @@ func Test_merge(t *testing.T) {
154154
})
155155
}
156156
}
157-
158-
// Helper function to create a pointer to any type.
159-
func toPtr[T any](v T) *T {
160-
return &v
161-
}

sv/commit.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ type CommitLog struct {
1717
Timestamp int `json:"timestamp,omitempty"`
1818
AuthorName string `json:"authorName,omitempty"`
1919
Hash string `json:"hash,omitempty"`
20-
Message CommitMessage `json:"message,omitempty"`
20+
Message CommitMessage `json:"message,omitzero"`
2121
}
2222

2323
// IsValidVersion return true when a version is valid.

sv/formatter/formatter_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ func Test_checkTemplatesExecution(t *testing.T) {
135135
tests := []struct {
136136
name string
137137
template string
138-
variables interface{}
138+
variables any
139139
}{
140140
{
141141
name: "changelog-md.tpl",

sv/message.go

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"regexp"
8+
"slices"
89
"strings"
910
)
1011

@@ -20,6 +21,9 @@ var (
2021
errIssueIDNotFound = errors.New("could not find issue id using configured regex")
2122
errInvalidIssueRegex = errors.New("could not compile issue regex")
2223
errInvalidHeaderRegex = errors.New("invalid regex on header-selector")
24+
25+
footerColonRe = regexp.MustCompile(`^[a-zA-Z-]+: `)
26+
footerHashRe = regexp.MustCompile(`^[a-zA-Z-]+ #`)
2327
)
2428

2529
// CommitMessage is a message using conventional commits.
@@ -306,7 +310,14 @@ func (p MessageProcessorImpl) Parse(subject, body string) (CommitMessage, error)
306310
if mdCfg.Key != "" {
307311
prefixes := append([]string{mdCfg.Key}, mdCfg.KeySynonyms...)
308312
for _, prefix := range prefixes {
309-
if tagValue := extractFooterMetadata(prefix, m.Body, mdCfg.UseHash); tagValue != "" {
313+
var prefixPattern string
314+
if mdCfg.UseHash {
315+
prefixPattern = prefix + " #"
316+
} else {
317+
prefixPattern = prefix + ": "
318+
}
319+
320+
if tagValue := extractFooterMetadata(prefixPattern, m.Body); tagValue != "" {
310321
m.Metadata[key] = tagValue
311322

312323
break
@@ -319,7 +330,7 @@ func (p MessageProcessorImpl) Parse(subject, body string) (CommitMessage, error)
319330
m.Metadata[BreakingChangeMetadataKey] = m.Description
320331
}
321332

322-
if tagValue := extractFooterMetadata(BreakingChangeFooterKey, m.Body, false); tagValue != "" {
333+
if tagValue := extractFooterMetadata(BreakingChangeFooterKey+": ", m.Body); tagValue != "" {
323334
m.IsBreakingChange = true
324335
m.Metadata[BreakingChangeMetadataKey] = tagValue
325336
}
@@ -367,19 +378,51 @@ func parseSubjectMessage(message string) (string, string, string, bool) {
367378
return result[1], result[3], strings.TrimSpace(result[5]), result[4] == "!"
368379
}
369380

370-
func extractFooterMetadata(key, text string, useHash bool) string {
371-
regex := regexp.MustCompile(key + ": (.*)")
381+
func extractFooterMetadata(key, text string) string {
382+
var value string
372383

373-
if useHash {
374-
regex = regexp.MustCompile(key + " (#.*)")
375-
}
384+
scanner := bufio.NewScanner(strings.NewReader(text))
385+
386+
inFooterValue := false
387+
388+
for scanner.Scan() {
389+
line := scanner.Text()
390+
391+
if after, ok := strings.CutPrefix(line, key); ok {
392+
if strings.HasSuffix(key, " #") {
393+
value = "#" + after
394+
} else {
395+
value = after
396+
}
397+
398+
inFooterValue = true
399+
400+
continue
401+
}
376402

377-
result := regex.FindStringSubmatch(text)
378-
if len(result) < 2 { //nolint:mnd
379-
return ""
403+
if inFooterValue {
404+
// Check if this line is another footer
405+
// Standard footer pattern: "Key: value"
406+
if footerColonRe.MatchString(line) {
407+
break
408+
}
409+
410+
// Hash footer pattern: "Key #value" - e.g., "Refs #123"
411+
if footerHashRe.MatchString(line) {
412+
break
413+
}
414+
415+
// Check for BREAKING CHANGE footer which has space in key name
416+
if strings.HasPrefix(line, BreakingChangeFooterKey+": ") {
417+
break
418+
}
419+
420+
// Continuation of previous footer value
421+
value += "\n" + line
422+
}
380423
}
381424

382-
return result[1]
425+
return value
383426
}
384427

385428
func hasFooter(message string) bool {
@@ -411,13 +454,7 @@ func hasIssueID(message string, issueConfig CommitMessageFooterConfig) bool {
411454
}
412455

413456
func contains(value string, content []string) bool {
414-
for _, v := range content {
415-
if value == v {
416-
return true
417-
}
418-
}
419-
420-
return false
457+
return slices.Contains(content, value)
421458
}
422459

423460
func splitCommitMessageContent(content string) (string, string) {

sv/message_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,12 @@ var hashMetadataBody = `some descriptions
728728
Jira: JIRA-999
729729
Refs #123`
730730

731+
var multilineBreakingChangeBody = `some descriptions
732+
733+
BREAKING CHANGE: Replace the custom logger and ` + "`" + `python-json-logger` + "`" + ` with
734+
` + "`" + `structlog` + "`" + `. This will also change the layout and general structure of
735+
the log messages.`
736+
731737
func TestMessageProcessorImpl_Parse(t *testing.T) {
732738
tests := []struct {
733739
name string
@@ -881,6 +887,25 @@ func TestMessageProcessorImpl_Parse(t *testing.T) {
881887
Metadata: map[string]string{IssueMetadataKey: "JIRA-123"},
882888
},
883889
},
890+
{
891+
// Multiline breaking change notes should capture the full content
892+
// across multiple lines, not just the first line.
893+
name: "multiline breaking change body",
894+
cfg: ccfg,
895+
subject: "refactor: replace logger by structlog",
896+
body: multilineBreakingChangeBody,
897+
want: CommitMessage{
898+
Type: "refactor",
899+
Scope: "",
900+
Description: "replace logger by structlog",
901+
Body: multilineBreakingChangeBody,
902+
IsBreakingChange: true,
903+
Metadata: map[string]string{
904+
BreakingChangeMetadataKey: "Replace the custom logger and `python-json-logger` with\n" +
905+
"`structlog`. This will also change the layout and general structure of\nthe log messages.",
906+
},
907+
},
908+
},
884909
}
885910

886911
for _, tt := range tests {

0 commit comments

Comments
 (0)