diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1fe33e5..5f7ada1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,7 +12,7 @@ Fixes # Format of block header: Possible values: -- category: breaking|feature|bugfix|doc|other +- category: breaking|feature|bugfix|refactor|doc|chore|other - target_group: user|operator|developer|dependency --> ```feature user diff --git a/.github/workflows/release.lib.yaml b/.github/workflows/release.lib.yaml index e255f1a..96c2278 100644 --- a/.github/workflows/release.lib.yaml +++ b/.github/workflows/release.lib.yaml @@ -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: diff --git a/changelog/main.go b/changelog/main.go new file mode 100644 index 0000000..339fc7a --- /dev/null +++ b/changelog/main.go @@ -0,0 +1,203 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "slices" + "strings" +) + +const ( + releaseNotePattern = "```" + `(?[a-zA-z]+)(\((?[a-zA-Z]+)\))?\s*(?[a-zA-Z]+)\s*(\r)?\n(?.*)\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 +} diff --git a/generate-changelog.sh b/generate-changelog.sh index 55cd0ba..6249a4b 100755 --- a/generate-changelog.sh +++ b/generate-changelog.sh @@ -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"