From 1d18561730e2fc25af3fb583a01a895e4da02d38 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Tue, 14 Apr 2026 14:47:05 +0200 Subject: [PATCH 1/7] feat: add service chapter exclusion rules for release notes generation --- action.yml | 14 ++ docs/configuration_reference.md | 1 + docs/features/service_chapters.md | 41 ++++++ release_notes_generator/action_inputs.py | 65 +++++++++ release_notes_generator/builder/builder.py | 1 + .../chapters/service_chapters.py | 124 ++++++++++------ release_notes_generator/utils/constants.py | 2 + .../chapters/test_service_chapters.py | 93 ++++++++++++ .../test_action_inputs.py | 135 ++++++++++++++++++ 9 files changed, 436 insertions(+), 40 deletions(-) diff --git a/action.yml b/action.yml index a3a6edd8..5f4dec95 100644 --- a/action.yml +++ b/action.yml @@ -101,6 +101,20 @@ inputs: - "Others - No Topic ⚠️" required: false default: '' + service-chapter-exclude: + description: | + YAML mapping of service chapter title to label-exclusion groups. + Each group is a list of labels (AND logic within a group). + Multiple groups per chapter are evaluated with OR logic. + Use the reserved key "*" to define global rules that apply to all service chapters. + Example: + service-chapter-exclude: | + "*": + - [scope:security, type:tech-debt] + Closed Issues without Pull Request ⚠️: + - [scope:security, type:false-positive] + required: false + default: '' print-empty-chapters: description: 'Print chapters even if they are empty.' required: false diff --git a/docs/configuration_reference.md b/docs/configuration_reference.md index 02b778f8..15b8729b 100644 --- a/docs/configuration_reference.md +++ b/docs/configuration_reference.md @@ -15,6 +15,7 @@ This page lists all action inputs and outputs with defaults. Grouped for readabi | `warnings` | No | `true` | Toggle Service Chapters generation. | | `hidden-service-chapters` | No | "" | Comma or newline list of service chapter titles to hide from output. Title matching is exact and case-sensitive. Only effective when `warnings: true`. | | `service-chapter-order` | No | "" | Comma or newline list of service chapter titles controlling display order. Listed titles render first; unlisted titles appended in default order. Title matching is exact and case-sensitive. Only effective when `warnings: true`. | +| `service-chapter-exclude` | No | "" | YAML mapping of service chapter title to label-exclusion groups. Each group is a list of labels (AND logic). Multiple groups per chapter use OR logic. Use the reserved key `"*"` for global rules applied to all service chapters. Only effective when `warnings: true`. | | `print-empty-chapters` | No | `true` | Print chapter headings even when empty. | | `duplicity-scope` | No | `both` | Where duplicates are allowed: `none`, `custom`, `service`, `both`. Case-insensitive. | | `duplicity-icon` | No | `🔔` | One-character icon prefixed on duplicate rows. | diff --git a/docs/features/service_chapters.md b/docs/features/service_chapters.md index af815e36..93725c85 100644 --- a/docs/features/service_chapters.md +++ b/docs/features/service_chapters.md @@ -48,6 +48,7 @@ The service chapters appear in the following order in the generated release note warnings: true # enable service chapters (default) hidden-service-chapters: '' # hide specific service chapters (default: empty) service-chapter-order: '' # custom display order (default: empty = default order) + service-chapter-exclude: '' # label-exclusion rules per chapter (default: empty) print-empty-chapters: true # show even when empty (default) duplicity-scope: "both" # allow duplicates across custom + service ``` @@ -116,6 +117,46 @@ Or use comma-separated format: **Note:** Title matching is exact and case-sensitive. The `service-chapter-order` input is independent of `hidden-service-chapters`; hidden chapters are still hidden even if listed in the order. +### Label-Based Exclusion Rules +Use `service-chapter-exclude` to filter out issues from service chapters by label combinations. Each rule is a group of labels that must **all** be present on an issue (AND logic) for the issue to be excluded. Multiple groups per chapter are evaluated with OR logic (any group match excludes the issue). + +**Per-chapter exclusion** — excludes a matching record from that chapter only: + +```yaml +- name: Generate Release Notes + with: + warnings: true + service-chapter-exclude: | + Closed Issues without Pull Request ⚠️: + - [scope:security, type:tech-debt] + - [scope:security, type:false-positive] + Others - No Topic ⚠️: + - [wontfix] +``` + +**Global exclusion** — use the reserved key `"*"` to exclude matching records from **all** service chapters: + +```yaml +- name: Generate Release Notes + with: + warnings: true + service-chapter-exclude: | + "*": + - [scope:security, type:tech-debt] + Closed Issues without Pull Request ⚠️: + - [scope:security, type:false-positive] +``` + +**Behavior:** +- Within a group, all labels must be present on the issue (AND logic). +- Across groups, any single match is sufficient to exclude (OR logic). +- The `"*"` key applies to every service chapter; a matching record is dropped entirely. +- Per-chapter rules only affect the specified chapter; the record may still appear in other chapters. +- Global exclusion takes precedence over per-chapter rules. +- Label strings that do not appear on any record simply prevent the group from matching. +- Unknown chapter titles are skipped with a warning. +- If omitted or empty, no exclusion is applied. + ## Example Result ```markdown ### Closed Issues without User Defined Labels ⚠️ diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index 0b7907cd..e1dce383 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -36,6 +36,8 @@ WARNINGS, HIDDEN_SERVICE_CHAPTERS, SERVICE_CHAPTER_ORDER, + SERVICE_CHAPTER_EXCLUDE, + GLOBAL_EXCLUDE_KEY, RUNNER_DEBUG, PRINT_EMPTY_CHAPTERS, DUPLICITY_SCOPE, @@ -417,6 +419,68 @@ def get_service_chapter_order() -> list[str]: return ordered + @staticmethod + def get_service_chapter_exclude() -> dict[str, list[list[str]]]: + """ + Get label-exclusion rules for service chapters from the action inputs. + + Returns: + Mapping of chapter title (or ``"*"`` for global) to a list of label + groups. Each group is a list of label strings (AND logic within a + group, OR logic across groups). + """ + valid_titles = set(DEFAULT_SERVICE_CHAPTER_ORDER) + + raw = get_action_input(SERVICE_CHAPTER_EXCLUDE, "") + if not isinstance(raw, str): + logger.error("Error: 'service-chapter-exclude' is not a valid string.") + return {} + + raw = raw.strip() + if not raw: + return {} + + try: + parsed = yaml.safe_load(raw) + if parsed is None: + return {} + if not isinstance(parsed, dict): + logger.error("Error: 'service-chapter-exclude' input is not a valid YAML mapping.") + return {} + except yaml.YAMLError as exc: + logger.error("Error parsing 'service-chapter-exclude' input: %s", exc) + return {} + + result: dict[str, list[list[str]]] = {} + for title, groups in parsed.items(): + title_str = str(title) + + if title_str != GLOBAL_EXCLUDE_KEY and title_str not in valid_titles: + logger.warning("Unknown service chapter title '%s' in 'service-chapter-exclude'. Skipping.", title_str) + continue + + if not isinstance(groups, list): + logger.warning( + "Value for '%s' in 'service-chapter-exclude' is not a list. Skipping.", + title_str, + ) + continue + + validated_groups: list[list[str]] = [] + for group in groups: + if not isinstance(group, list): + logger.warning( + "Group entry under '%s' in 'service-chapter-exclude' is not a list: %s. Skipping.", + title_str, + group, + ) + continue + validated_groups.append([str(label) for label in group]) + + result[title_str] = validated_groups + + return result + @staticmethod def get_print_empty_chapters() -> bool: """ @@ -612,6 +676,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("Service chapter exclude: %s", ActionInputs.get_service_chapter_exclude()) logger.debug("Super chapters (raw): %s", get_action_input(SUPER_CHAPTERS, default="")) @staticmethod diff --git a/release_notes_generator/builder/builder.py b/release_notes_generator/builder/builder.py index 999dd914..4464d8c3 100644 --- a/release_notes_generator/builder/builder.py +++ b/release_notes_generator/builder/builder.py @@ -72,6 +72,7 @@ def build(self) -> str: used_record_numbers=self.custom_chapters.populated_record_numbers_list, hidden_chapters=ActionInputs.get_hidden_service_chapters(), chapter_order=ActionInputs.get_service_chapter_order(), + exclude_rules=ActionInputs.get_service_chapter_exclude(), ) service_chapters.populate(self.records) diff --git a/release_notes_generator/chapters/service_chapters.py b/release_notes_generator/chapters/service_chapters.py index 84b2214a..f72e30bb 100644 --- a/release_notes_generator/chapters/service_chapters.py +++ b/release_notes_generator/chapters/service_chapters.py @@ -40,6 +40,7 @@ OTHERS_NO_TOPIC, DIRECT_COMMITS, DEFAULT_SERVICE_CHAPTER_ORDER, + GLOBAL_EXCLUDE_KEY, ) from release_notes_generator.utils.enums import DuplicityScopeEnum @@ -60,6 +61,7 @@ def __init__( used_record_numbers: Optional[list[int | str]] = None, hidden_chapters: Optional[list[str]] = None, chapter_order: Optional[list[str]] = None, + exclude_rules: Optional[dict[str, list[list[str]]]] = None, ): super().__init__(sort_ascending, print_empty_chapters) @@ -107,6 +109,13 @@ def __init__( self.show_chapter_merged_prs_linked_to_open_issues = True + # Exclude rules: split "*" (global) from per-chapter entries + all_rules = exclude_rules if exclude_rules is not None else {} + self._global_exclude_groups: list[list[str]] = all_rules.get(GLOBAL_EXCLUDE_KEY, []) + self._per_chapter_exclude_groups: dict[str, list[list[str]]] = { + k: v for k, v in all_rules.items() if k != GLOBAL_EXCLUDE_KEY + } + def populate(self, records: dict[str, Record]) -> None: """ Populates the service chapters with records. @@ -123,6 +132,11 @@ def populate(self, records: dict[str, Record]) -> None: if self.__is_row_present(record_id) and not self.duplicity_allowed(): continue + # global exclusion: if any "*" group is a subset of the record labels, drop entirely + if self._is_globally_excluded(record): + logger.debug("Record %s globally excluded by '*' rule.", record_id) + continue + # main three situations: if record.is_closed and isinstance(record, IssueRecord): self.__populate_closed_issues(cast(IssueRecord, record), record_id) @@ -144,20 +158,22 @@ def populate(self, records: dict[str, Record]) -> None: logger.debug("Skipping open HierarchyIssueRecord %s (pr_count=%d)", record_id, pr_count) elif is_issue_like and pr_count > 0: # Open issue/sub-issue with linked PRs → add to the specific chapter - record.add_to_chapter_presence(MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES) - self.chapters[MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES].add_row( - record_id, record.to_chapter_row() - ) - logger.debug("Linked PRs for open issue %s; added to chapter.", record_id) - self.used_record_numbers.append(record_id) + if not self._is_excluded_from_chapter(record, MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES): + record.add_to_chapter_presence(MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES) + self.chapters[MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES].add_row( + record_id, record.to_chapter_row() + ) + logger.debug("Linked PRs for open issue %s; added to chapter.", record_id) + self.used_record_numbers.append(record_id) else: # Open issue/sub-issue with no PRs → explicitly do nothing (keeps original behavior) pass else: if record_id not in self.used_record_numbers: - record.add_to_chapter_presence(OTHERS_NO_TOPIC) - self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) - self.used_record_numbers.append(record_id) + if not self._is_excluded_from_chapter(record, OTHERS_NO_TOPIC): + record.add_to_chapter_presence(OTHERS_NO_TOPIC) + self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) + self.used_record_numbers.append(record_id) def __populate_closed_issues(self, record: IssueRecord, record_id: int | str) -> None: """ @@ -175,10 +191,11 @@ def __populate_closed_issues(self, record: IssueRecord, record_id: int | str) -> pulls_count = record.pull_requests_count() if pulls_count == 0: - record.add_to_chapter_presence(CLOSED_ISSUES_WITHOUT_PULL_REQUESTS) - self.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].add_row(record_id, record.to_chapter_row()) - self.used_record_numbers.append(record_id) - populated = True + if not self._is_excluded_from_chapter(record, CLOSED_ISSUES_WITHOUT_PULL_REQUESTS): + record.add_to_chapter_presence(CLOSED_ISSUES_WITHOUT_PULL_REQUESTS) + self.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].add_row(record_id, record.to_chapter_row()) + self.used_record_numbers.append(record_id) + populated = True # check record properties if it fits to a chapter: CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS if not record.contains_min_one_label(self.user_defined_labels): @@ -186,10 +203,11 @@ def __populate_closed_issues(self, record: IssueRecord, record_id: int | str) -> if self.__is_row_present(record_id) and not self.duplicity_allowed(): return - record.add_to_chapter_presence(CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS) - self.chapters[CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS].add_row(record_id, record.to_chapter_row()) - self.used_record_numbers.append(record_id) - populated = True + if not self._is_excluded_from_chapter(record, CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS): + record.add_to_chapter_presence(CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS) + self.chapters[CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS].add_row(record_id, record.to_chapter_row()) + self.used_record_numbers.append(record_id) + populated = True if pulls_count > 0: # the record looks to be valid closed issue with 1+ pull requests, no reason to report it @@ -202,9 +220,10 @@ def __populate_closed_issues(self, record: IssueRecord, record_id: int | str) -> if record_id in self.used_record_numbers: return - record.add_to_chapter_presence(OTHERS_NO_TOPIC) - self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) - self.used_record_numbers.append(record_id) + if not self._is_excluded_from_chapter(record, OTHERS_NO_TOPIC): + record.add_to_chapter_presence(OTHERS_NO_TOPIC) + self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) + self.used_record_numbers.append(record_id) def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None: """ @@ -223,20 +242,22 @@ def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None if self.__is_row_present(record_id) and not self.duplicity_allowed(): return - record.add_to_chapter_presence(MERGED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS) - self.chapters[MERGED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS].add_row( - record_id, record.to_chapter_row() - ) - self.used_record_numbers.append(record_id) + if not self._is_excluded_from_chapter(record, MERGED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS): + record.add_to_chapter_presence(MERGED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS) + self.chapters[MERGED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS].add_row( + record_id, record.to_chapter_row() + ) + self.used_record_numbers.append(record_id) # check record properties if it fits to a chapter: MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES if record.contains_issue_mentions(): if self.__is_row_present(record_id) and not self.duplicity_allowed(): return - record.add_to_chapter_presence(MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES) - self.chapters[MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES].add_row(record_id, record.to_chapter_row()) - self.used_record_numbers.append(record_id) + if not self._is_excluded_from_chapter(record, MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES): + record.add_to_chapter_presence(MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES) + self.chapters[MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES].add_row(record_id, record.to_chapter_row()) + self.used_record_numbers.append(record_id) if not record.is_present_in_chapters: if self.__is_row_present(record_id) and not self.duplicity_allowed(): @@ -245,9 +266,10 @@ def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None if record_id in self.used_record_numbers: return - record.add_to_chapter_presence(OTHERS_NO_TOPIC) - self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) - self.used_record_numbers.append(record_id) + if not self._is_excluded_from_chapter(record, OTHERS_NO_TOPIC): + record.add_to_chapter_presence(OTHERS_NO_TOPIC) + self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) + self.used_record_numbers.append(record_id) # check record properties if it fits to a chapter: CLOSED_PRS_WITHOUT_ISSUE elif ( @@ -258,9 +280,12 @@ def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None if self.__is_row_present(record_id) and not self.duplicity_allowed(): return - record.add_to_chapter_presence(CLOSED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS) - self.chapters[CLOSED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS].add_row(record_id, record.to_chapter_row()) - self.used_record_numbers.append(record_id) + if not self._is_excluded_from_chapter(record, CLOSED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS): + record.add_to_chapter_presence(CLOSED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS) + self.chapters[CLOSED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS].add_row( + record_id, record.to_chapter_row() + ) + self.used_record_numbers.append(record_id) else: if self.__is_row_present(record_id) and not self.duplicity_allowed(): @@ -270,9 +295,10 @@ def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None return # not record.is_present_in_chapters: - record.add_to_chapter_presence(OTHERS_NO_TOPIC) - self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) - self.used_record_numbers.append(record_id) + if not self._is_excluded_from_chapter(record, OTHERS_NO_TOPIC): + record.add_to_chapter_presence(OTHERS_NO_TOPIC) + self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) + self.used_record_numbers.append(record_id) def __populate_direct_commit(self, record: CommitRecord, record_id: int | str) -> None: """ @@ -281,9 +307,10 @@ def __populate_direct_commit(self, record: CommitRecord, record_id: int | str) - @param record: The CommitRecord object representing the direct commit. @return: None """ - record.add_to_chapter_presence(DIRECT_COMMITS) - self.chapters[DIRECT_COMMITS].add_row(record_id, record.to_chapter_row()) - self.used_record_numbers.append(record_id) + if not self._is_excluded_from_chapter(record, DIRECT_COMMITS): + record.add_to_chapter_presence(DIRECT_COMMITS) + self.chapters[DIRECT_COMMITS].add_row(record_id, record.to_chapter_row()) + self.used_record_numbers.append(record_id) def __is_row_present(self, record_id: int | str) -> bool: """ @@ -294,6 +321,23 @@ def __is_row_present(self, record_id: int | str) -> bool: """ return record_id in self.used_record_numbers + def _is_globally_excluded(self, record: Record) -> bool: + """Check if the record matches any global (``"*"``) exclusion group.""" + return self._matches_any_group(record, self._global_exclude_groups) + + def _is_excluded_from_chapter(self, record: Record, chapter_title: str) -> bool: + """Check if the record matches any per-chapter exclusion group for *chapter_title*.""" + groups = self._per_chapter_exclude_groups.get(chapter_title, []) + return self._matches_any_group(record, groups) + + @staticmethod + def _matches_any_group(record: Record, groups: list[list[str]]) -> bool: + """Return True if the record labels are a superset of any label group.""" + if not groups: + return False + record_labels = set(record.labels) + return any(record_labels.issuperset(group) for group in groups if group) + @staticmethod def duplicity_allowed() -> bool: """ diff --git a/release_notes_generator/utils/constants.py b/release_notes_generator/utils/constants.py index 5f11a912..b3efdfdd 100644 --- a/release_notes_generator/utils/constants.py +++ b/release_notes_generator/utils/constants.py @@ -48,6 +48,8 @@ WARNINGS = "warnings" HIDDEN_SERVICE_CHAPTERS = "hidden-service-chapters" SERVICE_CHAPTER_ORDER = "service-chapter-order" +SERVICE_CHAPTER_EXCLUDE = "service-chapter-exclude" +GLOBAL_EXCLUDE_KEY = "*" PRINT_EMPTY_CHAPTERS = "print-empty-chapters" # Super chapter fallback heading diff --git a/tests/unit/release_notes_generator/chapters/test_service_chapters.py b/tests/unit/release_notes_generator/chapters/test_service_chapters.py index f2a353e5..0cb38f43 100644 --- a/tests/unit/release_notes_generator/chapters/test_service_chapters.py +++ b/tests/unit/release_notes_generator/chapters/test_service_chapters.py @@ -227,3 +227,96 @@ def test_chapter_order_none_uses_default(): """Passing chapter_order=None should use the default order.""" sc = ServiceChapters(chapter_order=None) assert sc.chapter_order == DEFAULT_SERVICE_CHAPTER_ORDER + + +def test_populate_per_chapter_no_exclude_rules(service_chapters, record_with_issue_closed_no_pull): + """Default behaviour unchanged when no exclude rules; also covers: no '*' global key -> normal routing.""" + service_chapters.populate({1: record_with_issue_closed_no_pull}) + assert 1 == len(service_chapters.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) + + +def test_populate_per_chapter_full_match_excluded(record_with_issue_closed_no_pull): + """AND logic: all labels present -> excluded from that chapter; also covers: chapter isolation (rules for one chapter do not affect others).""" + sc = ServiceChapters( + user_defined_labels=["bug", "enhancement"], + exclude_rules={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1", "label2"]]}, + ) + sc.populate({1: record_with_issue_closed_no_pull}) + assert 0 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) + # still present in the other chapter it qualifies for + assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS].rows) + + +def test_populate_per_chapter_or_logic_second_group_matches(record_with_issue_closed_no_pull): + """OR logic: second group match is sufficient for exclusion.""" + sc = ServiceChapters( + user_defined_labels=["bug", "enhancement"], + exclude_rules={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["nonexistent"], ["label1", "label2"]]}, + ) + sc.populate({1: record_with_issue_closed_no_pull}) + assert 0 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) + + +def test_populate_per_chapter_partial_and_failure(record_with_issue_closed_no_pull): + """AND failure: missing one label -> not excluded.""" + sc = ServiceChapters( + user_defined_labels=["bug", "enhancement"], + exclude_rules={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1", "nonexistent"]]}, + ) + sc.populate({1: record_with_issue_closed_no_pull}) + assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) + + +def test_populate_per_chapter_no_label_overlap(record_with_issue_closed_no_pull): + """No match -> not excluded.""" + sc = ServiceChapters( + user_defined_labels=["bug", "enhancement"], + exclude_rules={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["alpha", "beta"]]}, + ) + sc.populate({1: record_with_issue_closed_no_pull}) + assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) + + +def test_populate_per_chapter_empty_rules_no_exclusion(record_with_issue_closed_no_pull): + """Empty exclusion list -> no-op.""" + sc = ServiceChapters( + user_defined_labels=["bug", "enhancement"], + exclude_rules={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: []}, + ) + sc.populate({1: record_with_issue_closed_no_pull}) + assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) + + +def test_populate_global_full_match_excluded_from_all(record_with_issue_closed_no_pull): + """'*' match drops record from all chapters.""" + sc = ServiceChapters( + user_defined_labels=["bug", "enhancement"], + exclude_rules={"*": [["label1", "label2"]]}, + ) + sc.populate({1: record_with_issue_closed_no_pull}) + for chapter in sc.chapters.values(): + assert 0 == len(chapter.rows), f"Chapter '{chapter.title}' should be empty" + + +def test_populate_global_partial_and_failure(record_with_issue_closed_no_pull): + """'*' AND failure -> record not excluded.""" + sc = ServiceChapters( + user_defined_labels=["bug", "enhancement"], + exclude_rules={"*": [["label1", "nonexistent"]]}, + ) + sc.populate({1: record_with_issue_closed_no_pull}) + assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) + + +def test_populate_global_precedes_per_chapter(record_with_issue_closed_no_pull): + """Global exclusion takes precedence over per-chapter rules.""" + sc = ServiceChapters( + user_defined_labels=["bug", "enhancement"], + exclude_rules={ + "*": [["label1", "label2"]], + CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1"]], + }, + ) + sc.populate({1: record_with_issue_closed_no_pull}) + for chapter in sc.chapters.values(): + assert 0 == len(chapter.rows), f"Chapter '{chapter.title}' should be empty" diff --git a/tests/unit/release_notes_generator/test_action_inputs.py b/tests/unit/release_notes_generator/test_action_inputs.py index 0ffdf1ad..4e1d4d14 100644 --- a/tests/unit/release_notes_generator/test_action_inputs.py +++ b/tests/unit/release_notes_generator/test_action_inputs.py @@ -689,6 +689,141 @@ def test_detect_row_format_invalid_keywords_unknown_row_type(caplog): assert any("Unknown row_type" in r.message for r in caplog.records) +def test_get_service_chapter_exclude_default(mocker): + """Empty dict when env var absent.""" + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="") + assert ActionInputs.get_service_chapter_exclude() == {} + + +def test_get_service_chapter_exclude_single_chapter_single_group(mocker): + """One chapter title, one group parsed correctly.""" + yaml_input = f'{CLOSED_ISSUES_WITHOUT_PULL_REQUESTS}:\n - [scope:security, type:tech-debt]\n' + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value=yaml_input) + result = ActionInputs.get_service_chapter_exclude() + assert result == {CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["scope:security", "type:tech-debt"]]} + + +def test_get_service_chapter_exclude_single_chapter_multiple_groups(mocker): + """Multiple groups for one chapter (OR logic).""" + yaml_input = ( + f'{CLOSED_ISSUES_WITHOUT_PULL_REQUESTS}:\n' + f' - [scope:security, type:tech-debt]\n' + f' - [scope:security, type:false-positive]\n' + ) + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value=yaml_input) + result = ActionInputs.get_service_chapter_exclude() + assert result == { + CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [ + ["scope:security", "type:tech-debt"], + ["scope:security", "type:false-positive"], + ] + } + + +def test_get_service_chapter_exclude_multiple_chapters(mocker): + """Multiple chapter titles parsed.""" + yaml_input = ( + f'{CLOSED_ISSUES_WITHOUT_PULL_REQUESTS}:\n' + f' - [scope:security]\n' + f'{OTHERS_NO_TOPIC}:\n' + f' - [wontfix]\n' + ) + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value=yaml_input) + result = ActionInputs.get_service_chapter_exclude() + assert len(result) == 2 + assert CLOSED_ISSUES_WITHOUT_PULL_REQUESTS in result + assert OTHERS_NO_TOPIC in result + + +def test_get_service_chapter_exclude_global_key_single_group(mocker): + """Reserved '*' key accepted without title validation.""" + yaml_input = '"*":\n - [scope:security, type:tech-debt]\n' + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value=yaml_input) + result = ActionInputs.get_service_chapter_exclude() + assert result == {"*": [["scope:security", "type:tech-debt"]]} + + +def test_get_service_chapter_exclude_global_key_with_per_chapter(mocker): + """'*' key and a chapter title both preserved.""" + yaml_input = ( + f'"*":\n' + f' - [scope:security, type:tech-debt]\n' + f'{CLOSED_ISSUES_WITHOUT_PULL_REQUESTS}:\n' + f' - [scope:security, type:false-positive]\n' + ) + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value=yaml_input) + result = ActionInputs.get_service_chapter_exclude() + assert len(result) == 2 + assert "*" in result + assert CLOSED_ISSUES_WITHOUT_PULL_REQUESTS in result + + +def test_get_service_chapter_exclude_invalid_yaml(mocker): + """Parse error returns empty dict, error logged.""" + mock_log_error = mocker.patch("release_notes_generator.action_inputs.logger.error") + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value=":\n :\n - :") + result = ActionInputs.get_service_chapter_exclude() + assert result == {} + mock_log_error.assert_called_once() + + +def test_get_service_chapter_exclude_not_a_mapping(mocker): + """Non-dict YAML returns empty dict, error logged.""" + mock_log_error = mocker.patch("release_notes_generator.action_inputs.logger.error") + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="- item1\n- item2") + result = ActionInputs.get_service_chapter_exclude() + assert result == {} + mock_log_error.assert_called_once() + + +def test_get_service_chapter_exclude_non_string_input(mocker): + """Non-string env var returns empty dict, error logged.""" + mock_log_error = mocker.patch("release_notes_generator.action_inputs.logger.error") + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value=42) + result = ActionInputs.get_service_chapter_exclude() + assert result == {} + mock_log_error.assert_called_once() + + +def test_get_service_chapter_exclude_unknown_chapter_title(mocker): + """Unknown title skipped with warning.""" + mock_log_warning = mocker.patch("release_notes_generator.action_inputs.logger.warning") + yaml_input = ( + f'{CLOSED_ISSUES_WITHOUT_PULL_REQUESTS}:\n' + f' - [scope:security]\n' + f'Unknown Chapter Title:\n' + f' - [wontfix]\n' + ) + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value=yaml_input) + result = ActionInputs.get_service_chapter_exclude() + assert CLOSED_ISSUES_WITHOUT_PULL_REQUESTS in result + assert "Unknown Chapter Title" not in result + mock_log_warning.assert_called_once() + assert "Unknown service chapter title" in mock_log_warning.call_args[0][0] + + +def test_get_service_chapter_exclude_group_not_a_list(mocker): + """Non-list group skipped with warning, valid group kept.""" + mock_log_warning = mocker.patch("release_notes_generator.action_inputs.logger.warning") + yaml_input = ( + f'{CLOSED_ISSUES_WITHOUT_PULL_REQUESTS}:\n' + f' - [scope:security]\n' + f' - not-a-list\n' + ) + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value=yaml_input) + result = ActionInputs.get_service_chapter_exclude() + assert result == {CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["scope:security"]]} + mock_log_warning.assert_called_once() + + +def test_get_service_chapter_exclude_empty_group_list(mocker): + """Empty list value accepted as no-op.""" + yaml_input = f'{CLOSED_ISSUES_WITHOUT_PULL_REQUESTS}: []\n' + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value=yaml_input) + result = ActionInputs.get_service_chapter_exclude() + assert result == {CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: []} + + # Mirrored test file for release_notes_generator/generator.py # Extracted from previous aggregated test_release_notes_generator.py From 9be97b776c937192cd4d63cdf2f692bf7aa8ee02 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Tue, 14 Apr 2026 15:10:51 +0200 Subject: [PATCH 2/7] Add missing usage of new input in action.yml file. --- action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/action.yml b/action.yml index 5f4dec95..b2d3c8ed 100644 --- a/action.yml +++ b/action.yml @@ -212,6 +212,7 @@ runs: INPUT_WARNINGS: ${{ inputs.warnings }} INPUT_HIDDEN_SERVICE_CHAPTERS: ${{ inputs.hidden-service-chapters }} INPUT_SERVICE_CHAPTER_ORDER: ${{ inputs.service-chapter-order }} + INPUT_SERVICE_CHAPTER_EXCLUDE: ${{ inputs.service-chapter-exclude }} INPUT_PUBLISHED_AT: ${{ inputs.published-at }} INPUT_SKIP_RELEASE_NOTES_LABELS: ${{ inputs.skip-release-notes-labels }} INPUT_PRINT_EMPTY_CHAPTERS: ${{ inputs.print-empty-chapters }} From 99928a8fdca8e22bb09aaf4a7c1d56d67343ea84 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 15 Apr 2026 08:29:57 +0200 Subject: [PATCH 3/7] Fixed review comment and Int. test. --- .github/workflows/test.yml | 2 +- docs/features/service_chapters.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 124766a1..30b0fa35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -209,7 +209,7 @@ jobs: { title: Bugfixes 🛠, label: bug } ] INPUT_WARNINGS: 'true' - INPUT_PRINT_EMPTY_CHAPTERS: 'false' + INPUT_PRINT_EMPTY_CHAPTERS: 'true' INPUT_VERBOSE: 'true' INPUT_HIERARCHY: 'false' INPUT_DUPLICITY_SCOPE: 'both' diff --git a/docs/features/service_chapters.md b/docs/features/service_chapters.md index 93725c85..a25e9763 100644 --- a/docs/features/service_chapters.md +++ b/docs/features/service_chapters.md @@ -118,7 +118,7 @@ Or use comma-separated format: **Note:** Title matching is exact and case-sensitive. The `service-chapter-order` input is independent of `hidden-service-chapters`; hidden chapters are still hidden even if listed in the order. ### Label-Based Exclusion Rules -Use `service-chapter-exclude` to filter out issues from service chapters by label combinations. Each rule is a group of labels that must **all** be present on an issue (AND logic) for the issue to be excluded. Multiple groups per chapter are evaluated with OR logic (any group match excludes the issue). +Use `service-chapter-exclude` to filter out issues/PRs from service chapters by label combinations. Each rule is a group of labels that must **all** be present on a record (AND logic) for the record to be excluded. Multiple groups per chapter are evaluated with OR logic (any group match excludes the record). **Per-chapter exclusion** — excludes a matching record from that chapter only: From 73008ea6188024a54f0ad858dcb6fe9dd078ca1a Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 15 Apr 2026 09:11:36 +0200 Subject: [PATCH 4/7] Fixed issue with migration of excluded record to Others fallback ones. --- action.yml | 2 +- release_notes_generator/chapters/service_chapters.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/action.yml b/action.yml index b2d3c8ed..0b1e5fee 100644 --- a/action.yml +++ b/action.yml @@ -142,7 +142,7 @@ inputs: row-format-hierarchy-issue: description: 'Format of the hierarchy issue in the release notes. Available placeholders: {type}, {number}, {title}, {author}, {assignees}, {developers}. Placeholders are case-insensitive.' required: false - default: '{type}: _{title}_ {number}' + default: '{type}: _{title}_ {number} {progress}' row-format-issue: description: 'Format of the issue row in the release notes. Available placeholders: {type}, {number}, {title}, {author}, {assignees}, {developers}, {pull-requests}. Placeholders are case-insensitive.' required: false diff --git a/release_notes_generator/chapters/service_chapters.py b/release_notes_generator/chapters/service_chapters.py index f72e30bb..2dd23f1c 100644 --- a/release_notes_generator/chapters/service_chapters.py +++ b/release_notes_generator/chapters/service_chapters.py @@ -195,7 +195,7 @@ def __populate_closed_issues(self, record: IssueRecord, record_id: int | str) -> record.add_to_chapter_presence(CLOSED_ISSUES_WITHOUT_PULL_REQUESTS) self.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].add_row(record_id, record.to_chapter_row()) self.used_record_numbers.append(record_id) - populated = True + populated = True # check record properties if it fits to a chapter: CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS if not record.contains_min_one_label(self.user_defined_labels): @@ -207,7 +207,7 @@ def __populate_closed_issues(self, record: IssueRecord, record_id: int | str) -> record.add_to_chapter_presence(CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS) self.chapters[CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS].add_row(record_id, record.to_chapter_row()) self.used_record_numbers.append(record_id) - populated = True + populated = True if pulls_count > 0: # the record looks to be valid closed issue with 1+ pull requests, no reason to report it @@ -237,6 +237,7 @@ def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None None """ if record.is_merged: + consumed = False # check record properties if it fits to a chapter: MERGED_PRS_WITHOUT_ISSUE if not record.contains_issue_mentions() and not record.contains_min_one_label(self.user_defined_labels): if self.__is_row_present(record_id) and not self.duplicity_allowed(): @@ -248,6 +249,7 @@ def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None record_id, record.to_chapter_row() ) self.used_record_numbers.append(record_id) + consumed = True # check record properties if it fits to a chapter: MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES if record.contains_issue_mentions(): @@ -258,8 +260,9 @@ def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None record.add_to_chapter_presence(MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES) self.chapters[MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES].add_row(record_id, record.to_chapter_row()) self.used_record_numbers.append(record_id) + consumed = True - if not record.is_present_in_chapters: + if not consumed and not record.is_present_in_chapters: if self.__is_row_present(record_id) and not self.duplicity_allowed(): return From e19f8766b8410e24e5a0f37db3a765c52c379e0d Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 15 Apr 2026 15:21:16 +0200 Subject: [PATCH 5/7] Fixed review comment. --- release_notes_generator/builder/builder.py | 2 +- .../chapters/service_chapters.py | 4 ++-- .../chapters/test_service_chapters.py | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/release_notes_generator/builder/builder.py b/release_notes_generator/builder/builder.py index 4464d8c3..01694db7 100644 --- a/release_notes_generator/builder/builder.py +++ b/release_notes_generator/builder/builder.py @@ -72,7 +72,7 @@ def build(self) -> str: used_record_numbers=self.custom_chapters.populated_record_numbers_list, hidden_chapters=ActionInputs.get_hidden_service_chapters(), chapter_order=ActionInputs.get_service_chapter_order(), - exclude_rules=ActionInputs.get_service_chapter_exclude(), + chapter_exclude=ActionInputs.get_service_chapter_exclude(), ) service_chapters.populate(self.records) diff --git a/release_notes_generator/chapters/service_chapters.py b/release_notes_generator/chapters/service_chapters.py index 2dd23f1c..5e5e7d92 100644 --- a/release_notes_generator/chapters/service_chapters.py +++ b/release_notes_generator/chapters/service_chapters.py @@ -61,7 +61,7 @@ def __init__( used_record_numbers: Optional[list[int | str]] = None, hidden_chapters: Optional[list[str]] = None, chapter_order: Optional[list[str]] = None, - exclude_rules: Optional[dict[str, list[list[str]]]] = None, + chapter_exclude: Optional[dict[str, list[list[str]]]] = None, ): super().__init__(sort_ascending, print_empty_chapters) @@ -110,7 +110,7 @@ def __init__( self.show_chapter_merged_prs_linked_to_open_issues = True # Exclude rules: split "*" (global) from per-chapter entries - all_rules = exclude_rules if exclude_rules is not None else {} + all_rules = chapter_exclude if chapter_exclude is not None else {} self._global_exclude_groups: list[list[str]] = all_rules.get(GLOBAL_EXCLUDE_KEY, []) self._per_chapter_exclude_groups: dict[str, list[list[str]]] = { k: v for k, v in all_rules.items() if k != GLOBAL_EXCLUDE_KEY diff --git a/tests/unit/release_notes_generator/chapters/test_service_chapters.py b/tests/unit/release_notes_generator/chapters/test_service_chapters.py index 0cb38f43..3f10f466 100644 --- a/tests/unit/release_notes_generator/chapters/test_service_chapters.py +++ b/tests/unit/release_notes_generator/chapters/test_service_chapters.py @@ -239,7 +239,7 @@ def test_populate_per_chapter_full_match_excluded(record_with_issue_closed_no_pu """AND logic: all labels present -> excluded from that chapter; also covers: chapter isolation (rules for one chapter do not affect others).""" sc = ServiceChapters( user_defined_labels=["bug", "enhancement"], - exclude_rules={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1", "label2"]]}, + chapter_exclude={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1", "label2"]]}, ) sc.populate({1: record_with_issue_closed_no_pull}) assert 0 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) @@ -251,7 +251,7 @@ def test_populate_per_chapter_or_logic_second_group_matches(record_with_issue_cl """OR logic: second group match is sufficient for exclusion.""" sc = ServiceChapters( user_defined_labels=["bug", "enhancement"], - exclude_rules={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["nonexistent"], ["label1", "label2"]]}, + chapter_exclude={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["nonexistent"], ["label1", "label2"]]}, ) sc.populate({1: record_with_issue_closed_no_pull}) assert 0 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) @@ -261,7 +261,7 @@ def test_populate_per_chapter_partial_and_failure(record_with_issue_closed_no_pu """AND failure: missing one label -> not excluded.""" sc = ServiceChapters( user_defined_labels=["bug", "enhancement"], - exclude_rules={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1", "nonexistent"]]}, + chapter_exclude={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1", "nonexistent"]]}, ) sc.populate({1: record_with_issue_closed_no_pull}) assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) @@ -271,7 +271,7 @@ def test_populate_per_chapter_no_label_overlap(record_with_issue_closed_no_pull) """No match -> not excluded.""" sc = ServiceChapters( user_defined_labels=["bug", "enhancement"], - exclude_rules={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["alpha", "beta"]]}, + chapter_exclude={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["alpha", "beta"]]}, ) sc.populate({1: record_with_issue_closed_no_pull}) assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) @@ -281,7 +281,7 @@ def test_populate_per_chapter_empty_rules_no_exclusion(record_with_issue_closed_ """Empty exclusion list -> no-op.""" sc = ServiceChapters( user_defined_labels=["bug", "enhancement"], - exclude_rules={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: []}, + chapter_exclude={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: []}, ) sc.populate({1: record_with_issue_closed_no_pull}) assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) @@ -291,7 +291,7 @@ def test_populate_global_full_match_excluded_from_all(record_with_issue_closed_n """'*' match drops record from all chapters.""" sc = ServiceChapters( user_defined_labels=["bug", "enhancement"], - exclude_rules={"*": [["label1", "label2"]]}, + chapter_exclude={"*": [["label1", "label2"]]}, ) sc.populate({1: record_with_issue_closed_no_pull}) for chapter in sc.chapters.values(): @@ -302,7 +302,7 @@ def test_populate_global_partial_and_failure(record_with_issue_closed_no_pull): """'*' AND failure -> record not excluded.""" sc = ServiceChapters( user_defined_labels=["bug", "enhancement"], - exclude_rules={"*": [["label1", "nonexistent"]]}, + chapter_exclude={"*": [["label1", "nonexistent"]]}, ) sc.populate({1: record_with_issue_closed_no_pull}) assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) @@ -312,7 +312,7 @@ def test_populate_global_precedes_per_chapter(record_with_issue_closed_no_pull): """Global exclusion takes precedence over per-chapter rules.""" sc = ServiceChapters( user_defined_labels=["bug", "enhancement"], - exclude_rules={ + chapter_exclude={ "*": [["label1", "label2"]], CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1"]], }, From b0a3e010ee7c950f9016fcda62602c2e64f738ed Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 15 Apr 2026 15:50:42 +0200 Subject: [PATCH 6/7] fix: update chapter exclusion handling in ServiceChapters class --- release_notes_generator/chapters/service_chapters.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/release_notes_generator/chapters/service_chapters.py b/release_notes_generator/chapters/service_chapters.py index 5e5e7d92..50012fde 100644 --- a/release_notes_generator/chapters/service_chapters.py +++ b/release_notes_generator/chapters/service_chapters.py @@ -109,11 +109,10 @@ def __init__( self.show_chapter_merged_prs_linked_to_open_issues = True - # Exclude rules: split "*" (global) from per-chapter entries - all_rules = chapter_exclude if chapter_exclude is not None else {} - self._global_exclude_groups: list[list[str]] = all_rules.get(GLOBAL_EXCLUDE_KEY, []) + chapter_exclude = chapter_exclude if chapter_exclude is not None else {} + self._global_exclude_groups: list[list[str]] = chapter_exclude.get(GLOBAL_EXCLUDE_KEY, []) self._per_chapter_exclude_groups: dict[str, list[list[str]]] = { - k: v for k, v in all_rules.items() if k != GLOBAL_EXCLUDE_KEY + k: v for k, v in chapter_exclude.items() if k != GLOBAL_EXCLUDE_KEY } def populate(self, records: dict[str, Record]) -> None: From 92c4b88f90ea8f8488d5225ebac3d4f2f490e814 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 15 Apr 2026 15:58:18 +0200 Subject: [PATCH 7/7] test: enhance chapter exclusion tests with parameterized cases --- .../chapters/test_service_chapters.py | 66 ++++++------------- 1 file changed, 19 insertions(+), 47 deletions(-) diff --git a/tests/unit/release_notes_generator/chapters/test_service_chapters.py b/tests/unit/release_notes_generator/chapters/test_service_chapters.py index 3f10f466..ce7a10a4 100644 --- a/tests/unit/release_notes_generator/chapters/test_service_chapters.py +++ b/tests/unit/release_notes_generator/chapters/test_service_chapters.py @@ -14,6 +14,8 @@ # limitations under the License. # +import pytest + from release_notes_generator.model.chapter import Chapter from release_notes_generator.chapters.service_chapters import ServiceChapters from release_notes_generator.utils.constants import ( @@ -229,64 +231,34 @@ def test_chapter_order_none_uses_default(): assert sc.chapter_order == DEFAULT_SERVICE_CHAPTER_ORDER -def test_populate_per_chapter_no_exclude_rules(service_chapters, record_with_issue_closed_no_pull): - """Default behaviour unchanged when no exclude rules; also covers: no '*' global key -> normal routing.""" - service_chapters.populate({1: record_with_issue_closed_no_pull}) - assert 1 == len(service_chapters.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) +@pytest.mark.parametrize( + "chapter_exclude, expected_rows", + [ + ({CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1", "label2"]]}, 0), # full AND match -> excluded + ({CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["nonexistent"], ["label1", "label2"]]}, 0), # OR: second group matches + ({CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1", "nonexistent"]]}, 1), # AND failure -> not excluded + ({CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["alpha", "beta"]]}, 1), # no label overlap -> not excluded + ({CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: []}, 1), # empty list -> no-op + ], +) +def test_populate_per_chapter_exclusion(record_with_issue_closed_no_pull, chapter_exclude, expected_rows): + """Per-chapter exclusion: AND/OR logic and edge cases for CLOSED_ISSUES_WITHOUT_PULL_REQUESTS.""" + sc = ServiceChapters(user_defined_labels=["bug", "enhancement"], chapter_exclude=chapter_exclude) + sc.populate({1: record_with_issue_closed_no_pull}) + assert expected_rows == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) -def test_populate_per_chapter_full_match_excluded(record_with_issue_closed_no_pull): - """AND logic: all labels present -> excluded from that chapter; also covers: chapter isolation (rules for one chapter do not affect others).""" +def test_populate_per_chapter_exclusion_chapter_isolation(record_with_issue_closed_no_pull): + """Exclusion from one chapter does not affect others the record qualifies for.""" sc = ServiceChapters( user_defined_labels=["bug", "enhancement"], chapter_exclude={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1", "label2"]]}, ) sc.populate({1: record_with_issue_closed_no_pull}) assert 0 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) - # still present in the other chapter it qualifies for assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS].rows) -def test_populate_per_chapter_or_logic_second_group_matches(record_with_issue_closed_no_pull): - """OR logic: second group match is sufficient for exclusion.""" - sc = ServiceChapters( - user_defined_labels=["bug", "enhancement"], - chapter_exclude={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["nonexistent"], ["label1", "label2"]]}, - ) - sc.populate({1: record_with_issue_closed_no_pull}) - assert 0 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) - - -def test_populate_per_chapter_partial_and_failure(record_with_issue_closed_no_pull): - """AND failure: missing one label -> not excluded.""" - sc = ServiceChapters( - user_defined_labels=["bug", "enhancement"], - chapter_exclude={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["label1", "nonexistent"]]}, - ) - sc.populate({1: record_with_issue_closed_no_pull}) - assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) - - -def test_populate_per_chapter_no_label_overlap(record_with_issue_closed_no_pull): - """No match -> not excluded.""" - sc = ServiceChapters( - user_defined_labels=["bug", "enhancement"], - chapter_exclude={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: [["alpha", "beta"]]}, - ) - sc.populate({1: record_with_issue_closed_no_pull}) - assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) - - -def test_populate_per_chapter_empty_rules_no_exclusion(record_with_issue_closed_no_pull): - """Empty exclusion list -> no-op.""" - sc = ServiceChapters( - user_defined_labels=["bug", "enhancement"], - chapter_exclude={CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: []}, - ) - sc.populate({1: record_with_issue_closed_no_pull}) - assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].rows) - - def test_populate_global_full_match_excluded_from_all(record_with_issue_closed_no_pull): """'*' match drops record from all chapters.""" sc = ServiceChapters(