Skip to content

Commit 1f3b20a

Browse files
authored
feat(librarian): format release notes content for each library (#1827)
Format release notes (body of the release pull request) for each releasable unit (libraries) flagged with ReleaseTriggered. The release pull request description format expected is specified in [go/librarian:commits](http://goto.google.com/librarian:commits), see tag per library. Simplifications made for this first attempt: - only a per-library strategy for Python/Go is implemented for now. - Breaking e.g., “feat!” are not presented with a designated “breaking changes” section. (we do this today with release please). “feat!” and “feat”, “fix!” and “fix” are equivalent for release notes. Fixes #1697
1 parent 1df05c3 commit 1f3b20a

File tree

3 files changed

+431
-15
lines changed

3 files changed

+431
-15
lines changed

internal/librarian/mocks_test.go

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -201,21 +201,23 @@ func (m *mockContainerClient) ReleaseInit(ctx context.Context, request *docker.R
201201

202202
type MockRepository struct {
203203
gitrepo.Repository
204-
Dir string
205-
IsCleanValue bool
206-
IsCleanError error
207-
AddAllStatus git.Status
208-
AddAllError error
209-
CommitError error
210-
RemotesValue []*git.Remote
211-
RemotesError error
212-
CommitCalls int
213-
GetCommitsForPathsSinceTagValue []*gitrepo.Commit
214-
GetCommitsForPathsSinceTagError error
215-
ChangedFilesInCommitValue []string
216-
ChangedFilesInCommitError error
217-
CreateBranchAndCheckoutError error
218-
PushError error
204+
Dir string
205+
IsCleanValue bool
206+
IsCleanError error
207+
AddAllStatus git.Status
208+
AddAllError error
209+
CommitError error
210+
RemotesValue []*git.Remote
211+
RemotesError error
212+
CommitCalls int
213+
GetCommitsForPathsSinceTagValue []*gitrepo.Commit
214+
GetCommitsForPathsSinceTagValueByTag map[string][]*gitrepo.Commit
215+
GetCommitsForPathsSinceTagError error
216+
ChangedFilesInCommitValue []string
217+
ChangedFilesInCommitValueByHash map[string][]string
218+
ChangedFilesInCommitError error
219+
CreateBranchAndCheckoutError error
220+
PushError error
219221
}
220222

221223
func (m *MockRepository) IsClean() (bool, error) {
@@ -252,13 +254,23 @@ func (m *MockRepository) GetCommitsForPathsSinceTag(paths []string, tagName stri
252254
if m.GetCommitsForPathsSinceTagError != nil {
253255
return nil, m.GetCommitsForPathsSinceTagError
254256
}
257+
if m.GetCommitsForPathsSinceTagValueByTag != nil {
258+
if commits, ok := m.GetCommitsForPathsSinceTagValueByTag[tagName]; ok {
259+
return commits, nil
260+
}
261+
}
255262
return m.GetCommitsForPathsSinceTagValue, nil
256263
}
257264

258265
func (m *MockRepository) ChangedFilesInCommit(hash string) ([]string, error) {
259266
if m.ChangedFilesInCommitError != nil {
260267
return nil, m.ChangedFilesInCommitError
261268
}
269+
if m.ChangedFilesInCommitValueByHash != nil {
270+
if files, ok := m.ChangedFilesInCommitValueByHash[hash]; ok {
271+
return files, nil
272+
}
273+
}
262274
return m.ChangedFilesInCommitValue, nil
263275
}
264276

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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 librarian
16+
17+
import (
18+
"bytes"
19+
"fmt"
20+
"html/template"
21+
"strings"
22+
"time"
23+
24+
"github.com/googleapis/librarian/internal/cli"
25+
"github.com/googleapis/librarian/internal/config"
26+
"github.com/googleapis/librarian/internal/conventionalcommits"
27+
"github.com/googleapis/librarian/internal/github"
28+
"github.com/googleapis/librarian/internal/gitrepo"
29+
)
30+
31+
var (
32+
commitTypeToHeading = map[string]string{
33+
"feat": "Features",
34+
"fix": "Bug Fixes",
35+
"perf": "Performance Improvements",
36+
"revert": "Reverts",
37+
"docs": "Documentation",
38+
"style": "Styles",
39+
"chore": "Miscellaneous Chores",
40+
"refactor": "Code Refactoring",
41+
"test": "Tests",
42+
"build": "Build System",
43+
"ci": "Continuous Integration",
44+
}
45+
46+
// commitTypeOrder is the order in which commit types should appear in release notes.
47+
// Only these listed are included in release notes.
48+
commitTypeOrder = []string{
49+
"feat",
50+
"fix",
51+
"perf",
52+
"revert",
53+
"docs",
54+
}
55+
56+
releaseNotesTemplate = template.Must(template.New("releaseNotes").Funcs(template.FuncMap{
57+
"shortSHA": func(sha string) string {
58+
if len(sha) < 7 {
59+
return sha
60+
}
61+
return sha[:7]
62+
},
63+
}).Parse(`## [{{.NewVersion}}]({{"https://github.com/"}}{{.Repo.Owner}}/{{.Repo.Name}}/compare/{{.PreviousTag}}...{{.NewTag}}) ({{.Date}})
64+
{{- range .Sections -}}
65+
{{- if .Commits -}}
66+
{{- if .Heading}}
67+
68+
### {{.Heading}}
69+
{{end}}
70+
71+
{{- range .Commits -}}
72+
* {{.Description}} ([{{shortSHA .SHA}}]({{"https://github.com/"}}{{$.Repo.Owner}}/{{$.Repo.Name}}/commit/{{.SHA}}))
73+
{{- end -}}
74+
{{- end -}}
75+
{{- end -}}`))
76+
)
77+
78+
// FormatReleaseNotes generates the body for a release pull request.
79+
func FormatReleaseNotes(repo gitrepo.Repository, state *config.LibrarianState) (string, error) {
80+
var body bytes.Buffer
81+
82+
librarianVersion := cli.Version()
83+
fmt.Fprintf(&body, "Librarian Version: %s\n", librarianVersion)
84+
fmt.Fprintf(&body, "Language Image: %s\n\n", state.Image)
85+
86+
for _, library := range state.Libraries {
87+
if !library.ReleaseTriggered {
88+
continue
89+
}
90+
91+
notes, newVersion, err := formatLibraryReleaseNotes(repo, library)
92+
if err != nil {
93+
return "", fmt.Errorf("failed to format release notes for library %s: %w", library.ID, err)
94+
}
95+
fmt.Fprintf(&body, "<details><summary>%s: %s</summary>\n\n", library.ID, newVersion)
96+
97+
body.WriteString(notes)
98+
body.WriteString("\n\n</details>")
99+
100+
body.WriteString("\n")
101+
}
102+
return body.String(), nil
103+
}
104+
105+
// formatLibraryReleaseNotes generates release notes in Markdown format for a single library.
106+
// It returns the generated release notes and the new version string.
107+
func formatLibraryReleaseNotes(repo gitrepo.Repository, library *config.LibraryState) (string, string, error) {
108+
ghRepo, err := github.FetchGitHubRepoFromRemote(repo)
109+
if err != nil {
110+
return "", "", fmt.Errorf("failed to fetch github repo from remote: %w", err)
111+
}
112+
previousTag := formatTag(library, "")
113+
commits, err := GetConventionalCommitsSinceLastRelease(repo, library)
114+
if err != nil {
115+
return "", "", fmt.Errorf("failed to get conventional commits for library %s: %w", library.ID, err)
116+
}
117+
newVersion, err := NextVersion(commits, library.Version, "")
118+
if err != nil {
119+
return "", "", fmt.Errorf("failed to get next version for library %s: %w", library.ID, err)
120+
}
121+
newTag := formatTag(library, newVersion)
122+
123+
commitsByType := make(map[string][]*conventionalcommits.ConventionalCommit)
124+
for _, commit := range commits {
125+
commitsByType[commit.Type] = append(commitsByType[commit.Type], commit)
126+
}
127+
128+
type releaseNoteSection struct {
129+
Heading string
130+
Commits []*conventionalcommits.ConventionalCommit
131+
}
132+
var sections []releaseNoteSection
133+
// Group commits by type, according to commitTypeOrder, to be used in the release notes.
134+
for _, ct := range commitTypeOrder {
135+
displayName, headingOK := commitTypeToHeading[ct]
136+
typedCommits, commitsOK := commitsByType[ct]
137+
if headingOK && commitsOK {
138+
sections = append(sections, releaseNoteSection{
139+
Heading: displayName,
140+
Commits: typedCommits,
141+
})
142+
}
143+
}
144+
145+
var out bytes.Buffer
146+
data := struct {
147+
NewVersion string
148+
PreviousTag string
149+
NewTag string
150+
Repo *github.Repository
151+
Date string
152+
Sections []releaseNoteSection
153+
}{
154+
NewVersion: newVersion,
155+
PreviousTag: previousTag,
156+
NewTag: newTag,
157+
Repo: ghRepo,
158+
Date: time.Now().Format("2006-01-02"),
159+
Sections: sections,
160+
}
161+
if err := releaseNotesTemplate.Execute(&out, data); err != nil {
162+
// This should not happen, as the template is valid and the data is structured correctly.
163+
return "", "", fmt.Errorf("error executing template: %v", err)
164+
}
165+
166+
return strings.TrimSpace(out.String()), newVersion, nil
167+
}

0 commit comments

Comments
 (0)