Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ Testing
- Must not access private members (names starting with `_`) of the class under test directly in tests.
- Must place shared test helper functions and factory fixtures in the nearest `conftest.py` and reuse them across tests.
- Must annotate pytest fixture parameters with `MockerFixture` (from `pytest_mock`) and return types with `Callable[..., T]` (from `collections.abc`) when the fixture returns a factory function.
- Prefer TDD workflow:
- Must create or update `SPEC.md` in the relevant package directory before writing any code, listing scenarios, inputs, and expected outputs.
- Must propose the full set of test cases (name + one-line intent + input summary + expected output summary) and wait for user confirmation before writing any code.
- Must be ready to add, remove, or rename tests based on user feedback before proceeding.
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.

This line means, you are always doing a planning of task in PLAN mode before implementing? If not, would this line mean, that the implementation can stop to get confirmation from user and will spent extra premium requests.

From this section I am not sure, if TDD is now way how to implement new things, since it has a big part of the copilot doc, but is Preferred workflow.

Copy link
Copy Markdown
Collaborator Author

@miroslavpojer miroslavpojer Apr 8, 2026

Choose a reason for hiding this comment

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

The TDD block is marked Prefer (not Must), so it's being trialed rather than enforced.

  • The plan-then-confirm checkpoint is useful for bigger changes but too heavy for quick fixes
  • Worth tracking whether the extra round-trip pays off in practice

Final Note: I am experimenting with this approach, here.

- Must write all failing tests first (red), then implement until all pass (green).
- Must cover all distinct combinations; each test must state its scenario in the docstring.
- Must update `SPEC.md` after all tests pass with the confirmed test case table (name + intent + input + expected output).
- Must not add comments outside test methods in `test_*.py` files; use section-header comments (`# --- section ---`) only to separate logical groups of tests.
Comment thread
miroslavpojer marked this conversation as resolved.

Tooling
- Must format with Black (pyproject.toml).
Expand Down
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 @@ -181,6 +189,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
110 changes: 110 additions & 0 deletions docs/features/custom_chapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,116 @@ 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.
- Empty super chapters (no matching records) respect the `print-empty-chapters` setting:
- `print-empty-chapters: true` → header is printed with `No entries detected.`
- `print-empty-chapters: false` → header is omitted entirely
- `## Uncategorized` is only emitted when there are actually unmatched records; `print-empty-chapters` has no effect on it.
- When no super chapters are configured, output is flat (unchanged from previous behavior).

### Hierarchy Split (with `hierarchy: true`)

With `hierarchy: true`, super-chapter matching uses each hierarchy record's **full aggregated label set** — own labels plus all descendant labels recursively at every depth. So an Epic matches a super chapter even when the relevant label lives only on a grandchild Task (e.g. Epic → Feature → Task).

The record is then split by which descendants belong to which super chapter:

| Descendants | Output |
|---|---|
| All match one super chapter | Record appears in that super chapter only |
| None match any super chapter | Record appears in `## Uncategorized` only |
| Some match, some don't | Record appears in the matching super chapter **and** in `## Uncategorized`, each showing only its own subset |
| Match multiple super chapters | Record appears in each matching super chapter with its relevant subset |
| Match multiple SCs + some unmatched | Record appears in each matching super chapter and in `## Uncategorized` |

#### Example

Epic #1 has Task #2 (`scope:frontend`) and Task #3 (`scope:backend`):

```yaml
super-chapters: |
- title: "Frontend"
label: "scope:frontend"
- title: "Backend"
label: "scope:backend"
chapters: |
- {"title": "New Features", "labels": "feature"}
```

```markdown
## Frontend
### New Features
- Epic: _Add user authentication_ #1
- #2 _Build login form_

## Backend
### New Features
- Epic: _Add user authentication_ #1
- #3 _Add JWT endpoint_
```

If Epic #1 also had Task #4 with no super-chapter label, it would additionally appear in `## Uncategorized` with only Task #4.

#### 3-level depth

The same split works when the matching label is on a grandchild. Epic #1 → Feature #2 → Task #3 (`scope:security`):

```markdown
## Security
### New Features
- Epic: _Add authentication_ #1
- Feature: _Auth flow_ #2
- #3 _Add JWT endpoint_
```

Feature #2 has no `scope:security` label of its own, but its aggregated set includes it via Task #3, so it is routed to the Security super chapter.

Children within each rendered node are sorted **ascending by issue number**.

