Skip to content

Commit 65a9774

Browse files
authored
feat(librarian): parse conventional commits for library since last released version (#1711)
Adds logic to parse conventional commits and a method to get conventional commits for library since last released version. Main changes: - basic logic for getting commits for path since tag/commit mostly ported from v0.1.0 [gitrepo.go](https://github.com/googleapis/librarian/blob/v0.1.0/internal/gitrepo/gitrepo.go) - logic to parse commit message into conventional commit struct in `internal/gitrepo/conventional_commits.go` based on regex, inspired by [this code](https://gitlab.com/digitalxero/go-conventional-commit/-/blob/master/conventional_commits.go?ref_type=heads) Additional context: [go/librarian:release-please-lite](http://goto.google.com/librarian:release-please-lite) For #1694
1 parent 951d684 commit 65a9774

File tree

5 files changed

+957
-29
lines changed

5 files changed

+957
-29
lines changed
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES, OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gitrepo
16+
17+
import (
18+
"fmt"
19+
"log/slog"
20+
"regexp"
21+
"strings"
22+
)
23+
24+
// ConventionalCommit represents a parsed conventional commit message.
25+
// See https://www.conventionalcommits.org/en/v1.0.0/ for details.
26+
type ConventionalCommit struct {
27+
// Type is the type of change (e.g., "feat", "fix", "docs").
28+
Type string
29+
// Scope is the scope of the change.
30+
Scope string
31+
// Description is the short summary of the change.
32+
Description string
33+
// Body is the long-form description of the change.
34+
Body string
35+
// Footers contain metadata (e.g.,"BREAKING CHANGE", "Reviewed-by").
36+
Footers map[string]string
37+
// IsBreaking indicates if the commit introduces a breaking change.
38+
IsBreaking bool
39+
// SHA is the full commit hash.
40+
SHA string
41+
}
42+
43+
const breakingChangeKey = "BREAKING CHANGE"
44+
45+
var commitRegex = regexp.MustCompile(`^(?P<type>\w+)(?:\((?P<scope>.*)\))?(?P<breaking>!)?:\s(?P<description>.*)`)
46+
47+
// footerRegex defines the format for a conventional commit footer.
48+
// A footer key consists of letters and hyphens, or is the "BREAKING CHANGE"
49+
// literal. The key is followed by ": " and then the value.
50+
// e.g., "Reviewed-by: G. Gemini" or "BREAKING CHANGE: an API was changed".
51+
var footerRegex = regexp.MustCompile(`^([A-Za-z-]+|` + breakingChangeKey + `):\s(.*)`)
52+
53+
// parsedHeader holds the result of parsing the header line.
54+
type parsedHeader struct {
55+
Type string
56+
Scope string
57+
Description string
58+
IsBreaking bool
59+
}
60+
61+
// parseHeader parses the header line of a commit message.
62+
func parseHeader(headerLine string) (*parsedHeader, bool) {
63+
match := commitRegex.FindStringSubmatch(headerLine)
64+
if len(match) == 0 {
65+
return nil, false
66+
}
67+
68+
capturesMap := make(map[string]string)
69+
for i, name := range commitRegex.SubexpNames()[1:] {
70+
if name != "" {
71+
capturesMap[name] = match[i+1]
72+
}
73+
}
74+
75+
return &parsedHeader{
76+
Type: capturesMap["type"],
77+
Scope: capturesMap["scope"],
78+
Description: capturesMap["description"],
79+
IsBreaking: capturesMap["breaking"] == "!",
80+
}, true
81+
}
82+
83+
// separateBodyAndFooters splits the lines after the header into body and footer sections.
84+
func separateBodyAndFooters(lines []string) (bodyLines, footerLines []string) {
85+
inFooterSection := false
86+
for i, line := range lines {
87+
if inFooterSection {
88+
footerLines = append(footerLines, line)
89+
continue
90+
}
91+
if strings.TrimSpace(line) == "" {
92+
isSeparator := false
93+
// Look ahead at the next non-blank line.
94+
for j := i + 1; j < len(lines); j++ {
95+
if strings.TrimSpace(lines[j]) != "" {
96+
if footerRegex.MatchString(lines[j]) {
97+
isSeparator = true
98+
}
99+
break
100+
}
101+
}
102+
if isSeparator {
103+
inFooterSection = true
104+
continue // Skip the blank separator line.
105+
}
106+
}
107+
bodyLines = append(bodyLines, line)
108+
}
109+
return bodyLines, footerLines
110+
}
111+
112+
// parseFooters parses footer lines from a conventional commit message into a map
113+
// of key-value pairs. It supports multi-line footers and also returns a
114+
// boolean indicating if a breaking change was detected.
115+
func parseFooters(footerLines []string) (footers map[string]string, isBreaking bool) {
116+
footers = make(map[string]string)
117+
var lastKey string
118+
for _, line := range footerLines {
119+
footerMatches := footerRegex.FindStringSubmatch(line)
120+
if len(footerMatches) == 0 {
121+
// Not a new footer. If we have a previous key and the line is not
122+
// empty, append it to the last value.
123+
if lastKey != "" && strings.TrimSpace(line) != "" {
124+
footers[lastKey] += "\n" + line
125+
}
126+
continue
127+
}
128+
// This is a new footer.
129+
key := strings.TrimSpace(footerMatches[1])
130+
value := strings.TrimSpace(footerMatches[2])
131+
footers[key] = value
132+
lastKey = key
133+
if key == breakingChangeKey {
134+
isBreaking = true
135+
}
136+
}
137+
return footers, isBreaking
138+
}
139+
140+
// ParseCommit parses a single commit message and returns a ConventionalCommit.
141+
// If the commit message does not follow the conventional commit format, it
142+
// logs a warning and returns a nil commit and no error.
143+
func ParseCommit(message, hashString string) (*ConventionalCommit, error) {
144+
trimmedMessage := strings.TrimSpace(message)
145+
if trimmedMessage == "" {
146+
return nil, fmt.Errorf("empty commit message")
147+
}
148+
lines := strings.Split(trimmedMessage, "\n")
149+
150+
header, ok := parseHeader(lines[0])
151+
if !ok {
152+
slog.Warn("Invalid conventional commit message", "message", message, "hash", hashString)
153+
return nil, nil
154+
}
155+
156+
bodyLines, footerLines := separateBodyAndFooters(lines[1:])
157+
158+
footers, footerIsBreaking := parseFooters(footerLines)
159+
160+
return &ConventionalCommit{
161+
Type: header.Type,
162+
Scope: header.Scope,
163+
Description: header.Description,
164+
Body: strings.TrimSpace(strings.Join(bodyLines, "\n")),
165+
Footers: footers,
166+
IsBreaking: header.IsBreaking || footerIsBreaking,
167+
SHA: hashString,
168+
}, nil
169+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package gitrepo
16+
17+
import (
18+
"strings"
19+
"testing"
20+
21+
"github.com/google/go-cmp/cmp"
22+
)
23+
24+
func TestParseCommit(t *testing.T) {
25+
for _, test := range []struct {
26+
name string
27+
message string
28+
want *ConventionalCommit
29+
wantErr bool
30+
wantErrPhrase string
31+
}{
32+
{
33+
name: "simple feat",
34+
message: "feat: add new feature",
35+
want: &ConventionalCommit{
36+
Type: "feat",
37+
Description: "add new feature",
38+
Footers: make(map[string]string),
39+
SHA: "fake-sha",
40+
},
41+
},
42+
{
43+
name: "feat with scope",
44+
message: "feat(scope): add new feature",
45+
want: &ConventionalCommit{
46+
Type: "feat",
47+
Scope: "scope",
48+
Description: "add new feature",
49+
Footers: make(map[string]string),
50+
SHA: "fake-sha",
51+
},
52+
},
53+
{
54+
name: "feat with breaking change",
55+
message: "feat!: add new feature",
56+
want: &ConventionalCommit{
57+
Type: "feat",
58+
Description: "add new feature",
59+
IsBreaking: true,
60+
Footers: make(map[string]string),
61+
SHA: "fake-sha",
62+
},
63+
},
64+
{
65+
name: "feat with single footer",
66+
message: "feat: add new feature\n\nCo-authored-by: John Doe <[email protected]>",
67+
want: &ConventionalCommit{
68+
Type: "feat",
69+
Description: "add new feature",
70+
Footers: map[string]string{"Co-authored-by": "John Doe <[email protected]>"},
71+
SHA: "fake-sha",
72+
},
73+
},
74+
{
75+
name: "feat with multiple footers",
76+
message: "feat: add new feature\n\nCo-authored-by: John Doe <[email protected]>\nReviewed-by: Jane Smith <[email protected]>",
77+
want: &ConventionalCommit{
78+
Type: "feat",
79+
Description: "add new feature",
80+
Footers: map[string]string{
81+
"Co-authored-by": "John Doe <[email protected]>",
82+
"Reviewed-by": "Jane Smith <[email protected]>",
83+
},
84+
SHA: "fake-sha",
85+
},
86+
},
87+
{
88+
name: "feat with multiple footers for generated changes",
89+
message: "feat: [library-name] add new feature\nThis is the body.\n...\n\nPiperOrigin-RevId: piper_cl_number\n\nSource-Link: [googleapis/googleapis@{source_commit_hash}](https://github.com/googleapis/googleapis/commit/{source_commit_hash})",
90+
want: &ConventionalCommit{
91+
Type: "feat",
92+
Description: "[library-name] add new feature",
93+
Body: "This is the body.\n...",
94+
IsBreaking: false,
95+
Footers: map[string]string{
96+
"PiperOrigin-RevId": "piper_cl_number",
97+
"Source-Link": "[googleapis/googleapis@{source_commit_hash}](https://github.com/googleapis/googleapis/commit/{source_commit_hash})",
98+
},
99+
SHA: "fake-sha",
100+
},
101+
},
102+
{
103+
name: "feat with breaking change footer",
104+
message: "feat: add new feature\n\nBREAKING CHANGE: this is a breaking change",
105+
want: &ConventionalCommit{
106+
Type: "feat",
107+
Description: "add new feature",
108+
Body: "",
109+
IsBreaking: true,
110+
Footers: map[string]string{"BREAKING CHANGE": "this is a breaking change"},
111+
SHA: "fake-sha",
112+
},
113+
},
114+
{
115+
name: "feat with wrong breaking change footer",
116+
message: "feat: add new feature\n\nBreaking change: this is a breaking change",
117+
want: &ConventionalCommit{
118+
Type: "feat",
119+
Description: "add new feature",
120+
Body: "Breaking change: this is a breaking change",
121+
IsBreaking: false,
122+
Footers: map[string]string{},
123+
SHA: "fake-sha",
124+
},
125+
},
126+
{
127+
name: "feat with body and footers",
128+
message: "feat: add new feature\n\nThis is the body of the commit message.\nIt can span multiple lines.\n\nCo-authored-by: John Doe <[email protected]>",
129+
want: &ConventionalCommit{
130+
Type: "feat",
131+
Description: "add new feature",
132+
Body: "This is the body of the commit message.\nIt can span multiple lines.",
133+
Footers: map[string]string{"Co-authored-by": "John Doe <[email protected]>"},
134+
SHA: "fake-sha",
135+
},
136+
},
137+
{
138+
name: "feat with multi-line footer",
139+
message: "feat: add new feature\n\nThis is the body.\n\nBREAKING CHANGE: this is a breaking change\nthat spans multiple lines.",
140+
want: &ConventionalCommit{
141+
Type: "feat",
142+
Description: "add new feature",
143+
Body: "This is the body.",
144+
IsBreaking: true,
145+
Footers: map[string]string{"BREAKING CHANGE": "this is a breaking change\nthat spans multiple lines."},
146+
SHA: "fake-sha",
147+
},
148+
},
149+
{
150+
name: "invalid conventional commit",
151+
message: "this is not a conventional commit",
152+
wantErr: false,
153+
want: nil,
154+
},
155+
{
156+
name: "empty commit message",
157+
message: "",
158+
wantErr: true,
159+
wantErrPhrase: "empty commit",
160+
},
161+
} {
162+
t.Run(test.name, func(t *testing.T) {
163+
got, err := ParseCommit(test.message, "fake-sha")
164+
if test.wantErr {
165+
if err == nil {
166+
t.Errorf("%s should return error", test.name)
167+
}
168+
if !strings.Contains(err.Error(), test.wantErrPhrase) {
169+
t.Errorf("ParseCommit(%q) returned error %q, want to contain %q", test.message, err.Error(), test.wantErrPhrase)
170+
}
171+
return
172+
}
173+
if err != nil {
174+
t.Fatal(err)
175+
}
176+
if diff := cmp.Diff(test.want, got); diff != "" {
177+
t.Errorf("ParseCommit(%q) returned diff (-want +got):\n%s", test.message, diff)
178+
}
179+
})
180+
}
181+
}

0 commit comments

Comments
 (0)