Skip to content
Open
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
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ inputs:
description: 'Icon to be used for duplicity warning. Icon is placed before the record line.'
required: false
default: '🔔'
open-hierarchy-sub-issue-icon:
description: 'Icon prepended to open children under a closed hierarchy parent.'
required: false
default: '🟡'
published-at:
description: 'Use `published-at` timestamp instead of `created-at` as the reference point of previous Release.'
required: false
Expand Down Expand Up @@ -181,6 +185,7 @@ runs:
INPUT_HIERARCHY: ${{ inputs.hierarchy }}
INPUT_DUPLICITY_SCOPE: ${{ inputs.duplicity-scope }}
INPUT_DUPLICITY_ICON: ${{ inputs.duplicity-icon }}
INPUT_OPEN_HIERARCHY_SUB_ISSUE_ICON: ${{ inputs.open-hierarchy-sub-issue-icon }}
INPUT_WARNINGS: ${{ inputs.warnings }}
INPUT_HIDDEN_SERVICE_CHAPTERS: ${{ inputs.hidden-service-chapters }}
INPUT_SERVICE_CHAPTER_ORDER: ${{ inputs.service-chapter-order }}
Expand Down
12 changes: 11 additions & 1 deletion docs/features/issue_hierarchy_support.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ Represent issue → sub-issue relationships directly in release notes, aggregati
## How It Works
- Enabled via input `hierarchy: true` (default: `false`). When disabled, all issues render flat.
- Parent issues are detected; sub-issues (and nested hierarchy issues) are fetched and ordered by level. Levels indent with two spaces per depth; nested items use list markers (`-`).
- Only closed sub-issues that contain a change increment (merged PR to default branch) are rendered; open ones, and PR to non default branch are ignored.
- When the parent hierarchy issue is **open**:
- Leaf sub-issues: only closed ones with a change increment are rendered; open ones and those closed or delivered in a previous release are ignored.
- Nested sub-hierarchy children: filtered by change increment only — an open child that aggregates PRs from deeper levels is still rendered.
- When the parent hierarchy issue is **closed**: all children (sub-issues and nested sub-hierarchy children) are rendered. Open children are prefixed with the `open-hierarchy-sub-issue-icon` (default `🟡`) to signal incomplete work.
- Each hierarchy issue line can expand with its own extracted release notes block if present (prefixed with `_Release Notes_:` heading within the item block).

