Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
de66dc7
feat: add super-chapters support for grouping chapters by label
miroslavpojer Apr 2, 2026
8895bc2
feat: implement super-chapters functionality with uncategorized fallback
miroslavpojer Apr 2, 2026
b5b068c
fix: improve unclaimed IDs handling and normalize label input in Cust…
miroslavpojer Apr 2, 2026
d9291bc
Fixed review comments.
miroslavpojer Apr 2, 2026
d0250fd
Fix review comments.
miroslavpojer Apr 2, 2026
8679c2a
feat: enhance hierarchy issue rendering logic for open and closed par…
miroslavpojer Apr 2, 2026
30754f3
feat: add super-chapters support for grouping chapters by label
miroslavpojer Apr 2, 2026
203a8cf
Fixed after merge issues.
miroslavpojer Apr 6, 2026
b93b0db
Implemented partial split by super chapter label.
miroslavpojer Apr 6, 2026
abfb0ff
fix: simplify comment in CustomChapters class for clarity
miroslavpojer Apr 6, 2026
20cf902
Merge branch 'master' into fature/99-Super-chapters
miroslavpojer Apr 6, 2026
cd6cadc
refactor: streamline method calls in CustomChapters and HierarchyIssu…
miroslavpojer Apr 6, 2026
bafb0eb
Fixed review comments.
miroslavpojer Apr 6, 2026
583978f
Self-review and reduction of pylint exceptions.
miroslavpojer Apr 6, 2026
69d5b2d
Fixed black.
miroslavpojer Apr 6, 2026
4666b0d
refactor: update type hints in CustomChapters and HierarchyIssueRecor…
miroslavpojer Apr 6, 2026
2514d44
Fix review notes.
miroslavpojer Apr 6, 2026
f72bcc5
Fix review comments.
miroslavpojer Apr 6, 2026
2da077c
Fix review comments.
miroslavpojer Apr 6, 2026
8382e55
Fixed review comments.
miroslavpojer Apr 6, 2026
85774a5
Fixed review comment.
miroslavpojer Apr 6, 2026
bf1d759
Add caching for super-chapters input to optimize parsing
miroslavpojer Apr 7, 2026
d7a11ca
Fixed review comments.
miroslavpojer Apr 7, 2026
a6490c2
Fix review notes.
miroslavpojer Apr 7, 2026
fdd73c9
Fixed review comment.
miroslavpojer Apr 7, 2026
bc4354a
Refactor docstrings to improve parameter descriptions and return valu…
miroslavpojer Apr 7, 2026
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
9 changes: 9 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ inputs:
At most one `catch-open-hierarchy` chapter allowed; duplicates are warned and ignored.
required: false
default: ''
super-chapters:
description: |
YAML array of super-chapter definitions that group regular chapters by label.
Required keys: `title`, `label` or `labels`.
Records matching a super-chapter label are rendered inside that super-chapter.
A record can appear in multiple super-chapters.
required: false
default: ''
duplicity-scope:
description: 'Allow duplicity of issue lines in chapters. Scopes: custom, service, both, none.'
required: false
Expand Down Expand Up @@ -177,6 +185,7 @@ runs:
INPUT_GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
INPUT_TAG_NAME: ${{ inputs.tag-name }}
INPUT_CHAPTERS: ${{ inputs.chapters }}
INPUT_SUPER_CHAPTERS: ${{ inputs.super-chapters }}
INPUT_FROM_TAG_NAME: ${{ inputs.from-tag-name }}
INPUT_HIERARCHY: ${{ inputs.hierarchy }}
INPUT_DUPLICITY_SCOPE: ${{ inputs.duplicity-scope }}
Expand Down
1 change: 1 addition & 0 deletions docs/configuration_reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ This page lists all action inputs and outputs with defaults. Grouped for readabi
| `row-format-issue` | No | `{type}: {number} _{title}_ developed by {developers} in {pull-requests}` | Template for issue rows. |
| `row-format-pr` | No | `{number} _{title}_ developed by {developers}` | Template for PR rows. |
| `row-format-link-pr` | No | `true` | If true adds `PR:` prefix when a PR is listed without an issue. |
| `super-chapters` | No | "" | YAML multi-line list of super-chapter entries (`title` + `label`/`labels`). Groups regular chapters under higher-level headings by label. See [Super Chapters](features/custom_chapters.md#super-chapters). |

> CodeRabbit summaries must already be present in the PR body (produced by your own CI/App setup). This action only parses existing summaries; it does not configure or call CodeRabbit.

Expand Down
49 changes: 49 additions & 0 deletions docs/features/custom_chapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,55 @@ A `catch-open-hierarchy` chapter can also be `hidden: true` to silently track op
- Duplicate `catch-open-hierarchy: true` chapters are reduced to the first; a warning is logged for the rest.
- When `hierarchy: false`, a warning is logged once at populate time: `"catch-open-hierarchy has no effect when hierarchy is disabled"`.

## Super Chapters

**Super chapters** group regular chapters under higher-level headings based on a separate label. This is useful in monorepo or multi-module projects where the same chapter structure (Features, Bugfixes, …) should appear once per component.

### Configuration

Define super chapters via the `super-chapters` input — a YAML array with `title` and `label`/`labels`:

```yaml
with:
super-chapters: |
- title: "Atum Server"
label: "atum-server"
- title: "Atum Agent"
labels: "atum-agent, atum-agent-spark"
chapters: |
- {"title": "Enhancements", "label": "enhancement"}
- {"title": "Bugfixes", "label": "bug"}
```

### Rendering

When super chapters are configured the output uses `##` headings for super chapters and `###` for regular chapters nested inside:

```markdown
## Atum Server
### Enhancements
- #10 _Streaming API_ developed by @alice in #12

### Bugfixes
- #15 _Fix timeout_ developed by @bob in #16

## Atum Agent
### Enhancements
- #20 _Checkpointing_ developed by @carol in #22
```

### Behavior
- A record is placed under a super chapter if it carries at least one label matching the super chapter's labels.
- A record can appear in **multiple** super chapters if its labels match more than one.
- Within each super chapter, records are routed to regular chapters by the normal label matching rules.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- Empty super chapters (no matching records) respect the `print-empty-chapters` setting.
- When no super chapters are configured, output is flat (unchanged from previous behavior).

### Validation
- Entries missing `title` or `label`/`labels` are skipped with a warning.
- Non-dict entries are skipped with a warning.
- Empty labels after normalization cause the entry to be skipped with a warning.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Related Features
- [Duplicity Handling](./duplicity_handling.md) – governs multi-chapter visibility.
- [Release Notes Extraction](./release_notes_extraction.md) – provides the change increment lines.
Expand Down
28 changes: 26 additions & 2 deletions release_notes_generator/action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,14 @@
import os
import sys
import re

import yaml

from release_notes_generator.utils.constants import (
GITHUB_REPOSITORY,
GITHUB_TOKEN,
TAG_NAME,
CHAPTERS,
SUPER_CHAPTERS,
PUBLISHED_AT,
VERBOSE,
WARNINGS,
Expand Down Expand Up @@ -166,11 +166,34 @@ def get_chapters() -> list[dict[str, str]]:
logger.error("Error: 'chapters' input is not a valid YAML list.")
return []
except yaml.YAMLError as exc:
logger.error("Error parsing 'chapters' input: {%s}", exc)
logger.error("Error parsing 'chapters' input: %s", exc)
return []

return chapters

@staticmethod
def get_super_chapters() -> list[dict[str, str]]:
"""
Get list of super chapter definitions from the action inputs.
"""
# Get the 'super-chapters' input from environment variables
super_chapters_input: str = get_action_input(SUPER_CHAPTERS, default="") # type: ignore[assignment]
# mypy: string is returned as default

# Parse the received string back to YAML array input.
try:
super_chapters = yaml.safe_load(super_chapters_input)
if super_chapters is None:
return []
if not isinstance(super_chapters, list):
logger.error("Error: 'super-chapters' input is not a valid YAML list.")
return []
except yaml.YAMLError as exc:
logger.error("Error parsing 'super-chapters' input: %s", exc)
return []

return super_chapters
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Comment thread
miroslavpojer marked this conversation as resolved.
@staticmethod
def get_hierarchy() -> bool:
"""
Expand Down Expand Up @@ -546,6 +569,7 @@ def validate_inputs() -> None:
logger.debug("CodeRabbit summary ignore groups: %s", coderabbit_summary_ignore_groups)
logger.debug("Hidden service chapters: %s", ActionInputs.get_hidden_service_chapters())
logger.debug("Service chapter order: %s", ActionInputs.get_service_chapter_order())
logger.debug("Super chapters: %s", ActionInputs.get_super_chapters())

@staticmethod
def _detect_row_format_invalid_keywords(row_format: str, row_type: str = "Issue", clean: bool = False) -> str:
Expand Down
134 changes: 134 additions & 0 deletions release_notes_generator/chapters/custom_chapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,28 @@
"""

import logging
from dataclasses import dataclass, field
from typing import Any, Iterable

from release_notes_generator.action_inputs import ActionInputs
from release_notes_generator.chapters.base_chapters import BaseChapters
from release_notes_generator.model.chapter import Chapter
from release_notes_generator.utils.constants import UNCATEGORIZED_CHAPTER_TITLE
from release_notes_generator.model.record.commit_record import CommitRecord
from release_notes_generator.model.record.hierarchy_issue_record import HierarchyIssueRecord
from release_notes_generator.model.record.record import Record

logger = logging.getLogger(__name__)


@dataclass
class SuperChapter:
"""A label-based grouping that wraps regular chapters in the rendered output."""

title: str
labels: list[str] = field(default_factory=list)


def _normalize_labels(raw: Any) -> list[str]: # helper for multi-label
"""Normalize a raw labels definition into an ordered, de-duplicated list.

Expand Down Expand Up @@ -75,6 +85,11 @@ class CustomChapters(BaseChapters):
A class used to represent the custom chapters in the release notes.
"""

def __init__(self, sort_ascending: bool = True, print_empty_chapters: bool = True):
super().__init__(sort_ascending, print_empty_chapters)
self._super_chapters: list[SuperChapter] = []
self._record_labels: dict[str, list[str]] = {}

def _find_catch_open_hierarchy_chapter(self) -> Chapter | None:
"""Return the first chapter with catch_open_hierarchy enabled, or None."""
for ch in self.chapters.values():
Expand Down Expand Up @@ -135,6 +150,7 @@ def populate(self, records: dict[str, Record]) -> None:
continue

record_labels = getattr(record, "labels", [])
self._record_labels[record_id] = list(record_labels)

# Conditional Custom Chapter gate: intercept open hierarchy parents before label routing.
# Note: precedes the record_labels early-exit so label-less HierarchyIssueRecord
Expand Down Expand Up @@ -175,9 +191,21 @@ def to_string(self) -> str:
"""
Converts the custom chapters to a string, excluding hidden chapters.

When super chapters are configured, records are grouped under super-chapter
headings (``## Title``) with regular chapters nested inside (``### Title``).
A record may appear in multiple super chapters. Chapters with no matching
records under a given super chapter are governed by ``print_empty_chapters``.

Returns:
str: The chapters as a string with hidden chapters filtered out.
"""
if self._super_chapters:
return self._to_string_with_super_chapters()

return self._to_string_flat()

def _to_string_flat(self) -> str:
"""Render chapters without super-chapter grouping (original behaviour)."""
result = ""
for chapter in self._sorted_chapters():
# Skip hidden chapters from output
Expand All @@ -196,6 +224,75 @@ def to_string(self) -> str:
# Note: strip is required to remove leading newline chars when empty chapters are not printed option
return result.strip()

def _collect_super_chapter_ids(self, sc: SuperChapter) -> set[str]:
"""Return record IDs whose labels match the given super chapter."""
matching: set[str] = set()
for rid, labels in self._record_labels.items():
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What rid stands for please?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Short for record ID — the unique PR or issue number used as the key in _record_labels.

if any(lbl in sc.labels for lbl in labels):
matching.add(rid)
return matching

def _render_chapter_for_ids(self, chapter: Chapter, matching_ids: set[str]) -> str:
"""Render a single chapter filtered to matching_ids, delegating to Chapter.to_string."""
original_rows = chapter.rows
chapter.rows = {
rid: row for rid, row in original_rows.items() if str(rid) in matching_ids or rid in matching_ids
}
try:
return chapter.to_string(sort_ascending=self.sort_ascending, print_empty_chapters=self.print_empty_chapters)
finally:
chapter.rows = original_rows

def _to_string_with_super_chapters(self) -> str:
"""Render chapters grouped under super-chapter headings."""
# Collect all record IDs claimed by at least one super chapter
all_super_labels: set[str] = set()
for sc in self._super_chapters:
all_super_labels.update(sc.labels)

claimed_ids: set[str] = set()
for rid, labels in self._record_labels.items():
if any(lbl in all_super_labels for lbl in labels):
claimed_ids.add(rid)

result = ""
for sc in self._super_chapters:
matching_ids = self._collect_super_chapter_ids(sc)

sc_block = ""
for chapter in self._sorted_chapters():
if chapter.hidden:
continue
ch_str = self._render_chapter_for_ids(chapter, matching_ids)
if ch_str:
sc_block += ch_str + "\n\n"

if sc_block.strip():
result += f"## {sc.title}\n{sc_block}"
elif self.print_empty_chapters:
result += f"## {sc.title}\nNo entries detected.\n\n"

# Fallback: records not claimed by any super chapter
unclaimed_ids: set[str] = set()
for chapter in self._sorted_chapters():
for row_id in chapter.rows:
row_id_str = str(row_id)
if row_id_str not in claimed_ids:
unclaimed_ids.add(row_id_str)

if unclaimed_ids:
uc_block = ""
for chapter in self._sorted_chapters():
if chapter.hidden:
continue
ch_str = self._render_chapter_for_ids(chapter, unclaimed_ids)
if ch_str:
uc_block += ch_str + "\n\n"
if uc_block.strip():
result += f"## {UNCATEGORIZED_CHAPTER_TITLE}\n{uc_block}"

return result.strip()

def from_yaml_array(self, chapters: list[dict[str, Any]]) -> "CustomChapters": # type: ignore[override]
"""
Populate the custom chapters from a list of dict objects.
Expand Down Expand Up @@ -262,8 +359,45 @@ def from_yaml_array(self, chapters: list[dict[str, Any]]) -> "CustomChapters":
parsed_order=parsed_order,
)

# Parse super-chapter definitions from action inputs
self._super_chapters = self._parse_super_chapters(ActionInputs.get_super_chapters())

return self

@staticmethod
def _parse_super_chapters(raw_super_chapters: list[dict[str, str]]) -> list[SuperChapter]:
"""Parse super-chapter YAML definitions into SuperChapter instances.

Parameters:
raw_super_chapters: Parsed YAML list of dicts with 'title' and 'label'/'labels'.

Returns:
List of validated SuperChapter instances; invalid entries are skipped with a warning.
"""
result: list[SuperChapter] = []
for entry in raw_super_chapters:
if not isinstance(entry, dict):
logger.warning("Skipping super-chapter definition with invalid type %s: %s", type(entry), entry)
continue
if "title" not in entry:
logger.warning("Skipping super-chapter without title key: %s", entry)
continue
title = entry["title"]

raw_labels = entry.get("labels", entry.get("label"))
if raw_labels is None:
logger.warning("Super-chapter '%s' has no 'label' or 'labels' key; skipping", title)
continue
labels_input: str | list[str] = [raw_labels] if isinstance(raw_labels, str) else raw_labels
normalized = _normalize_labels(labels_input)
Comment thread
miroslavpojer marked this conversation as resolved.
Outdated
if not normalized:
logger.warning("Super-chapter '%s' labels definition empty after normalization; skipping", title)
continue

result.append(SuperChapter(title=title, labels=normalized))
logger.debug("Registered super-chapter '%s' with labels: %s", title, normalized)
return result
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

@staticmethod
def _parse_bool_flag(title: str, raw: Any, key: str) -> bool:
"""Parse and validate a boolean flag from a chapter config entry.
Expand Down
4 changes: 4 additions & 0 deletions release_notes_generator/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
TAG_NAME = "tag-name"
FROM_TAG_NAME = "from-tag-name"
CHAPTERS = "chapters"
SUPER_CHAPTERS = "super-chapters"
DUPLICITY_SCOPE = "duplicity-scope"
DUPLICITY_ICON = "duplicity-icon"
PUBLISHED_AT = "published-at"
Expand All @@ -48,6 +49,9 @@
SERVICE_CHAPTER_ORDER = "service-chapter-order"
PRINT_EMPTY_CHAPTERS = "print-empty-chapters"

# Super chapter fallback heading
UNCATEGORIZED_CHAPTER_TITLE: str = "Uncategorized"

# Release notes comment constants
RELEASE_NOTE_TITLE_DEFAULT: str = "[Rr]elease [Nn]otes:"
CODERABBIT_RELEASE_NOTE_TITLE_DEFAULT: str = "Summary by CodeRabbit"
Expand Down
Loading
Loading