Skip to content
Merged
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
14 changes: 13 additions & 1 deletion docs/features/issue_hierarchy_support.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ 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.
- Set `open-hierarchy-sub-issue-icon` to an empty string to disable the highlighting.
- 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 +28,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 +44,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
48 changes: 21 additions & 27 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 @@ -156,8 +157,7 @@ def get_chapters() -> list[dict[str, str]]:
Get list of the chapters from the action inputs. Each chapter is a dict.
"""
# Get the 'chapters' input from environment variables
chapters_input: str = get_action_input(CHAPTERS, default="") # type: ignore[assignment]
# mypy: string is returned as default
chapters_input: str = get_action_input(CHAPTERS, default="")

# Parse the received string back to YAML array input.
try:
Expand All @@ -184,8 +184,7 @@ def get_duplicity_scope() -> DuplicityScopeEnum:
"""
Get the duplicity scope parameter value from the action inputs.
"""
duplicity_scope = get_action_input(DUPLICITY_SCOPE, "both").upper() # type: ignore[union-attr]
# mypy: string is returned as default
duplicity_scope = get_action_input(DUPLICITY_SCOPE, "both").upper()

try:
return DuplicityScopeEnum(duplicity_scope)
Expand All @@ -198,15 +197,21 @@ def get_duplicity_icon() -> str:
"""
Get the duplicity icon from the action inputs.
"""
return get_action_input(DUPLICITY_ICON, "🔔") # type: ignore[return-value] # string is returned as default
return get_action_input(DUPLICITY_ICON, "🔔")

@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, "🟡")

@staticmethod
def get_published_at() -> bool:
"""
Get the published at parameter value from the action inputs.
"""
return get_action_input(PUBLISHED_AT, "false").lower() == "true" # type: ignore[union-attr]
# mypy: string is returned as default
return get_action_input(PUBLISHED_AT, "false").lower() == "true"

@staticmethod
def get_skip_release_notes_labels() -> list[str]:
Expand All @@ -225,35 +230,31 @@ def get_verbose() -> bool:
Get the verbose parameter value from the action inputs.
Safe for non-GitHub test contexts where the input may be unset (returns False by default).
"""
raw = get_action_input(VERBOSE, "false") # type: ignore[assignment]
raw = get_action_input(VERBOSE, "false")
# Some test contexts (unit/integration) do not populate GitHub inputs; fall back to default.
raw_normalized = (raw or "false").strip().lower()
return os.getenv(RUNNER_DEBUG, "0") == "1" or raw_normalized == "true"
# mypy: string is returned as default

@staticmethod
def get_release_notes_title() -> str:
"""
Get the release notes title from the action inputs.
"""
return get_action_input(RELEASE_NOTES_TITLE, RELEASE_NOTE_TITLE_DEFAULT) # type: ignore[return-value]
# mypy: string is returned as default
return get_action_input(RELEASE_NOTES_TITLE, RELEASE_NOTE_TITLE_DEFAULT)

@staticmethod
def is_coderabbit_support_active() -> bool:
"""
Get the CodeRabbit support active parameter value from the action inputs.
"""
return get_action_input(CODERABBIT_SUPPORT_ACTIVE, "false").lower() == "true" # type: ignore[union-attr]
return get_action_input(CODERABBIT_SUPPORT_ACTIVE, "false").lower() == "true"

@staticmethod
def get_coderabbit_release_notes_title() -> str:
"""
Get the CodeRabbit release notes title from the action inputs.
"""
return get_action_input(
CODERABBIT_RELEASE_NOTES_TITLE, CODERABBIT_RELEASE_NOTE_TITLE_DEFAULT
) # type: ignore[return-value]
return get_action_input(CODERABBIT_RELEASE_NOTES_TITLE, CODERABBIT_RELEASE_NOTE_TITLE_DEFAULT)

@staticmethod
def get_coderabbit_summary_ignore_groups() -> list[str]:
Expand All @@ -279,7 +280,7 @@ def get_warnings() -> bool:
"""
Get the warnings parameter value from the action inputs.
"""
return get_action_input(WARNINGS, "true").lower() == "true" # type: ignore[union-attr]
return get_action_input(WARNINGS, "true").lower() == "true"
# mypy: string is returned as default

@staticmethod
Expand Down Expand Up @@ -377,9 +378,7 @@ def get_row_format_hierarchy_issue() -> str:
"""
if ActionInputs._row_format_hierarchy_issue is None:
ActionInputs._row_format_hierarchy_issue = ActionInputs._detect_row_format_invalid_keywords(
get_action_input(
ROW_FORMAT_HIERARCHY_ISSUE, "{type}: _{title}_ {number}"
).strip(), # type: ignore[union-attr]
get_action_input(ROW_FORMAT_HIERARCHY_ISSUE, "{type}: _{title}_ {number}").strip(),
row_type=ActionInputs.ROW_TYPE_HIERARCHY_ISSUE,
clean=True,
)
Expand All @@ -394,9 +393,8 @@ def get_row_format_issue() -> str:
ActionInputs._row_format_issue = ActionInputs._detect_row_format_invalid_keywords(
get_action_input(
ROW_FORMAT_ISSUE, "{type}: {number} _{title}_ developed by {developers} in {pull-requests}"
).strip(), # type: ignore[union-attr]
).strip(),
clean=True,
# mypy: string is returned as default
)
return ActionInputs._row_format_issue

Expand All @@ -407,12 +405,9 @@ def get_row_format_pr() -> str:
"""
if ActionInputs._row_format_pr is None:
ActionInputs._row_format_pr = ActionInputs._detect_row_format_invalid_keywords(
get_action_input(
ROW_FORMAT_PR, "{number} _{title}_ developed by {developers}"
).strip(), # type: ignore[union-attr]
get_action_input(ROW_FORMAT_PR, "{number} _{title}_ developed by {developers}").strip(),
row_type=ActionInputs.ROW_TYPE_PR,
clean=True,
# mypy: string is returned as default
)
return ActionInputs._row_format_pr

Expand All @@ -421,8 +416,7 @@ def get_row_format_link_pr() -> bool:
"""
Get the value controlling whether the row format should include a 'PR:' prefix when linking to PRs.
"""
return get_action_input(ROW_FORMAT_LINK_PR, "true").lower() == "true" # type: ignore[union-attr]
# mypy: string is returned as default
return get_action_input(ROW_FORMAT_LINK_PR, "true").lower() == "true"

@staticmethod
def validate_inputs() -> None:
Expand Down
44 changes: 31 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,42 @@ 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)
if self.is_closed and sub_hierarchy_issue.is_open:
sub_row = sub_hierarchy_issue.to_chapter_row()
# 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}"
else:
sub_row = sub_hierarchy_issue.to_chapter_row()
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 = 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
6 changes: 1 addition & 5 deletions release_notes_generator/utils/gh_action.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@

import os
import sys
from typing import Optional


def get_action_input(name: str, default: Optional[str] = None) -> Optional[str]:
def get_action_input(name: str, default: str = "") -> str:
"""
Retrieve the value of a specified input parameter from environment variables.

Expand All @@ -32,9 +31,6 @@ def get_action_input(name: str, default: Optional[str] = None) -> Optional[str]:

@return: The value of the specified input parameter, or an empty string if the environment
"""
if default is None:
return os.getenv(f'INPUT_{name.replace("-", "_").upper()}')

return os.getenv(f'INPUT_{name.replace("-", "_").upper()}', default=default)


Expand Down
Loading