1515package librarian
1616
1717import (
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
4245var (
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
4759type 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+
5280func 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.
217235func 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+ }
0 commit comments