## Configuration
Expand All @@ -23,6 +26,12 @@ Represent issue → sub-issue relationships directly in release notes, aggregati
- {"title": "New Features 🎉", "label": "feature"}
```

Optional inputs related to hierarchy:

| Input | Default | Description |
|---|---|---|
| `open-hierarchy-sub-issue-icon` | `🟡` | Icon prepended to open sub-issues and open sub-hierarchy issues rendered under a closed hierarchy parent. Set to an empty string to disable highlighting. |

## Example Result
```markdown
### New Features 🎉
Expand All @@ -33,6 +42,7 @@ Represent issue → sub-issue relationships directly in release notes, aggregati
- Updated `scala213 = "2.13.13"`
- Feature: _Add user MFA enrollment flow_ #123 developed by @alice in #124
- Add user MFA enrollment flow
- 🟡 Feature: _Add OAuth2 login_ #125 ← open sub-issue under closed Epic
```
(1st four indented bullets under Epic line represent the extracted release notes from the parent hierarchy issue's body.)

Expand Down
8 changes: 8 additions & 0 deletions release_notes_generator/action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
PRINT_EMPTY_CHAPTERS,
DUPLICITY_SCOPE,
DUPLICITY_ICON,
OPEN_HIERARCHY_SUB_ISSUE_ICON,
ROW_FORMAT_LINK_PR,
ROW_FORMAT_ISSUE,
ROW_FORMAT_PR,
Expand Down Expand Up @@ -200,6 +201,13 @@ def get_duplicity_icon() -> str:
"""
return get_action_input(DUPLICITY_ICON, "🔔") # type: ignore[return-value] # string is returned as default

@staticmethod
def get_open_hierarchy_sub_issue_icon() -> str:
"""
Get the icon prepended to open sub-issues rendered under a closed hierarchy parent.
"""
return get_action_input(OPEN_HIERARCHY_SUB_ISSUE_ICON, "🟡") # type: ignore[return-value] # string is returned as default

@staticmethod
def get_published_at() -> bool:
"""
Expand Down
42 changes: 29 additions & 13 deletions release_notes_generator/model/record/hierarchy_issue_record.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,24 +216,40 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str:

# add sub-hierarchy issues
for sub_hierarchy_issue in self._sub_hierarchy_issues.values():
logger.debug("Rendering hierarchy issue row for sub-issue #%s", sub_hierarchy_issue.issue.number)
if sub_hierarchy_issue.contains_change_increment():
logger.debug("Sub-hierarchy issue #%s contains change increment", sub_hierarchy_issue.issue.number)
row = f"{row}\n{sub_hierarchy_issue.to_chapter_row()}"
logger.debug("Rendering sub-hierarchy issue row for #%s", sub_hierarchy_issue.issue.number)
if self.is_open:
if not sub_hierarchy_issue.contains_change_increment():
continue
# Closed parent: render all sub-hierarchy issues regardless of state or change increment
logger.debug("Rendering sub-hierarchy issue #%s", sub_hierarchy_issue.issue.number)
sub_row = sub_hierarchy_issue.to_chapter_row()
if self.is_closed and sub_hierarchy_issue.is_open:
# Highlight open children under a closed parent to signal incomplete work
icon = ActionInputs.get_open_hierarchy_sub_issue_icon()
header_line, newline, remaining_lines = sub_row.partition("\n")
header_text = header_line.lstrip()
indent = header_line[: len(header_line) - len(header_text)]
sub_row = f"{indent}{icon} {header_text}{newline}{remaining_lines}"
row = f"{row}\n{sub_row}"

# add sub-issues
if len(self._sub_issues) > 0:
sub_indent = " " * (self._level + 1)
for sub_issue in self._sub_issues.values():
logger.debug("Rendering sub-issue row for issue #%d", sub_issue.issue.number)
if sub_issue.is_open:
continue # only closed issues are reported in release notes

if not sub_issue.contains_change_increment():
continue # skip sub-issues without change increment

logger.debug("Sub-issue #%s contains change increment", sub_issue.issue.number)
sub_issue_block = "- " + sub_issue.to_chapter_row()
logger.debug("Rendering sub-issue row for issue #%s", sub_issue.issue.number)
if self.is_open:
if sub_issue.is_open:
continue # only closed issues are reported in release notes
if not sub_issue.contains_change_increment():
continue # skip sub-issues without change increment
# Closed parent: render all sub-issues regardless of state or change increment

logger.debug("Rendering sub-issue #%s", sub_issue.issue.number)
open_icon_prefix = ""
if self.is_closed and sub_issue.is_open:
# Highlight open children under a closed parent to signal incomplete work
open_icon_prefix = f"{ActionInputs.get_open_hierarchy_sub_issue_icon()} "
sub_issue_block = "- " + open_icon_prefix + sub_issue.to_chapter_row()
ind_child_block = "\n".join(
f"{sub_indent}{line}" if line else "" for line in sub_issue_block.splitlines()
)
Expand Down
1 change: 1 addition & 0 deletions release_notes_generator/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
CHAPTERS = "chapters"
DUPLICITY_SCOPE = "duplicity-scope"
DUPLICITY_ICON = "duplicity-icon"
OPEN_HIERARCHY_SUB_ISSUE_ICON = "open-hierarchy-sub-issue-icon"
PUBLISHED_AT = "published-at"
SKIP_RELEASE_NOTES_LABELS = "skip-release-notes-labels"
VERBOSE = "verbose"
Expand Down
106 changes: 106 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from datetime import datetime, timedelta

import pytest
from pytest_mock import MockerFixture

from github import Github, IssueType, NamedUser
from github.Commit import Commit
Expand Down Expand Up @@ -1226,3 +1227,108 @@ def mock_logging_setup(mocker):
"""Fixture to mock the basic logging setup using pytest-mock."""
mock_log_config = mocker.patch("logging.basicConfig")
yield mock_log_config


# Helpers for hierarchy issue record tests
_HIERARCHY_ROW_FMT = "_{title}_ {number}"
_ISSUE_ROW_FMT_MINIMAL = "{number} _{title}_"


def make_minimal_issue(mocker: MockerFixture, state: str, number: int) -> Issue:
"""Return a minimal Issue mock."""
issue = mocker.Mock(spec=Issue)
issue.state = state
issue.number = number
issue.title = "Minimal test issue"
issue.body = None
issue.type = None
issue.user = None
issue.assignees = []
issue.get_labels.return_value = []
return issue


def make_minimal_pr(mocker: MockerFixture, number: int) -> PullRequest:
"""Return a minimal PullRequest mock."""
pr = mocker.Mock(spec=PullRequest)
pr.number = number
pr.body = None
pr.html_url = f"https://github.com/org/repo/pull/{number}"
pr.user = None
pr.assignees = []
pr.get_labels.return_value = []
return pr


def make_closed_sub_issue_record_with_pr(mocker: MockerFixture, number: int) -> SubIssueRecord:
"""Return a closed SubIssueRecord with one PR (no commits)."""
sub_record = SubIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number))
sub_record.register_pull_request(make_minimal_pr(mocker, number=number + 1000))
return sub_record


def make_open_sub_issue_record_no_pr(mocker: MockerFixture, number: int) -> SubIssueRecord:
"""Return an open SubIssueRecord with no PRs."""
return SubIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number))


def make_closed_sub_issue_record_no_pr(mocker: MockerFixture, number: int) -> SubIssueRecord:
"""Return a closed SubIssueRecord with no PRs (no change increment)."""
return SubIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number))


def make_open_sub_hierarchy_record_no_pr(mocker: MockerFixture, number: int) -> HierarchyIssueRecord:
"""Return an open HierarchyIssueRecord with no PRs."""
return HierarchyIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number))


def make_open_sub_hierarchy_record_with_pr(mocker: MockerFixture, number: int) -> HierarchyIssueRecord:
"""Return an open HierarchyIssueRecord with one PR (no commits)."""
rec = HierarchyIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number))
rec.register_pull_request(make_minimal_pr(mocker, number=number + 1000))
return rec


def make_closed_sub_hierarchy_record_with_pr(mocker: MockerFixture, number: int) -> HierarchyIssueRecord:
"""Return a closed HierarchyIssueRecord with one PR (no commits)."""
rec = HierarchyIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number))
rec.register_pull_request(make_minimal_pr(mocker, number=number + 1000))
return rec


def make_closed_sub_hierarchy_record_no_pr(mocker: MockerFixture, number: int) -> HierarchyIssueRecord:
"""Return a closed HierarchyIssueRecord with no PRs (no change increment)."""
return HierarchyIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number))


@pytest.fixture
def patch_hierarchy_action_inputs(mocker):
"""Patch ActionInputs with minimal row-format values for HierarchyIssueRecord tests."""
mocker.patch(
"release_notes_generator.model.record.hierarchy_issue_record.ActionInputs.get_row_format_hierarchy_issue",
return_value=_HIERARCHY_ROW_FMT,
)
mocker.patch(
"release_notes_generator.model.record.hierarchy_issue_record.ActionInputs.get_duplicity_icon",
return_value="🔔",
)
mocker.patch(
"release_notes_generator.model.record.issue_record.ActionInputs.get_row_format_issue",
return_value=_ISSUE_ROW_FMT_MINIMAL,
)
mocker.patch(
"release_notes_generator.model.record.issue_record.ActionInputs.get_duplicity_icon",
return_value="🔔",
)
mocker.patch(
"release_notes_generator.model.record.record.ActionInputs.get_release_notes_title",
return_value="Release Notes:",
)
mocker.patch(
"release_notes_generator.model.record.record.ActionInputs.is_coderabbit_support_active",
return_value=False,
)
mocker.patch(
"release_notes_generator.model.record.hierarchy_issue_record.ActionInputs.get_open_hierarchy_sub_issue_icon",
return_value="🟡",
)
Loading