Skip to content

Commit 73c1bc7

Browse files
authored
feat(internal/semver): support derive next semver from conventional commits (#1769)
Add a semver package for logic to parse and increase semantic version according to https://semver.org/, then use it to derive next version from slices of conventional commits. Note: according to [go/librarian:release-command](http://goto.google.com/librarian:release-command) and [go/librarian:commits](http://goto.google.com/librarian:commits), nested commit information should only be used for release notes, and not for bumping semver. This implementation simply disregard nested commit types when determining version bumps and always do minor for them. Note: moved conventional_commits.go to a separate package in #1776. TODO: Will also rename release_please_lite.go in a separate followup PR. Additional context: [go/librarian:release-please-lite](http://goto.google.com/librarian:release-please-lite) Fixes #1696
1 parent 8756e01 commit 73c1bc7

File tree

4 files changed

+644
-13
lines changed

4 files changed

+644
-13
lines changed

internal/librarian/release_please_lite.go

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/googleapis/librarian/internal/config"
2222
"github.com/googleapis/librarian/internal/conventionalcommits"
2323
"github.com/googleapis/librarian/internal/gitrepo"
24+
"github.com/googleapis/librarian/internal/semver"
2425
)
2526

2627
const defaultTagFormat = "{id}-{version}"
@@ -33,7 +34,7 @@ func GetConventionalCommitsSinceLastRelease(repo gitrepo.Repository, library *co
3334
if err != nil {
3435
return nil, fmt.Errorf("failed to get commits for library %s: %w", library.ID, err)
3536
}
36-
var result []*conventionalcommits.ConventionalCommit
37+
var conventionalCommits []*conventionalcommits.ConventionalCommit
3738
for _, commit := range commits {
3839
files, err := repo.ChangedFilesInCommit(commit.Hash.String())
3940
if err != nil {
@@ -46,9 +47,12 @@ func GetConventionalCommitsSinceLastRelease(repo gitrepo.Repository, library *co
4647
if err != nil {
4748
return nil, fmt.Errorf("failed to parse commit %s: %w", commit.Hash.String(), err)
4849
}
49-
result = append(result, parsedCommits...)
50+
if parsedCommits == nil {
51+
continue
52+
}
53+
conventionalCommits = append(conventionalCommits, parsedCommits...)
5054
}
51-
return result, nil
55+
return conventionalCommits, nil
5256
}
5357

5458
// shouldExclude determines if a commit should be excluded from a release.
@@ -78,3 +82,37 @@ func formatTag(library *config.LibraryState) string {
7882
r := strings.NewReplacer("{id}", library.ID, "{version}", library.Version)
7983
return r.Replace(tagFormat)
8084
}
85+
86+
// NextVersion calculates the next semantic version based on a slice of conventional commits.
87+
// If overrideNextVersion is not empty, it is returned as the next version.
88+
func NextVersion(commits []*conventionalcommits.ConventionalCommit, currentVersion, overrideNextVersion string) (string, error) {
89+
if overrideNextVersion != "" {
90+
return overrideNextVersion, nil
91+
}
92+
highestChange := getHighestChange(commits)
93+
return semver.DeriveNext(highestChange, currentVersion)
94+
}
95+
96+
// getHighestChange determines the highest-ranking change type from a slice of commits.
97+
func getHighestChange(commits []*conventionalcommits.ConventionalCommit) semver.ChangeLevel {
98+
highestChange := semver.None
99+
for _, commit := range commits {
100+
var currentChange semver.ChangeLevel
101+
switch {
102+
case commit.IsNested:
103+
// ignore nested commit type for version bump
104+
// this allows for always increase minor version for generation PR
105+
currentChange = semver.Minor
106+
case commit.IsBreaking:
107+
currentChange = semver.Major
108+
case commit.Type == "feat":
109+
currentChange = semver.Minor
110+
case commit.Type == "fix":
111+
currentChange = semver.Patch
112+
}
113+
if currentChange > highestChange {
114+
highestChange = currentChange
115+
}
116+
}
117+
return highestChange
118+
}

internal/librarian/release_please_lite_test.go

Lines changed: 178 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ import (
2424
"github.com/googleapis/librarian/internal/config"
2525
"github.com/googleapis/librarian/internal/conventionalcommits"
2626
"github.com/googleapis/librarian/internal/gitrepo"
27+
"github.com/googleapis/librarian/internal/semver"
2728
)
2829

2930
func TestShouldExclude(t *testing.T) {
3031
t.Parallel()
31-
for _, tc := range []struct {
32+
for _, test := range []struct {
3233
name string
3334
files []string
3435
excludePaths []string
@@ -65,18 +66,18 @@ func TestShouldExclude(t *testing.T) {
6566
want: true,
6667
},
6768
} {
68-
t.Run(tc.name, func(t *testing.T) {
69-
got := shouldExclude(tc.files, tc.excludePaths)
70-
if got != tc.want {
71-
t.Errorf("shouldExclude(%v, %v) = %v, want %v", tc.files, tc.excludePaths, got, tc.want)
69+
t.Run(test.name, func(t *testing.T) {
70+
got := shouldExclude(test.files, test.excludePaths)
71+
if got != test.want {
72+
t.Errorf("shouldExclude(%v, %v) = %v, want %v", test.files, test.excludePaths, got, test.want)
7273
}
7374
})
7475
}
7576
}
7677

7778
func TestFormatTag(t *testing.T) {
7879
t.Parallel()
79-
for _, tc := range []struct {
80+
for _, test := range []struct {
8081
name string
8182
library *config.LibraryState
8283
want string
@@ -108,10 +109,10 @@ func TestFormatTag(t *testing.T) {
108109
want: "v1.2.3",
109110
},
110111
} {
111-
t.Run(tc.name, func(t *testing.T) {
112-
got := formatTag(tc.library)
113-
if got != tc.want {
114-
t.Errorf("formatTag() = %q, want %q", got, tc.want)
112+
t.Run(test.name, func(t *testing.T) {
113+
got := formatTag(test.library)
114+
if got != test.want {
115+
t.Errorf("formatTag() = %q, want %q", got, test.want)
115116
}
116117
})
117118
}
@@ -230,3 +231,170 @@ func TestGetConventionalCommitsSinceLastRelease(t *testing.T) {
230231
})
231232
}
232233
}
234+
235+
func TestGetHighestChange(t *testing.T) {
236+
t.Parallel()
237+
for _, test := range []struct {
238+
name string
239+
commits []*conventionalcommits.ConventionalCommit
240+
expectedChange semver.ChangeLevel
241+
}{
242+
{
243+
name: "major change",
244+
commits: []*conventionalcommits.ConventionalCommit{
245+
{Type: "feat", IsBreaking: true},
246+
{Type: "feat"},
247+
{Type: "fix"},
248+
},
249+
expectedChange: semver.Major,
250+
},
251+
{
252+
name: "minor change",
253+
commits: []*conventionalcommits.ConventionalCommit{
254+
{Type: "feat"},
255+
{Type: "fix"},
256+
},
257+
expectedChange: semver.Minor,
258+
},
259+
{
260+
name: "patch change",
261+
commits: []*conventionalcommits.ConventionalCommit{
262+
{Type: "fix"},
263+
},
264+
expectedChange: semver.Patch,
265+
},
266+
{
267+
name: "no change",
268+
commits: []*conventionalcommits.ConventionalCommit{
269+
{Type: "docs"},
270+
{Type: "chore"},
271+
},
272+
expectedChange: semver.None,
273+
},
274+
{
275+
name: "no commits",
276+
commits: []*conventionalcommits.ConventionalCommit{},
277+
expectedChange: semver.None,
278+
},
279+
{
280+
name: "nested commit forces minor bump",
281+
commits: []*conventionalcommits.ConventionalCommit{
282+
{Type: "fix"},
283+
{Type: "feat", IsNested: true},
284+
},
285+
expectedChange: semver.Minor,
286+
},
287+
{
288+
name: "nested commit with breaking change forces minor bump",
289+
commits: []*conventionalcommits.ConventionalCommit{
290+
{Type: "feat", IsBreaking: true, IsNested: true},
291+
{Type: "feat"},
292+
},
293+
expectedChange: semver.Minor,
294+
},
295+
{
296+
name: "major change and nested commit",
297+
commits: []*conventionalcommits.ConventionalCommit{
298+
{Type: "feat", IsBreaking: true},
299+
{Type: "fix", IsNested: true},
300+
},
301+
expectedChange: semver.Major,
302+
},
303+
{
304+
name: "nested commit before major change",
305+
commits: []*conventionalcommits.ConventionalCommit{
306+
{Type: "fix", IsNested: true},
307+
{Type: "feat", IsBreaking: true},
308+
},
309+
expectedChange: semver.Major,
310+
},
311+
{
312+
name: "nested commit with only fixes forces minor bump",
313+
commits: []*conventionalcommits.ConventionalCommit{
314+
{Type: "fix"},
315+
{Type: "fix", IsNested: true},
316+
},
317+
expectedChange: semver.Minor,
318+
},
319+
} {
320+
t.Run(test.name, func(t *testing.T) {
321+
highestChange := getHighestChange(test.commits)
322+
if diff := cmp.Diff(test.expectedChange, highestChange); diff != "" {
323+
t.Errorf("getHighestChange() returned diff (-want +got):\n%s", diff)
324+
}
325+
})
326+
}
327+
}
328+
329+
func TestNextVersion(t *testing.T) {
330+
t.Parallel()
331+
for _, test := range []struct {
332+
name string
333+
commits []*conventionalcommits.ConventionalCommit
334+
currentVersion string
335+
overrideNextVersion string
336+
wantVersion string
337+
wantErr bool
338+
}{
339+
{
340+
name: "with override version",
341+
commits: []*conventionalcommits.ConventionalCommit{},
342+
currentVersion: "1.0.0",
343+
overrideNextVersion: "2.0.0",
344+
wantVersion: "2.0.0",
345+
wantErr: false,
346+
},
347+
{
348+
name: "without override version",
349+
commits: []*conventionalcommits.ConventionalCommit{
350+
{Type: "feat"},
351+
},
352+
currentVersion: "1.0.0",
353+
overrideNextVersion: "",
354+
wantVersion: "1.1.0",
355+
wantErr: false,
356+
},
357+
{
358+
name: "derive next returns error",
359+
commits: []*conventionalcommits.ConventionalCommit{
360+
{Type: "feat"},
361+
},
362+
currentVersion: "invalid-version",
363+
overrideNextVersion: "",
364+
wantVersion: "",
365+
wantErr: true,
366+
},
367+
{
368+
name: "breaking change on nested commit results in minor bump",
369+
commits: []*conventionalcommits.ConventionalCommit{
370+
{Type: "feat", IsBreaking: true, IsNested: true},
371+
},
372+
currentVersion: "1.2.3",
373+
overrideNextVersion: "",
374+
wantVersion: "1.3.0",
375+
wantErr: false,
376+
},
377+
{
378+
name: "major change before nested commit results in major bump",
379+
commits: []*conventionalcommits.ConventionalCommit{
380+
{Type: "feat", IsBreaking: true},
381+
{Type: "fix", IsNested: true},
382+
},
383+
currentVersion: "1.2.3",
384+
overrideNextVersion: "",
385+
wantVersion: "2.0.0",
386+
wantErr: false,
387+
},
388+
} {
389+
t.Run(test.name, func(t *testing.T) {
390+
gotVersion, err := NextVersion(test.commits, test.currentVersion, test.overrideNextVersion)
391+
if (err != nil) != test.wantErr {
392+
t.Errorf("NextVersion() error = %v, wantErr %v", err, test.wantErr)
393+
return
394+
}
395+
if gotVersion != test.wantVersion {
396+
t.Errorf("NextVersion() = %v, want %v", gotVersion, test.wantVersion)
397+
}
398+
})
399+
}
400+
}

0 commit comments

Comments
 (0)