Skip to content

Commit d726e0c

Browse files
committed
change release note generation
1 parent 5ffe5c0 commit d726e0c

File tree

3 files changed

+304
-84
lines changed

3 files changed

+304
-84
lines changed

.github/pull_request_template.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Fixes #
1212
1313
Format of block header: <category> <target_group>
1414
Possible values:
15-
- category: breaking|feature|bugfix|doc|other
15+
- category: breaking|feature|bugfix|refactor|doc|chore|other
1616
- target_group: user|operator|developer|dependency
1717
-->
1818
```feature user

changelog/main.go

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"regexp"
8+
"slices"
9+
"strings"
10+
)
11+
12+
const (
13+
releaseNotePattern = "```" + `(?<type>[a-zA-z]+)(\((?<subtype>[a-zA-Z]+)\))?\s*(?<audience>[a-zA-Z]+)\s*(\r)?\n(?<body>.*)\n` + "```"
14+
15+
SectionKeyOther = "other"
16+
SubsectionKeyOther = "other"
17+
)
18+
19+
var (
20+
releaseNoteRegex = regexp.MustCompile(releaseNotePattern)
21+
)
22+
23+
func main() {
24+
if len(os.Args) != 2 {
25+
panic("expected exactly one argument: path to PR info JSON file")
26+
}
27+
28+
data, err := os.ReadFile(os.Args[1])
29+
if err != nil {
30+
panic(fmt.Sprintf("failed to read PR info file: %v", err))
31+
}
32+
prs := []PRInfo{}
33+
if err := json.Unmarshal(data, &prs); err != nil {
34+
panic(fmt.Errorf("failed to unmarshal PR info JSON: %w", err))
35+
}
36+
37+
sections := NewSections().
38+
WithSection("breaking", "🚨 Breaking", nil).
39+
WithSection("feature", "🚀 Features", nil).
40+
WithSection("bugfix", "🐛 Bugfixes", nil).
41+
WithSection("refactor", "🛠️ Refactorings", nil).
42+
WithSection("doc", "📚 Documentation", nil).
43+
WithSection("chore", "🔧 Chores", nil)
44+
45+
for _, pr := range prs {
46+
prNotes := pr.ExtractReleaseNotes()
47+
for _, note := range prNotes {
48+
sections.Add(note)
49+
}
50+
}
51+
52+
fmt.Print(sections.Render())
53+
}
54+
55+
type PRInfo struct {
56+
Number int `json:"number"`
57+
Title string `json:"title"`
58+
Body string `json:"body"`
59+
URL string `json:"url"`
60+
Author PRAuthor `json:"author"`
61+
}
62+
63+
type PRAuthor struct {
64+
ID string `json:"id"`
65+
Name string `json:"name"`
66+
Login string `json:"login"`
67+
IsBot bool `json:"is_bot"`
68+
}
69+
70+
type Sections struct {
71+
CustomSections map[string]*Section
72+
Other *Section
73+
IterationOrder []string
74+
}
75+
76+
type Section struct {
77+
ID string
78+
Title string
79+
Notes []ReleaseNote
80+
Subsections *Subsections
81+
}
82+
83+
type Subsections struct {
84+
CustomSubsections map[string]*Subsection
85+
Other *Subsection
86+
}
87+
88+
type Subsection struct {
89+
ID string
90+
Title string
91+
Notes []ReleaseNote
92+
}
93+
94+
type ReleaseNote struct {
95+
PRInfo *PRInfo
96+
Note string
97+
Type string
98+
Subtype string
99+
Audience string
100+
}
101+
102+
func NewSections() *Sections {
103+
ss := &Sections{
104+
CustomSections: map[string]*Section{},
105+
Other: NewSection(SectionKeyOther, "➕ Other", nil),
106+
IterationOrder: []string{},
107+
}
108+
return ss
109+
}
110+
111+
func (ss *Sections) WithSection(id, title string, subsections map[string]string) *Sections {
112+
section := NewSection(id, title, subsections)
113+
ss.CustomSections[id] = section
114+
ss.IterationOrder = append(ss.IterationOrder, id)
115+
return ss
116+
}
117+
118+
func NewSection(id, title string, subsections map[string]string) *Section {
119+
section := &Section{
120+
ID: id,
121+
Title: title,
122+
Notes: []ReleaseNote{},
123+
Subsections: NewSubsections(subsections),
124+
}
125+
return section
126+
}
127+
128+
func NewSubsections(subsections map[string]string) *Subsections {
129+
ss := &Subsections{
130+
CustomSubsections: map[string]*Subsection{},
131+
Other: NewSubsection(SubsectionKeyOther, "➕ Other"),
132+
}
133+
for key, title := range subsections {
134+
ss.CustomSubsections[key] = NewSubsection(key, title)
135+
}
136+
return ss
137+
}
138+
139+
func NewSubsection(id, title string) *Subsection {
140+
return &Subsection{
141+
ID: id,
142+
Title: title,
143+
Notes: []ReleaseNote{},
144+
}
145+
}
146+
147+
func (ss Sections) Add(note ReleaseNote) {
148+
section, ok := ss.CustomSections[note.Type]
149+
if !ok {
150+
section = ss.Other
151+
}
152+
var subsection *Subsection
153+
if note.Subtype != "" {
154+
subsection, ok = section.Subsections.CustomSubsections[note.Subtype]
155+
if !ok {
156+
subsection = section.Subsections.Other
157+
}
158+
subsection.Notes = append(subsection.Notes, note)
159+
} else {
160+
section.Notes = append(section.Notes, note)
161+
}
162+
}
163+
164+
func (pri *PRInfo) ExtractReleaseNotes() []ReleaseNote {
165+
res := []ReleaseNote{}
166+
matches := releaseNoteRegex.FindAllStringSubmatch(pri.Body, -1)
167+
for _, match := range matches {
168+
note := ReleaseNote{
169+
PRInfo: pri,
170+
Note: normalizeLineEndings(match[releaseNoteRegex.SubexpIndex("body")]),
171+
Type: strings.ToLower(match[releaseNoteRegex.SubexpIndex("type")]),
172+
Subtype: strings.ToLower(match[releaseNoteRegex.SubexpIndex("subtype")]),
173+
Audience: strings.ToLower(match[releaseNoteRegex.SubexpIndex("audience")]),
174+
}
175+
if note.Note == "" || (len(note.Note) <= 6 && strings.ToUpper(strings.TrimSpace(note.Note)) == "NONE") {
176+
continue
177+
}
178+
res = append(res, note)
179+
}
180+
return res
181+
}
182+
183+
func (ss *Sections) Render() string {
184+
var sb strings.Builder
185+
sb.WriteString("# Changelog\n\n\n")
186+
for _, sid := range ss.IterationOrder {
187+
section := ss.CustomSections[sid]
188+
sb.WriteString(section.Render())
189+
}
190+
sb.WriteString(ss.Other.Render())
191+
sb.WriteString("\n")
192+
return sb.String()
193+
}
194+
195+
func (s *Section) Render() string {
196+
var sb strings.Builder
197+
if len(s.Notes) == 0 && !s.Subsections.HasNotes() {
198+
return ""
199+
}
200+
sb.WriteString(fmt.Sprintf("## %s\n\n", s.Title))
201+
notesByAudience, audienceOrder := orderNotesByAudience(s.Notes)
202+
for _, audience := range audienceOrder {
203+
notes := notesByAudience[audience]
204+
sb.WriteString(fmt.Sprintf("#### [%s]\n", strings.ToUpper(audience)))
205+
for _, note := range notes {
206+
author := "@" + note.PRInfo.Author.Login
207+
if note.PRInfo.Author.IsBot {
208+
author = "⚙️"
209+
}
210+
sb.WriteString(fmt.Sprintf("- %s **(#%d, %s)**\n", indent(strings.TrimSpace(note.Note), 2), note.PRInfo.Number, author))
211+
}
212+
}
213+
sb.WriteString("\n")
214+
215+
for _, subsection := range s.Subsections.CustomSubsections {
216+
sb.WriteString(subsection.Render())
217+
}
218+
sb.WriteString(s.Subsections.Other.Render())
219+
return sb.String()
220+
}
221+
222+
func (ss *Subsections) HasNotes() bool {
223+
if ss == nil {
224+
return false
225+
}
226+
if len(ss.Other.Notes) > 0 {
227+
return true
228+
}
229+
for _, subsection := range ss.CustomSubsections {
230+
if len(subsection.Notes) > 0 {
231+
return true
232+
}
233+
}
234+
return false
235+
}
236+
237+
func (s *Subsection) Render() string {
238+
var sb strings.Builder
239+
if len(s.Notes) == 0 {
240+
return ""
241+
}
242+
sb.WriteString(fmt.Sprintf("### %s\n\n", s.Title))
243+
notesByAudience, audienceOrder := orderNotesByAudience(s.Notes)
244+
for _, audience := range audienceOrder {
245+
notes := notesByAudience[audience]
246+
sb.WriteString(fmt.Sprintf("#### [%s]\n", strings.ToUpper(audience)))
247+
for _, note := range notes {
248+
author := "@" + note.PRInfo.Author.Login
249+
if note.PRInfo.Author.IsBot {
250+
author = "⚙️"
251+
}
252+
sb.WriteString(fmt.Sprintf("- %s **(#%d, %s)**\n", indent(strings.TrimSpace(note.Note), 2), note.PRInfo.Number, author))
253+
}
254+
}
255+
sb.WriteString("\n")
256+
return sb.String()
257+
}
258+
259+
func normalizeLineEndings(s string) string {
260+
return strings.ReplaceAll(s, "\r\n", "\n")
261+
}
262+
263+
func indent(s string, spaces int) string {
264+
prefix := strings.Repeat(" ", spaces)
265+
lines := strings.Split(s, "\n")
266+
for i, line := range lines {
267+
lines[i] = prefix + line
268+
}
269+
return strings.Join(lines, "\n")
270+
}
271+
272+
// orderNotesByAudience returns a mapping from audience to list of release notes for that audience
273+
// and an alphabetically ordered list of audiences.
274+
func orderNotesByAudience(notes []ReleaseNote) (map[string][]ReleaseNote, []string) {
275+
notesByAudience := map[string][]ReleaseNote{}
276+
for _, note := range notes {
277+
notesByAudience[note.Audience] = append(notesByAudience[note.Audience], note)
278+
}
279+
audiences := []string{}
280+
for audience := range notesByAudience {
281+
audiences = append(audiences, audience)
282+
}
283+
slices.Sort(audiences)
284+
return notesByAudience, audiences
285+
}

0 commit comments

Comments
 (0)