From 3ce8e02f07c61e106ec128b96da84813633d3c59 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 30 Mar 2026 18:38:57 +0200 Subject: [PATCH 1/5] feat: enhance hierarchy issue rendering logic for open and closed parents --- docs/features/issue_hierarchy_support.md | 5 +- .../model/record/hierarchy_issue_record.py | 22 +-- tests/unit/conftest.py | 107 +++++++++++++ .../model/test_hierarchy_issue_record.py | 149 ++++++++++++++++++ 4 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py diff --git a/docs/features/issue_hierarchy_support.md b/docs/features/issue_hierarchy_support.md index 6acccadd..08e1d02b 100644 --- a/docs/features/issue_hierarchy_support.md +++ b/docs/features/issue_hierarchy_support.md @@ -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 sub-issues are rendered. - 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 diff --git a/release_notes_generator/model/record/hierarchy_issue_record.py b/release_notes_generator/model/record/hierarchy_issue_record.py index fcf4c571..72a8f34c 100644 --- a/release_notes_generator/model/record/hierarchy_issue_record.py +++ b/release_notes_generator/model/record/hierarchy_issue_record.py @@ -172,20 +172,24 @@ 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()}" + 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) + row = f"{row}\n{sub_hierarchy_issue.to_chapter_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("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("Sub-issue #%s contains change increment", sub_issue.issue.number) sub_issue_block = "- " + sub_issue.to_chapter_row() diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 2f7fdd6e..c12be754 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1191,3 +1191,110 @@ 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: pytest.MonkeyPatch, state: str, number: int) -> Issue: + """Return a minimal Issue mock.""" + issue = mocker.Mock(spec=Issue) + issue.state = state + issue.number = number + issue.title = f"Issue #{number}" + issue.body = None + issue.type = None + issue.user = None + issue.assignees = [] + issue.get_labels.return_value = [] + return issue + + +def make_minimal_pr(mocker: pytest.MonkeyPatch, 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: pytest.MonkeyPatch, number: int) -> SubIssueRecord: + """Return a closed SubIssueRecord with one PR (no commits).""" + sub_record = SubIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number)) + pr = make_minimal_pr(mocker, number=number + 1000) + sub_record._pull_requests[pr.number] = pr + sub_record._commits[pr.number] = {} + return sub_record + + +def make_open_sub_issue_record_no_pr(mocker: pytest.MonkeyPatch, 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: pytest.MonkeyPatch, 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: pytest.MonkeyPatch, 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: pytest.MonkeyPatch, number: int) -> HierarchyIssueRecord: + """Return an open HierarchyIssueRecord with one PR (no commits).""" + rec = HierarchyIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number)) + pr = make_minimal_pr(mocker, number=number + 1000) + rec._pull_requests[pr.number] = pr + rec._commits[pr.number] = {} + return rec + + +def make_closed_sub_hierarchy_record_with_pr(mocker: pytest.MonkeyPatch, number: int) -> HierarchyIssueRecord: + """Return a closed HierarchyIssueRecord with one PR (no commits).""" + rec = HierarchyIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number)) + pr = make_minimal_pr(mocker, number=number + 1000) + rec._pull_requests[pr.number] = pr + rec._commits[pr.number] = {} + return rec + + +def make_closed_sub_hierarchy_record_no_pr(mocker: pytest.MonkeyPatch, 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, + ) diff --git a/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py b/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py new file mode 100644 index 00000000..70b7fa85 --- /dev/null +++ b/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py @@ -0,0 +1,149 @@ +# +# Copyright 2023 ABSA Group Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +""" +Tests for HierarchyIssueRecord. +""" + +from release_notes_generator.model.record.hierarchy_issue_record import HierarchyIssueRecord +from release_notes_generator.model.record.issue_record import IssueRecord +from tests.unit.conftest import ( + make_closed_sub_hierarchy_record_no_pr, + make_closed_sub_hierarchy_record_with_pr, + make_closed_sub_issue_record_no_pr, + make_closed_sub_issue_record_with_pr, + make_minimal_issue, + make_open_sub_hierarchy_record_no_pr, + make_open_sub_hierarchy_record_with_pr, + make_open_sub_issue_record_no_pr, +) + + +def test_to_chapter_row_closed_parent_renders_closed_and_open_sub_issues(mocker, patch_hierarchy_action_inputs): + """ + Closed parent with one closed sub-issue (has PR) and one open sub-issue (no change + increment) → both appear in to_chapter_row() output. + """ + parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number=300) + record = HierarchyIssueRecord(parent) + + record._sub_issues["450"] = make_closed_sub_issue_record_with_pr(mocker, number=450) + record._sub_issues["400"] = make_open_sub_issue_record_no_pr(mocker, number=400) + + row = record.to_chapter_row() + + assert "#450" in row, f"Closed sub-issue #450 must appear; got:\n{row}" + assert "#400" in row, f"Open sub-issue #400 must appear when parent is closed; got:\n{row}" + + +def test_to_chapter_row_open_parent_only_renders_closed_sub_issues_with_change_increment(mocker, patch_hierarchy_action_inputs): + """ + Open parent with one closed sub-issue (has PR) and one open sub-issue (no PR) → + only the closed sub-issue appears; open sub-issue is suppressed. + """ + parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number=300) + record = HierarchyIssueRecord(parent) + + record._sub_issues["450"] = make_closed_sub_issue_record_with_pr(mocker, number=450) + record._sub_issues["400"] = make_open_sub_issue_record_no_pr(mocker, number=400) + + row = record.to_chapter_row() + + assert "#450" in row, f"Closed sub-issue #450 must appear; got:\n{row}" + assert "#400" not in row, f"Open sub-issue #400 must NOT appear when parent is open; got:\n{row}" + + +def test_to_chapter_row_open_parent_suppresses_closed_sub_issue_from_previous_release(mocker, patch_hierarchy_action_inputs): + """ + Open parent with one closed sub-issue that has no change increment (closed or delivered + in a previous release) → the sub-issue does not appear in the current release notes. + """ + parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number=300) + record = HierarchyIssueRecord(parent) + + record._sub_issues["450"] = make_closed_sub_issue_record_no_pr(mocker, number=450) + + row = record.to_chapter_row() + + assert "#450" not in row, f"Sub-issue from previous release must NOT appear; got:\n{row}" + + +def test_to_chapter_row_open_parent_suppresses_closed_sub_hierarchy_issue_from_previous_release(mocker, patch_hierarchy_action_inputs): + """ + Open parent with one closed sub-hierarchy issue that has no change increment (closed or + delivered in a previous release) → the sub-hierarchy issue does not appear. + """ + parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number=300) + record = HierarchyIssueRecord(parent) + + record._sub_hierarchy_issues["350"] = make_closed_sub_hierarchy_record_no_pr(mocker, number=350) + + row = record.to_chapter_row() + + assert "#350" not in row, f"Sub-hierarchy issue from previous release must NOT appear; got:\n{row}" + + +def test_to_chapter_row_closed_parent_renders_closed_and_open_sub_hierarchy_issues(mocker, patch_hierarchy_action_inputs): + """ + Closed parent with one closed sub-hierarchy issue (has PR) and one open sub-hierarchy + issue (no change increment) → both appear in to_chapter_row() output. + """ + parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number=300) + record = HierarchyIssueRecord(parent) + + record._sub_hierarchy_issues["350"] = make_closed_sub_hierarchy_record_with_pr(mocker, number=350) + record._sub_hierarchy_issues["360"] = make_open_sub_hierarchy_record_no_pr(mocker, number=360) + + row = record.to_chapter_row() + + assert "#350" in row, f"Closed sub-hierarchy issue #350 must appear; got:\n{row}" + assert "#360" in row, f"Open sub-hierarchy issue #360 must appear when parent is closed; got:\n{row}" + + +def test_to_chapter_row_open_parent_only_renders_sub_hierarchy_issues_with_change_increment(mocker, patch_hierarchy_action_inputs): + """ + Open parent with one closed sub-hierarchy issue (has PR) and one open sub-hierarchy + issue (no PR, no change increment) → only the closed sub-hierarchy issue appears. + Unlike leaf sub-issues, sub-hierarchy issues are filtered by change increment only; + an open sub-hierarchy issue that aggregates PRs from its own sub-issues would still appear. + """ + parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number=300) + record = HierarchyIssueRecord(parent) + + record._sub_hierarchy_issues["350"] = make_closed_sub_hierarchy_record_with_pr(mocker, number=350) + record._sub_hierarchy_issues["360"] = make_open_sub_hierarchy_record_no_pr(mocker, number=360) + + row = record.to_chapter_row() + + assert "#350" in row, f"Closed sub-hierarchy issue #350 must appear; got:\n{row}" + assert "#360" not in row, f"Sub-hierarchy issue #360 with no change increment must NOT appear; got:\n{row}" + + +def test_to_chapter_row_open_parent_renders_open_sub_hierarchy_issue_with_change_increment(mocker, patch_hierarchy_action_inputs): + """ + Open parent with one open sub-hierarchy issue that has a PR (change increment present) + → the sub-hierarchy issue appears. Sub-hierarchy children are filtered by change + increment only; open state alone is not a reason to suppress them. + """ + parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number=300) + record = HierarchyIssueRecord(parent) + + record._sub_hierarchy_issues["360"] = make_open_sub_hierarchy_record_with_pr(mocker, number=360) + + row = record.to_chapter_row() + + assert "#360" in row, f"Open sub-hierarchy issue #360 with a PR must appear under open parent; got:\n{row}" + From 6273e1ea544d08e71b3283b134ea39320e8175b1 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 30 Mar 2026 21:06:59 +0200 Subject: [PATCH 2/5] Fixed review comments. --- .../model/record/hierarchy_issue_record.py | 4 ++-- tests/unit/conftest.py | 12 +++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/release_notes_generator/model/record/hierarchy_issue_record.py b/release_notes_generator/model/record/hierarchy_issue_record.py index 72a8f34c..af08208a 100644 --- a/release_notes_generator/model/record/hierarchy_issue_record.py +++ b/release_notes_generator/model/record/hierarchy_issue_record.py @@ -171,7 +171,7 @@ 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) + 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 @@ -191,7 +191,7 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str: continue # skip sub-issues without change increment # Closed parent: render all sub-issues regardless of state or change increment - logger.debug("Sub-issue #%s contains change increment", sub_issue.issue.number) + logger.debug("Rendering sub-issue #%s", sub_issue.issue.number) sub_issue_block = "- " + sub_issue.to_chapter_row() ind_child_block = "\n".join( f"{sub_indent}{line}" if line else "" for line in sub_issue_block.splitlines() diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index c12be754..eac40b64 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1227,9 +1227,7 @@ def make_minimal_pr(mocker: pytest.MonkeyPatch, number: int) -> PullRequest: def make_closed_sub_issue_record_with_pr(mocker: pytest.MonkeyPatch, number: int) -> SubIssueRecord: """Return a closed SubIssueRecord with one PR (no commits).""" sub_record = SubIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number)) - pr = make_minimal_pr(mocker, number=number + 1000) - sub_record._pull_requests[pr.number] = pr - sub_record._commits[pr.number] = {} + sub_record.register_pull_request(make_minimal_pr(mocker, number=number + 1000)) return sub_record @@ -1251,18 +1249,14 @@ def make_open_sub_hierarchy_record_no_pr(mocker: pytest.MonkeyPatch, number: int def make_open_sub_hierarchy_record_with_pr(mocker: pytest.MonkeyPatch, number: int) -> HierarchyIssueRecord: """Return an open HierarchyIssueRecord with one PR (no commits).""" rec = HierarchyIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number)) - pr = make_minimal_pr(mocker, number=number + 1000) - rec._pull_requests[pr.number] = pr - rec._commits[pr.number] = {} + rec.register_pull_request(make_minimal_pr(mocker, number=number + 1000)) return rec def make_closed_sub_hierarchy_record_with_pr(mocker: pytest.MonkeyPatch, number: int) -> HierarchyIssueRecord: """Return a closed HierarchyIssueRecord with one PR (no commits).""" rec = HierarchyIssueRecord(make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number)) - pr = make_minimal_pr(mocker, number=number + 1000) - rec._pull_requests[pr.number] = pr - rec._commits[pr.number] = {} + rec.register_pull_request(make_minimal_pr(mocker, number=number + 1000)) return rec From 7c26d3a2811fedca36c939c032248e48571a9228 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 30 Mar 2026 21:10:46 +0200 Subject: [PATCH 3/5] Fixed review comment. --- tests/unit/conftest.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index eac40b64..21b8b969 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -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 @@ -1198,7 +1199,7 @@ def mock_logging_setup(mocker): _ISSUE_ROW_FMT_MINIMAL = "{number} _{title}_" -def make_minimal_issue(mocker: pytest.MonkeyPatch, state: str, number: int) -> Issue: +def make_minimal_issue(mocker: MockerFixture, state: str, number: int) -> Issue: """Return a minimal Issue mock.""" issue = mocker.Mock(spec=Issue) issue.state = state @@ -1212,7 +1213,7 @@ def make_minimal_issue(mocker: pytest.MonkeyPatch, state: str, number: int) -> I return issue -def make_minimal_pr(mocker: pytest.MonkeyPatch, number: int) -> PullRequest: +def make_minimal_pr(mocker: MockerFixture, number: int) -> PullRequest: """Return a minimal PullRequest mock.""" pr = mocker.Mock(spec=PullRequest) pr.number = number @@ -1224,43 +1225,43 @@ def make_minimal_pr(mocker: pytest.MonkeyPatch, number: int) -> PullRequest: return pr -def make_closed_sub_issue_record_with_pr(mocker: pytest.MonkeyPatch, number: int) -> SubIssueRecord: +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: pytest.MonkeyPatch, number: int) -> SubIssueRecord: +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: pytest.MonkeyPatch, number: int) -> SubIssueRecord: +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: pytest.MonkeyPatch, number: int) -> HierarchyIssueRecord: +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: pytest.MonkeyPatch, number: int) -> HierarchyIssueRecord: +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: pytest.MonkeyPatch, number: int) -> HierarchyIssueRecord: +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: pytest.MonkeyPatch, number: int) -> HierarchyIssueRecord: +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)) From a006c534489af74921e2dffcdfe05aba1ee1d648 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 30 Mar 2026 21:51:08 +0200 Subject: [PATCH 4/5] Fix review comments. --- docs/features/issue_hierarchy_support.md | 2 +- tests/unit/conftest.py | 2 +- .../model/test_hierarchy_issue_record.py | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/features/issue_hierarchy_support.md b/docs/features/issue_hierarchy_support.md index 08e1d02b..423a55be 100644 --- a/docs/features/issue_hierarchy_support.md +++ b/docs/features/issue_hierarchy_support.md @@ -9,7 +9,7 @@ Represent issue → sub-issue relationships directly in release notes, aggregati - 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 sub-issues are rendered. +- When the parent hierarchy issue is **closed**: all children (sub-issues and nested sub-hierarchy children) are rendered. - 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 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 21b8b969..93814b9d 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1204,7 +1204,7 @@ def make_minimal_issue(mocker: MockerFixture, state: str, number: int) -> Issue: issue = mocker.Mock(spec=Issue) issue.state = state issue.number = number - issue.title = f"Issue #{number}" + issue.title = "Minimal test issue" issue.body = None issue.type = None issue.user = None diff --git a/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py b/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py index 70b7fa85..5aa43dff 100644 --- a/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py +++ b/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py @@ -89,7 +89,7 @@ def test_to_chapter_row_open_parent_suppresses_closed_sub_hierarchy_issue_from_p parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number=300) record = HierarchyIssueRecord(parent) - record._sub_hierarchy_issues["350"] = make_closed_sub_hierarchy_record_no_pr(mocker, number=350) + record.sub_hierarchy_issues["350"] = make_closed_sub_hierarchy_record_no_pr(mocker, number=350) row = record.to_chapter_row() @@ -104,8 +104,8 @@ def test_to_chapter_row_closed_parent_renders_closed_and_open_sub_hierarchy_issu parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number=300) record = HierarchyIssueRecord(parent) - record._sub_hierarchy_issues["350"] = make_closed_sub_hierarchy_record_with_pr(mocker, number=350) - record._sub_hierarchy_issues["360"] = make_open_sub_hierarchy_record_no_pr(mocker, number=360) + record.sub_hierarchy_issues["350"] = make_closed_sub_hierarchy_record_with_pr(mocker, number=350) + record.sub_hierarchy_issues["360"] = make_open_sub_hierarchy_record_no_pr(mocker, number=360) row = record.to_chapter_row() @@ -123,8 +123,8 @@ def test_to_chapter_row_open_parent_only_renders_sub_hierarchy_issues_with_chang parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number=300) record = HierarchyIssueRecord(parent) - record._sub_hierarchy_issues["350"] = make_closed_sub_hierarchy_record_with_pr(mocker, number=350) - record._sub_hierarchy_issues["360"] = make_open_sub_hierarchy_record_no_pr(mocker, number=360) + record.sub_hierarchy_issues["350"] = make_closed_sub_hierarchy_record_with_pr(mocker, number=350) + record.sub_hierarchy_issues["360"] = make_open_sub_hierarchy_record_no_pr(mocker, number=360) row = record.to_chapter_row() @@ -141,7 +141,7 @@ def test_to_chapter_row_open_parent_renders_open_sub_hierarchy_issue_with_change parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number=300) record = HierarchyIssueRecord(parent) - record._sub_hierarchy_issues["360"] = make_open_sub_hierarchy_record_with_pr(mocker, number=360) + record.sub_hierarchy_issues["360"] = make_open_sub_hierarchy_record_with_pr(mocker, number=360) row = record.to_chapter_row() From e6ad80caf88d60e71bf0c5f63d7dbd6ed884a769 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 1 Apr 2026 14:46:45 +0200 Subject: [PATCH 5/5] feat: add support for highlighting open sub-issues under closed hierarchy parents --- action.yml | 5 ++ docs/features/issue_hierarchy_support.md | 9 ++- release_notes_generator/action_inputs.py | 8 ++ .../model/record/hierarchy_issue_record.py | 16 +++- release_notes_generator/utils/constants.py | 1 + tests/unit/conftest.py | 4 + .../model/test_hierarchy_issue_record.py | 73 +++++++++++++------ 7 files changed, 91 insertions(+), 25 deletions(-) diff --git a/action.yml b/action.yml index dbf935ee..936c16a2 100644 --- a/action.yml +++ b/action.yml @@ -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 @@ -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 }} diff --git a/docs/features/issue_hierarchy_support.md b/docs/features/issue_hierarchy_support.md index 89dd0807..87dceac0 100644 --- a/docs/features/issue_hierarchy_support.md +++ b/docs/features/issue_hierarchy_support.md @@ -9,7 +9,7 @@ Represent issue → sub-issue relationships directly in release notes, aggregati - 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. +- 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 @@ -26,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 🎉 @@ -36,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.) diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index ec6555c7..fe55ec64 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -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, @@ -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: """ diff --git a/release_notes_generator/model/record/hierarchy_issue_record.py b/release_notes_generator/model/record/hierarchy_issue_record.py index 36f5cf7b..5696d790 100644 --- a/release_notes_generator/model/record/hierarchy_issue_record.py +++ b/release_notes_generator/model/record/hierarchy_issue_record.py @@ -222,7 +222,15 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str: 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) - row = f"{row}\n{sub_hierarchy_issue.to_chapter_row()}" + 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: @@ -237,7 +245,11 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str: # Closed parent: render all sub-issues regardless of state or change increment logger.debug("Rendering sub-issue #%s", sub_issue.issue.number) - sub_issue_block = "- " + sub_issue.to_chapter_row() + 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() ) diff --git a/release_notes_generator/utils/constants.py b/release_notes_generator/utils/constants.py index 89693c24..38a2c35b 100644 --- a/release_notes_generator/utils/constants.py +++ b/release_notes_generator/utils/constants.py @@ -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" diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index e1604e25..ea8b2817 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1328,3 +1328,7 @@ def patch_hierarchy_action_inputs(mocker): "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="🟡", + ) diff --git a/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py b/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py index d799f86e..a8fb515f 100644 --- a/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py +++ b/tests/unit/release_notes_generator/model/test_hierarchy_issue_record.py @@ -25,6 +25,7 @@ make_closed_sub_issue_record_no_pr, make_closed_sub_issue_record_with_pr, make_minimal_issue, + make_minimal_pr, make_open_sub_hierarchy_record_no_pr, make_open_sub_hierarchy_record_with_pr, make_open_sub_issue_record_no_pr, @@ -39,8 +40,8 @@ def test_to_chapter_row_closed_parent_renders_closed_and_open_sub_issues(mocker, parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number=300) record = HierarchyIssueRecord(parent) - record._sub_issues["450"] = make_closed_sub_issue_record_with_pr(mocker, number=450) - record._sub_issues["400"] = make_open_sub_issue_record_no_pr(mocker, number=400) + record.sub_issues["450"] = make_closed_sub_issue_record_with_pr(mocker, number=450) + record.sub_issues["400"] = make_open_sub_issue_record_no_pr(mocker, number=400) row = record.to_chapter_row() @@ -48,6 +49,44 @@ def test_to_chapter_row_closed_parent_renders_closed_and_open_sub_issues(mocker, assert "#400" in row, f"Open sub-issue #400 must appear when parent is closed; got:\n{row}" +def test_to_chapter_row_closed_parent_highlights_open_sub_issue(mocker, patch_hierarchy_action_inputs): + """Open sub-issue under a closed parent is prefixed with the open-hierarchy-sub-issue-icon.""" + parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number=300) + record = HierarchyIssueRecord(parent) + + record.sub_issues["400"] = make_open_sub_issue_record_no_pr(mocker, number=400) + record.sub_issues["450"] = make_closed_sub_issue_record_with_pr(mocker, number=450) + + row = record.to_chapter_row() + + lines = row.splitlines() + open_line = next((l for l in lines if "#400" in l), None) + closed_line = next((l for l in lines if "#450" in l), None) + assert open_line is not None, f"Open sub-issue #400 must be present; got:\n{row}" + assert "🟡" in open_line, f"Open sub-issue line must contain icon; got: {open_line!r}" + assert closed_line is not None, f"Closed sub-issue #450 must be present; got:\n{row}" + assert "🟡" not in closed_line, f"Closed sub-issue line must NOT contain icon; got: {closed_line!r}" + + +def test_to_chapter_row_closed_parent_highlights_open_sub_hierarchy_issue(mocker, patch_hierarchy_action_inputs): + """Open sub-hierarchy-issue under a closed parent is prefixed with the open-hierarchy-sub-issue-icon.""" + parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_CLOSED, number=300) + record = HierarchyIssueRecord(parent) + + record.sub_hierarchy_issues["360"] = make_open_sub_hierarchy_record_with_pr(mocker, number=360) + record.sub_hierarchy_issues["350"] = make_closed_sub_hierarchy_record_with_pr(mocker, number=350) + + row = record.to_chapter_row() + + lines = row.splitlines() + open_line = next((l for l in lines if "#360" in l), None) + closed_line = next((l for l in lines if "#350" in l), None) + assert open_line is not None, f"Open sub-hierarchy #360 must be present; got:\n{row}" + assert "🟡" in open_line, f"Open sub-hierarchy line must contain icon; got: {open_line!r}" + assert closed_line is not None, f"Closed sub-hierarchy #350 must be present; got:\n{row}" + assert "🟡" not in closed_line, f"Closed sub-hierarchy line must NOT contain icon; got: {closed_line!r}" + + def test_to_chapter_row_open_parent_only_renders_closed_sub_issues_with_change_increment(mocker, patch_hierarchy_action_inputs): """ Open parent with one closed sub-issue (has PR) and one open sub-issue (no PR) → @@ -56,8 +95,8 @@ def test_to_chapter_row_open_parent_only_renders_closed_sub_issues_with_change_i parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number=300) record = HierarchyIssueRecord(parent) - record._sub_issues["450"] = make_closed_sub_issue_record_with_pr(mocker, number=450) - record._sub_issues["400"] = make_open_sub_issue_record_no_pr(mocker, number=400) + record.sub_issues["450"] = make_closed_sub_issue_record_with_pr(mocker, number=450) + record.sub_issues["400"] = make_open_sub_issue_record_no_pr(mocker, number=400) row = record.to_chapter_row() @@ -73,7 +112,7 @@ def test_to_chapter_row_open_parent_suppresses_closed_sub_issue_from_previous_re parent = make_minimal_issue(mocker, IssueRecord.ISSUE_STATE_OPEN, number=300) record = HierarchyIssueRecord(parent) - record._sub_issues["450"] = make_closed_sub_issue_record_no_pr(mocker, number=450) + record.sub_issues["450"] = make_closed_sub_issue_record_no_pr(mocker, number=450) row = record.to_chapter_row() @@ -332,24 +371,14 @@ def test_progress_per_level_independence(make_hierarchy_issue, make_sub_issue): assert child_c.progress == "", f"child_c: {child_c.progress!r}" -def _make_mock_pull(mocker, number: int): - """Create a minimal mock PullRequest with the given number.""" - from github.PullRequest import PullRequest as GHPullRequest - - pull = mocker.Mock(spec=GHPullRequest) - pull.number = number - pull.get_labels.return_value = [] - return pull - - def test_contains_change_increment_false_when_all_sub_issues_open(mocker, make_hierarchy_issue, make_sub_issue): """Bug regression: open hierarchy with only open sub-issues (with PRs) must not appear in release notes.""" parent = HierarchyIssueRecord(make_hierarchy_issue(10, IssueRecord.ISSUE_STATE_OPEN)) sub1 = make_sub_issue(11, IssueRecord.ISSUE_STATE_OPEN) - sub1.register_pull_request(_make_mock_pull(mocker, 111)) + sub1.register_pull_request(make_minimal_pr(mocker, 111)) sub2 = make_sub_issue(12, IssueRecord.ISSUE_STATE_OPEN) - sub2.register_pull_request(_make_mock_pull(mocker, 112)) + sub2.register_pull_request(make_minimal_pr(mocker, 112)) parent.sub_issues.update({"org/repo#11": sub1, "org/repo#12": sub2}) assert parent.contains_change_increment() is False @@ -360,9 +389,9 @@ def test_contains_change_increment_true_when_one_closed_sub_issue_has_pr(mocker, parent = HierarchyIssueRecord(make_hierarchy_issue(20, IssueRecord.ISSUE_STATE_OPEN)) open_sub = make_sub_issue(21, IssueRecord.ISSUE_STATE_OPEN) - open_sub.register_pull_request(_make_mock_pull(mocker, 211)) + open_sub.register_pull_request(make_minimal_pr(mocker, 211)) closed_sub = make_sub_issue(22, IssueRecord.ISSUE_STATE_CLOSED) - closed_sub.register_pull_request(_make_mock_pull(mocker, 212)) + closed_sub.register_pull_request(make_minimal_pr(mocker, 212)) parent.sub_issues.update({"org/repo#21": open_sub, "org/repo#22": closed_sub}) assert parent.contains_change_increment() is True @@ -378,7 +407,7 @@ def test_contains_change_increment_false_leaf_no_prs(make_hierarchy_issue): def test_contains_change_increment_true_leaf_with_direct_pr(mocker, make_hierarchy_issue): """A hierarchy issue with a direct PR on itself (not from sub-issues) returns True.""" record = HierarchyIssueRecord(make_hierarchy_issue(40, IssueRecord.ISSUE_STATE_OPEN)) - record.register_pull_request(_make_mock_pull(mocker, 401)) + record.register_pull_request(make_minimal_pr(mocker, 401)) assert record.contains_change_increment() is True @@ -401,7 +430,7 @@ def test_contains_change_increment_false_nested_open_only(mocker, make_hierarchy child = HierarchyIssueRecord(make_hierarchy_issue(61, IssueRecord.ISSUE_STATE_OPEN)) open_leaf = make_sub_issue(62, IssueRecord.ISSUE_STATE_OPEN) - open_leaf.register_pull_request(_make_mock_pull(mocker, 621)) + open_leaf.register_pull_request(make_minimal_pr(mocker, 621)) child.sub_issues.update({"org/repo#62": open_leaf}) root.sub_hierarchy_issues.update({"org/repo#61": child}) @@ -418,7 +447,7 @@ def test_contains_change_increment_true_nested_with_closed_leaf(mocker, make_hie child = HierarchyIssueRecord(make_hierarchy_issue(71, IssueRecord.ISSUE_STATE_OPEN)) closed_leaf = make_sub_issue(72, IssueRecord.ISSUE_STATE_CLOSED) - closed_leaf.register_pull_request(_make_mock_pull(mocker, 721)) + closed_leaf.register_pull_request(make_minimal_pr(mocker, 721)) child.sub_issues.update({"org/repo#72": closed_leaf}) root.sub_hierarchy_issues.update({"org/repo#71": child})