Skip to content

Commit dd24921

Browse files
authored
fix: associate bulk change to individual libraries (#2626)
Fixes #2609
1 parent 6dbec18 commit dd24921

File tree

3 files changed

+241
-31
lines changed

3 files changed

+241
-31
lines changed

internal/librarian/release_notes_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,8 +551,8 @@ func TestLanguageRepoChangedFiles(t *testing.T) {
551551
IsCleanValue: true,
552552
HeadHashValue: "1234",
553553
ChangedFilesInCommitValueByHash: map[string][]string{
554-
"abcd": []string{"a/b/c", "d/e/f"},
555-
"1234": []string{"g/h/i", "j/k/l"},
554+
"abcd": {"a/b/c", "d/e/f"},
555+
"1234": {"g/h/i", "j/k/l"},
556556
},
557557
},
558558
want: []string{"g/h/i", "j/k/l"},

internal/librarian/tag_and_release.go

Lines changed: 157 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,17 @@
1515
package librarian
1616

1717
import (
18+
"bytes"
1819
"context"
1920
"errors"
2021
"fmt"
22+
"html/template"
2123
"log/slog"
2224
"net/url"
2325
"path/filepath"
2426
"regexp"
2527
"slices"
28+
"sort"
2629
"strconv"
2730
"strings"
2831
"time"
@@ -40,15 +43,40 @@ const (
4043
)
4144

4245
var (
43-
detailsRegex = regexp.MustCompile(`(?s)<details><summary>(.*?)</summary>(.*?)</details>`)
44-
summaryRegex = regexp.MustCompile(`(.*?): (v?\d+\.\d+\.\d+)`)
46+
bulkChangeSectionRegex = regexp.MustCompile(`(feat|fix|perf|revert|docs): (.*)\nLibraries: (.*)`)
47+
contentRegex = regexp.MustCompile(`### (Features|Bug Fixes|Performance Improvements|Reverts|Documentation)\n`)
48+
detailsRegex = regexp.MustCompile(`(?s)<details><summary>(.*?)</summary>(.*?)</details>`)
49+
summaryRegex = regexp.MustCompile(`(.*?): (v?\d+\.\d+\.\d+)`)
50+
51+
libraryReleaseTemplate = template.Must(template.New("libraryRelease").Parse(`### {{.Type}}
52+
{{ range .Messages }}
53+
{{.}}
54+
{{ end }}
55+
56+
`))
4557
)
4658

4759
type tagAndReleaseRunner struct {
4860
ghClient GitHubClient
4961
pullRequest string
5062
}
5163

64+
// libraryRelease holds the parsed information from a pull request body.
65+
type libraryRelease struct {
66+
// Body contains the release notes.
67+
Body string
68+
// Library is the library id of the library being released
69+
Library string
70+
// Version is the version that is being released
71+
Version string
72+
}
73+
74+
type libraryReleaseBuilder struct {
75+
typeToMessages map[string][]string
76+
title string
77+
version string
78+
}
79+
5280
func newTagAndReleaseRunner(cfg *config.Config) (*tagAndReleaseRunner, error) {
5381
if cfg.GitHubToken == "" {
5482
return nil, fmt.Errorf("`%s` must be set", config.LibrarianGithubToken)
@@ -203,43 +231,86 @@ func (r *tagAndReleaseRunner) processPullRequest(ctx context.Context, p *github.
203231
return r.replacePendingLabel(ctx, p)
204232
}
205233

206-
// libraryRelease holds the parsed information from a pull request body.
207-
type libraryRelease struct {
208-
// Body contains the release notes.
209-
Body string
210-
// Library is the library id of the library being released
211-
Library string
212-
// Version is the version that is being released
213-
Version string
214-
}
215-
216234
// parsePullRequestBody parses a string containing release notes and returns a slice of ParsedPullRequestBody.
217235
func parsePullRequestBody(body string) []libraryRelease {
218236
slog.Info("parsing pull request body")
219-
var parsedBodies []libraryRelease
237+
idToBuilder := make(map[string]*libraryReleaseBuilder)
220238
matches := detailsRegex.FindAllStringSubmatch(body, -1)
221239
for _, match := range matches {
222240
summary := match[1]
241+
content := strings.TrimSpace(match[2])
223242
if summary == "Bulk Changes" {
243+
// Associated bulk changes to individual libraries.
244+
sections := bulkChangeSectionRegex.FindAllStringSubmatch(content, -1)
245+
for _, section := range sections {
246+
if len(section) != 4 {
247+
slog.Warn("bulk change does not associated with a library id", "content", section)
248+
continue
249+
}
250+
251+
commitType, ok := commitTypeToHeading[strings.TrimSpace(section[1])]
252+
if !ok {
253+
slog.Warn("unrecognized commit type, skipping", "commit", section[1])
254+
continue
255+
}
256+
message := fmt.Sprintf("* %s", strings.TrimSpace(section[2]))
257+
libraries := section[3]
258+
for _, library := range strings.Split(libraries, ",") {
259+
// Bulk change doesn't have title and version, put an empty string so that
260+
// title and version are not overwritten, if exists.
261+
updateLibraryReleaseBuilder(idToBuilder, library, commitType, "", message, "")
262+
}
263+
}
264+
224265
continue
225266
}
226-
content := strings.TrimSpace(match[2])
227267

228-
summaryMatches := summaryRegex.FindStringSubmatch(summary)
229-
if len(summaryMatches) == 3 {
230-
slog.Info("parsed pull request body", "library", summaryMatches[1], "version", summaryMatches[2])
231-
library := strings.TrimSpace(summaryMatches[1])
232-
version := strings.TrimSpace(summaryMatches[2])
233-
parsedBodies = append(parsedBodies, libraryRelease{
234-
Version: version,
235-
Library: library,
236-
Body: content,
237-
})
238-
} else {
268+
summaryMatch := summaryRegex.FindStringSubmatch(summary)
269+
if len(summaryMatch) != 3 {
239270
slog.Warn("failed to parse pull request body", "match", strings.Join(match, "\n"))
271+
continue
272+
}
273+
274+
slog.Info("parsed pull request body", "library", summaryMatch[1], "version", summaryMatch[2])
275+
library := strings.TrimSpace(summaryMatch[1])
276+
version := strings.TrimSpace(summaryMatch[2])
277+
// Split the content using commit types, e.g., Features, Bug Fixes, etc.
278+
// For non-bulk changes, the first match (i = 0) is the release title, the i-th match is
279+
// the commit messages of typeMatches[i-1].
280+
contentMatches := contentRegex.Split(content, -1)
281+
title := contentMatches[0]
282+
typeMatches := contentRegex.FindAllStringSubmatch(content, -1)
283+
if len(typeMatches) == 0 {
284+
// No commit message in a library.
285+
updateLibraryReleaseBuilder(idToBuilder, library, "", title, "", version)
286+
}
287+
for i, typeMatch := range typeMatches {
288+
commitType := typeMatch[1]
289+
contentMatch := contentMatches[i+1]
290+
messages := strings.Split(contentMatch, "\n\n")
291+
for _, message := range messages {
292+
message = strings.TrimSpace(message)
293+
if message != "" {
294+
updateLibraryReleaseBuilder(idToBuilder, library, commitType, title, message, version)
295+
}
296+
}
240297
}
298+
299+
}
300+
301+
var parsedBodies []libraryRelease
302+
for libraryID, builder := range idToBuilder {
303+
parsedBodies = append(parsedBodies, libraryRelease{
304+
Body: buildReleaseBody(builder.typeToMessages, builder.title),
305+
Library: libraryID,
306+
Version: builder.version,
307+
})
241308
}
242309

310+
sort.Slice(parsedBodies, func(i, j int) bool {
311+
return parsedBodies[i].Library < parsedBodies[j].Library
312+
})
313+
243314
return parsedBodies
244315
}
245316

@@ -258,3 +329,64 @@ func (r *tagAndReleaseRunner) replacePendingLabel(ctx context.Context, p *github
258329
}
259330
return nil
260331
}
332+
333+
// updateLibraryReleaseBuilder finds or creates a libraryReleaseBuilder for a given library
334+
// and updates it with new information.
335+
func updateLibraryReleaseBuilder(idToVersionAndBody map[string]*libraryReleaseBuilder, library, commitType, title, message, version string) {
336+
vab, ok := idToVersionAndBody[library]
337+
if !ok {
338+
idToVersionAndBody[library] = &libraryReleaseBuilder{
339+
typeToMessages: map[string][]string{
340+
commitType: {message},
341+
},
342+
version: version,
343+
title: title,
344+
}
345+
346+
return
347+
}
348+
349+
vab.typeToMessages[commitType] = append(vab.typeToMessages[commitType], message)
350+
if version == "" {
351+
version = vab.version
352+
}
353+
vab.version = version
354+
if title == "" {
355+
title = vab.title
356+
}
357+
vab.title = title
358+
}
359+
360+
// buildReleaseBody formats the release notes for a single library.
361+
//
362+
// It takes a map of commit types (e.g., "Features", "Bug Fixes") to their corresponding messages and a title string.
363+
// It returns a formatted string containing the title and all commit messages organized by type, following the order
364+
// defined in commitTypeOrder.
365+
func buildReleaseBody(body map[string][]string, title string) string {
366+
var builder strings.Builder
367+
builder.WriteString(title)
368+
for _, commitType := range commitTypeOrder {
369+
heading := commitTypeToHeading[commitType]
370+
messages, ok := body[heading]
371+
if !ok {
372+
continue
373+
}
374+
var out bytes.Buffer
375+
data := &struct {
376+
Type string
377+
Messages []string
378+
}{
379+
Type: heading,
380+
Messages: messages,
381+
}
382+
if err := libraryReleaseTemplate.Execute(&out, data); err != nil {
383+
slog.Error("error executing template", "error", err)
384+
continue
385+
}
386+
387+
builder.WriteString(strings.TrimSpace(out.String()))
388+
builder.WriteString("\n\n")
389+
}
390+
391+
return strings.TrimSpace(builder.String())
392+
}

internal/librarian/tag_and_release_test.go

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,9 @@ Language Image: gcr.io/test/image:latest
263263
264264
</details>
265265
266-
<details><summary>another-library-name: 2.3.4</summary>
266+
<details><summary>library-two: 2.3.4</summary>
267267
268-
[2.3.4](https://github.com/googleapis/repo/compare/another-library-name-v2.3.3...another-library-name-v2.3.4) (2025-08-15)
268+
[2.3.4](https://github.com/googleapis/repo/compare/library-two-v2.3.3...library-two-v2.3.4) (2025-08-15)
269269
270270
### Bug Fixes
271271
@@ -284,8 +284,8 @@ Language Image: gcr.io/test/image:latest
284284
},
285285
{
286286
Version: "2.3.4",
287-
Library: "another-library-name",
288-
Body: `[2.3.4](https://github.com/googleapis/repo/compare/another-library-name-v2.3.3...another-library-name-v2.3.4) (2025-08-15)
287+
Library: "library-two",
288+
Body: `[2.3.4](https://github.com/googleapis/repo/compare/library-two-v2.3.3...library-two-v2.3.4) (2025-08-15)
289289
290290
### Bug Fixes
291291
@@ -350,6 +350,84 @@ some content
350350
},
351351
},
352352
},
353+
{
354+
name: "bulk_changes_appears_in_github_release",
355+
body: `
356+
<details><summary>google-cloud-storage: v1.2.3</summary>
357+
358+
[v1.2.3](https://github.com/googleapis/google-cloud-go/compare/google-cloud-storage-v1.2.2...google-cloud-storage-v1.2.3) (2025-08-15)
359+
360+
### Features
361+
362+
* add SaveToGcsFindingsOutput ([1234567](https://github.com/googleapis/google-cloud-go/commit/1234567))
363+
364+
* another feature ([9876543](https://github.com/googleapis/google-cloud-go/commit/9876543))
365+
366+
### Documentation
367+
368+
* minor doc revision ([abcdefgh](https://github.com/googleapis/google-cloud-go/commit/abcdefgh))
369+
370+
</details>
371+
372+
373+
<details><summary>Bulk Changes</summary>
374+
375+
* feat: this is a bulk change ([abcdefgh](https://github.com/googleapis/google-cloud-go/commit/abcdefgh))
376+
Libraries: a,b,google-cloud-storage
377+
378+
* fix: this is another bulk change
379+
Libraries: a,b,c
380+
381+
</details>`,
382+
want: []libraryRelease{
383+
{
384+
Version: "",
385+
Library: "a",
386+
Body: `### Features
387+
388+
* this is a bulk change ([abcdefgh](https://github.com/googleapis/google-cloud-go/commit/abcdefgh))
389+
390+
### Bug Fixes
391+
392+
* this is another bulk change`,
393+
},
394+
{
395+
Version: "",
396+
Library: "b",
397+
Body: `### Features
398+
399+
* this is a bulk change ([abcdefgh](https://github.com/googleapis/google-cloud-go/commit/abcdefgh))
400+
401+
### Bug Fixes
402+
403+
* this is another bulk change`,
404+
},
405+
{
406+
Version: "",
407+
Library: "c",
408+
Body: `### Bug Fixes
409+
410+
* this is another bulk change`,
411+
},
412+
{
413+
Version: "v1.2.3",
414+
Library: "google-cloud-storage",
415+
Body: `[v1.2.3](https://github.com/googleapis/google-cloud-go/compare/google-cloud-storage-v1.2.2...google-cloud-storage-v1.2.3) (2025-08-15)
416+
417+
### Features
418+
419+
* add SaveToGcsFindingsOutput ([1234567](https://github.com/googleapis/google-cloud-go/commit/1234567))
420+
421+
* another feature ([9876543](https://github.com/googleapis/google-cloud-go/commit/9876543))
422+
423+
* this is a bulk change ([abcdefgh](https://github.com/googleapis/google-cloud-go/commit/abcdefgh))
424+
425+
### Documentation
426+
427+
* minor doc revision ([abcdefgh](https://github.com/googleapis/google-cloud-go/commit/abcdefgh))`,
428+
},
429+
},
430+
},
353431
} {
354432
t.Run(test.name, func(t *testing.T) {
355433
got := parsePullRequestBody(test.body)

0 commit comments

Comments
 (0)