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/action.yml b/action.yml index a3a6edd8..0b1e5fee 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 @@ -128,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 @@ -198,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 }} 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..a25e9763 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/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: + +```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..01694db7 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(), + 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 84b2214a..50012fde 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, + chapter_exclude: Optional[dict[str, list[list[str]]]] = None, ): super().__init__(sort_ascending, print_empty_chapters) @@ -107,6 +109,12 @@ def __init__( self.show_chapter_merged_prs_linked_to_open_issues = True + 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 chapter_exclude.items() if k != GLOBAL_EXCLUDE_KEY + } + def populate(self, records: dict[str, Record]) -> None: """ Populates the service chapters with records. @@ -123,6 +131,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 +157,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,9 +190,10 @@ 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) + 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 @@ -186,9 +202,10 @@ 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) + 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: @@ -202,9 +219,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: """ @@ -218,36 +236,42 @@ 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(): 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) + consumed = True # 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) + 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 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 +282,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 +297,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 +309,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 +323,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..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 ( @@ -227,3 +229,66 @@ 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 + + +@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_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) + assert 1 == len(sc.chapters[CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS].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"], + chapter_exclude={"*": [["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"], + chapter_exclude={"*": [["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"], + chapter_exclude={ + "*": [["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