### 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
1 change: 1 addition & 0 deletions docs/features/issue_hierarchy_support.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Resulting output (hierarchy issues only — sub-issue rows use `row-format-issue
- [Custom Row Formats](./custom_row_formats.md) – controls hierarchy line rendering.
- [Service Chapters](./service_chapters.md) – flags missing change increments if hierarchy parents lack qualifying sub-issues.
- [Duplicity Handling](./duplicity_handling.md) – duplicate hierarchy items can be icon-prefixed if allowed.
- [Super Chapters](./custom_chapters.md#super-chapters) – hierarchy records split across super chapters when descendants carry different super-chapter labels.

← [Back to Feature Tutorials](../../README.md#feature-tutorials)

107 changes: 92 additions & 15 deletions release_notes_generator/action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
import os
import sys
import re

from typing import Any
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 @@ -59,7 +60,7 @@
)
from release_notes_generator.utils.enums import DuplicityScopeEnum
from release_notes_generator.utils.gh_action import get_action_input
from release_notes_generator.utils.utils import normalize_version_tag
from release_notes_generator.utils.utils import normalize_labels, normalize_version_tag

logger = logging.getLogger(__name__)

Expand All @@ -80,6 +81,8 @@ class ActionInputs:
_row_format_link_pr = None
_owner = ""
_repo_name = ""
_super_chapters_raw: str | None = None
_super_chapters_cache: list[dict[str, Any]] | None = None

@staticmethod
def get_github_owner() -> str:
Expand Down Expand Up @@ -141,7 +144,7 @@ def get_from_tag_name() -> str:
Get the from-tag name from the action inputs.
"""
raw = get_action_input(FROM_TAG_NAME, default="")
return normalize_version_tag(raw) # type: ignore[arg-type]
return normalize_version_tag(raw)

@staticmethod
def is_from_tag_name_defined() -> bool:
Expand All @@ -166,11 +169,79 @@ 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, Any]]:
"""
Get list of validated super-chapter definitions from the action inputs.

Each returned entry is guaranteed to have:
- 'title': str
- 'labels': list[str] (non-empty, normalized)

Invalid entries (non-dict, missing title, missing/empty labels) are skipped
with a warning log.
"""
# Get the 'super-chapters' input from environment variables
super_chapters_input: str = get_action_input(SUPER_CHAPTERS, default="")

if (
ActionInputs._super_chapters_raw is not None
and ActionInputs._super_chapters_raw == super_chapters_input
and ActionInputs._super_chapters_cache is not None
):
return ActionInputs._super_chapters_cache

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

result: list[dict[str, Any]] = []
for entry in raw_list:
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"]
if not isinstance(title, str) or not title.strip():
logger.warning("Skipping super-chapter with invalid title value: %r", title)
continue

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
normalized = normalize_labels(raw_labels)
if not normalized:
logger.warning("Super-chapter '%s' labels definition empty after normalization; skipping", title)
continue

result.append({"title": title, "labels": normalized})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
logger.debug("Validated super-chapter '%s' with labels: %s", title, normalized)
ActionInputs._super_chapters_raw = super_chapters_input
ActionInputs._super_chapters_cache = result
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 is this cache var solving?

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.

get_super_chapters() reads and parses a YAML env var. The cache avoids re-parsing the same string on every call.

return result

Comment thread
miroslavpojer marked this conversation as resolved.
@staticmethod
def get_hierarchy() -> bool:
"""
Expand Down Expand Up @@ -351,19 +422,21 @@ def get_print_empty_chapters() -> bool:
"""
Get the print empty chapters parameter value from the action inputs.
"""
return get_action_input(PRINT_EMPTY_CHAPTERS, "true").lower() == "true" # type: ignore[union-attr]
# mypy: string is returned as default
return get_action_input(PRINT_EMPTY_CHAPTERS, "true").lower() == "true"

@staticmethod
def validate_input(input_value, expected_type: type, error_message: str, error_buffer: list) -> bool:
"""
Validates the input value against the expected type.

@param input_value: The input value to validate.
@param expected_type: The expected type of the input value.
@param error_message: The error message to log if the validation fails.
@param error_buffer: The buffer to store the error messages.
@return: The boolean result of the validation.
Parameters:
input_value: The input value to validate.
expected_type: The expected type of the input value.
error_message: The error message to log if the validation fails.
error_buffer: The buffer to store the error messages.

Returns:
The boolean result of the validation.
"""

if not isinstance(input_value, expected_type):
Expand Down Expand Up @@ -423,7 +496,6 @@ def validate_inputs() -> None:
"""
Validates the inputs provided for the release notes generator.
Logs any validation errors and exits if any are found.
@return: None
"""
errors = []

Expand Down Expand Up @@ -540,15 +612,20 @@ 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 (raw): %s", get_action_input(SUPER_CHAPTERS, default=""))

@staticmethod
def _detect_row_format_invalid_keywords(row_format: str, row_type: str = "Issue", clean: bool = False) -> str:
"""
Detects invalid keywords in the row format.

@param row_format: The row format to be checked for invalid keywords.
@param row_type: The type of row format. Default is "Issue".
@return: If clean is True, the cleaned row format. Otherwise, the original row format.
Parameters:
row_format: The row format to be checked for invalid keywords.
row_type: The type of row format. Default is "Issue".
clean: When True, strip invalid keywords from the returned string.

Returns:
If clean is True, the cleaned row format. Otherwise, the original row format.
"""
keywords_in_braces = re.findall(r"\{(.*?)\}", row_format)

Expand Down
Loading