Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Fixes #

Format of block header: <category> <target_group>
Possible values:
- category: breaking|feature|bugfix|doc|other
- category: breaking|feature|bugfix|refactor|doc|chore|other
- target_group: user|operator|developer|dependency
-->
```feature user
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release.lib.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ jobs:
app-id: 1312871
private-key: ${{ secrets.OPENMCP_CI_APP_PRIVATE_KEY }}

- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6
with:
go-version: '1.25'

- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
Expand Down
203 changes: 203 additions & 0 deletions changelog/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package main

import (
"encoding/json"
"fmt"
"os"
"regexp"
"slices"
"strings"
)

const (
releaseNotePattern = "```" + `(?<type>[a-zA-z]+)(\((?<subtype>[a-zA-Z]+)\))?\s*(?<audience>[a-zA-Z]+)\s*(\r)?\n(?<body>.*)\n` + "```"

SectionKeyOther = "other"
SubsectionKeyOther = "other"
)

var (
releaseNoteRegex = regexp.MustCompile(releaseNotePattern)
)

func main() {
if len(os.Args) != 2 {
panic("expected exactly one argument: path to PR info JSON file")
}

data, err := os.ReadFile(os.Args[1])
if err != nil {
panic(fmt.Sprintf("failed to read PR info file: %v", err))
}
prs := []PRInfo{}
if err := json.Unmarshal(data, &prs); err != nil {
panic(fmt.Errorf("failed to unmarshal PR info JSON: %w", err))
}

sections := NewSections().
WithSection("breaking", "🚨 Breaking").
WithSection("feature", "πŸš€ Features").
WithSection("bugfix", "πŸ› Bugfixes").
WithSection("refactor", "πŸ› οΈ Refactorings").
WithSection("doc", "πŸ“š Documentation").
WithSection("chore", "πŸ”§ Chores")

for _, pr := range prs {
prNotes := pr.ExtractReleaseNotes()
for _, note := range prNotes {
sections.Add(note)
}
}

fmt.Print(sections.Render())
}

type PRInfo struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
Author PRAuthor `json:"author"`
}

type PRAuthor struct {
ID string `json:"id"`
Name string `json:"name"`
Login string `json:"login"`
IsBot bool `json:"is_bot"`
}

type Sections struct {
CustomSections map[string]*Section
Other *Section
IterationOrder []string
}

type Section struct {
ID string
Title string
Notes []ReleaseNote
}

type ReleaseNote struct {
PRInfo *PRInfo
Note string
Type string
Subtype string
Audience string
}

func NewSections() *Sections {
ss := &Sections{
CustomSections: map[string]*Section{},
Other: NewSection(SectionKeyOther, "βž• Other"),
IterationOrder: []string{},
}
return ss
}

func (ss *Sections) WithSection(id, title string) *Sections {
section := NewSection(id, title)
ss.CustomSections[id] = section
ss.IterationOrder = append(ss.IterationOrder, id)
return ss
}

func NewSection(id, title string) *Section {
section := &Section{
ID: id,
Title: title,
Notes: []ReleaseNote{},
}
return section
}

func (ss Sections) Add(note ReleaseNote) {
section, ok := ss.CustomSections[note.Type]
if !ok {
section = ss.Other
}
section.Notes = append(section.Notes, note)
}

func (pri *PRInfo) ExtractReleaseNotes() []ReleaseNote {
res := []ReleaseNote{}
matches := releaseNoteRegex.FindAllStringSubmatch(pri.Body, -1)
for _, match := range matches {
note := ReleaseNote{
PRInfo: pri,
Note: normalizeLineEndings(match[releaseNoteRegex.SubexpIndex("body")]),
Type: strings.ToLower(match[releaseNoteRegex.SubexpIndex("type")]),
Subtype: strings.ToLower(match[releaseNoteRegex.SubexpIndex("subtype")]),
Audience: strings.ToLower(match[releaseNoteRegex.SubexpIndex("audience")]),
}
if note.Note == "" || (len(note.Note) <= 6 && strings.ToUpper(strings.TrimSpace(note.Note)) == "NONE") {
continue
}
res = append(res, note)
}
return res
}

func (ss *Sections) Render() string {
var sb strings.Builder
sb.WriteString("# Changelog\n\n\n")
for _, sid := range ss.IterationOrder {
section := ss.CustomSections[sid]
sb.WriteString(section.Render())
}
sb.WriteString(ss.Other.Render())
sb.WriteString("\n")
return sb.String()
}

func (s *Section) Render() string {
var sb strings.Builder
if len(s.Notes) == 0 {
return ""
}
sb.WriteString(fmt.Sprintf("## %s\n\n", s.Title))
notesByAudience, audienceOrder := orderNotesByAudience(s.Notes)
for _, audience := range audienceOrder {
notes := notesByAudience[audience]
sb.WriteString(fmt.Sprintf("#### [%s]\n", strings.ToUpper(audience)))
for _, note := range notes {
author := "@" + note.PRInfo.Author.Login
if note.PRInfo.Author.IsBot {
author = "βš™οΈ"
}
sb.WriteString(fmt.Sprintf("- %s **(#%d, %s)**\n", indent(strings.TrimSpace(note.Note), 2), note.PRInfo.Number, author))
}
}
sb.WriteString("\n")

return sb.String()
}

func normalizeLineEndings(s string) string {
return strings.ReplaceAll(s, "\r\n", "\n")
}

func indent(s string, spaces int) string {
prefix := strings.Repeat(" ", spaces)
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = prefix + line
}
return strings.Join(lines, "\n")
}

// orderNotesByAudience returns a mapping from audience to list of release notes for that audience
// and an alphabetically ordered list of audiences.
func orderNotesByAudience(notes []ReleaseNote) (map[string][]ReleaseNote, []string) {
notesByAudience := map[string][]ReleaseNote{}
for _, note := range notes {
notesByAudience[note.Audience] = append(notesByAudience[note.Audience], note)
}
audiences := []string{}
for audience := range notesByAudience {
audiences = append(audiences, audience)
}
slices.Sort(audiences)
return notesByAudience, audiences
}
101 changes: 18 additions & 83 deletions generate-changelog.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,105 +11,40 @@ if ! command -v gh &> /dev/null; then
fi

RELEASE_NOTES_TO_JSON_SCRIPT="$(realpath "$(dirname $0)/release-notes-to-json.sh")"
CHANGELOG_GENERATOR_SCRIPT="$(realpath "$(dirname $0)/changelog/main.go")"
cd $(dirname "$0")/../../

LATEST_RELEASE_TAG=$(gh release list --json tagName,isLatest --jq '.[] | select(.isLatest)|.tagName')
if [[ -z "$LATEST_RELEASE_TAG" ]]; then # first release?
LATEST_RELEASE_TAG=$(git rev-list --max-parents=0 HEAD) # first commit in the branch.
fi
LATEST_RELEASE_TAG="v0.2.0"
# LATEST_RELEASE_TAG=$(gh release list --json tagName,isLatest --jq '.[] | select(.isLatest)|.tagName')
# if [[ -z "$LATEST_RELEASE_TAG" ]]; then # first release?
# LATEST_RELEASE_TAG=$(git rev-list --max-parents=0 HEAD) # first commit in the branch.
# fi

GIT_LOG_OUTPUT=$(git log "$LATEST_RELEASE_TAG"..HEAD --oneline --pretty=format:"%s")
PR_COMMITS=$(echo "$GIT_LOG_OUTPUT" | grep -oE "#[0-9]+" || true | tr -d '#' | sort -u)

PR_INFO_FILE=$(mktemp)
echo "[" > "$PR_INFO_FILE"
CHANGELOG_FILE=./CHANGELOG.md
# File header Header
echo "# Changes included in $VERSION:" > "$CHANGELOG_FILE"
echo "" >> "$CHANGELOG_FILE"

declare -A SECTIONS
SECTIONS=(
[feat]="### πŸš€ Features"
[fix]="### πŸ› Fixes"
[chore]="### πŸ”§ Chores"
[docs]="### πŸ“š Documentation"
[refactor]="### πŸ”¨ Refactoring"
[test]="### βœ… Tests"
[perf]="### ⚑ Performance"
[ci]="### πŸ” CI"
)

# Prepare section buffers
declare -A PR_ENTRIES
for key in "${!SECTIONS[@]}"; do
PR_ENTRIES[$key]=""
done

is_first=true
for PR_NUMBER in $PR_COMMITS; do
echo "Getting info for PR $PR_NUMBER"
PR_JSON=$(gh pr view "$PR_NUMBER" --json number,title,body,url,author)
echo -n "Checking PR $PR_NUMBER"

IS_BOT=$(jq -r '.author.is_bot' <<< "$PR_JSON")
if [[ "$IS_BOT" == "true" ]]; then
echo " [skipping bot PR"]
continue
fi

PR_TITLE=$(jq -r '.title' <<< "$PR_JSON")
PR_URL=$(jq -r '.url' <<< "$PR_JSON")
PR_BODY=$(jq -r '.body' <<< "$PR_JSON")
echo " - $PR_TITLE"

# Determine type from conventional commit (assumes title like "type(scope): message" or "type: message")
TYPE=$(echo "$PR_TITLE" | grep -oE '^[a-z]+' || echo "feat")
CLEAN_TITLE=$(echo "$PR_TITLE" | sed -E 's/^[a-z]+(\([^)]+\))?(!)?:[[:space:]]+//')

# Extract release note block, this contains the release notes and the release notes headers.
# The last sed call is required to remove the carriage return characters (Github seems to use \r\n for new lines in PR bodies).
RELEASE_NOTE_BLOCK=$(echo "$PR_BODY" | sed -n '/\*\*Release note\*\*:/,$p' | sed -n '/^```.*$/,/^```$/p' | sed 's/\r//g')
# Extract release notes body
RELEASE_NOTE_JSON=$("$RELEASE_NOTES_TO_JSON_SCRIPT" <<< "$RELEASE_NOTE_BLOCK")

# skip PRs without release notes
if [[ "$RELEASE_NOTE_JSON" == "[]" ]]; then
echo " [ignoring PR without release notes]"
continue
fi

# Format release notes
# Updating NOTE_ENTRY in the loop does not work because it is executed in a subshell, therefore this workaround via echo.
NOTE_ENTRY="$(
jq -rc 'sort_by(.audience, .type) | .[]' <<< "$RELEASE_NOTE_JSON" | while IFS= read -r note; do
NOTE_TYPE=$(jq -r '.type' <<< "$note" | tr '[:lower:]' '[:upper:]')
NOTE_AUDIENCE=$(jq -r '.audience' <<< "$note" | tr '[:lower:]' '[:upper:]')
NOTE_BODY=$(jq -r '.body' <<< "$note")
echo -en "\n - **[$NOTE_AUDIENCE][$NOTE_TYPE]** ${NOTE_BODY//'\n'/'\n '}" # the parameter expansion is required to fix the indentation
done
)"

# Format entry
ENTRY="- $CLEAN_TITLE [#${PR_NUMBER}](${PR_URL})"

# Extract and format the release note headers.
HEADERS=$(echo "$PR_BODY" | sed -n '/\*\*Release note\*\*:/,$p' | sed -n '/^```.*$/,/^```$/p'| head -n 1 | sed 's/^```//')
FORMATED_HEADERS=$(echo "$HEADERS" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/\s\+/ /g' | sed 's/\(\S\+\)/[\1]/g')

ENTRY="- ${CLEAN_TITLE} [${PR_NUMBER}](${PR_URL})${NOTE_ENTRY}\n"

# Append to appropriate section
if [[ -n "${PR_ENTRIES[$TYPE]+x}" ]]; then
PR_ENTRIES[$TYPE]+="$ENTRY"
if [[ "$is_first" == true ]]; then
is_first=false
else
PR_ENTRIES[chore]+="$ENTRY"
echo "," >> "$PR_INFO_FILE"
fi
echo "$PR_JSON" >> "$PR_INFO_FILE"
done

# Output sections
for key in "${!SECTIONS[@]}"; do
if [[ -n "${PR_ENTRIES[$key]}" ]]; then
echo "${SECTIONS[$key]}" >> "$CHANGELOG_FILE"
echo -e "${PR_ENTRIES[$key]}" >> "$CHANGELOG_FILE"
echo "" >> "$CHANGELOG_FILE"
fi
done
echo "]" >> "$PR_INFO_FILE"

echo "Executing changelog generator for $PR_INFO_FILE ..."
go run "$CHANGELOG_GENERATOR_SCRIPT" "$PR_INFO_FILE" >> "$CHANGELOG_FILE"

cat "$CHANGELOG_FILE"