diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 12dccd31..93d24ab5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -125,6 +125,10 @@ Learned rules - Must not change externally-visible output strings without updating the contract. Repo additions +- Test conftest files: + - tests/unit/conftest.py — shared fixtures for all unit tests + - tests/unit/release_notes_generator/builder/conftest.py — builder-scoped fixtures (e.g. autouse mocks that must not bleed into other test packages) + - Must not place a builder-only autouse fixture in the global conftest. - Project name: generate-release-notes - Entry points: action.yml, main.py - Core package: release_notes_generator/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 124766a1..716babef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -198,7 +198,6 @@ jobs: - name: Run action against real repository and validate output env: - INPUT_TAG_NAME: 'v0.2.0' INPUT_GITHUB_REPOSITORY: 'AbsaOSS/generate-release-notes' INPUT_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_CHAPTERS: | @@ -209,7 +208,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/README.md b/README.md index 75c2db10..ec5a8b37 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ Each feature is documented separately — click a name below to learn configurat | [CodeRabbit Integration](docs/features/coderabbit_integration.md) | Extraction | Optional extension to Release Notes Extraction, enabling AI-generated summaries when PR notes are missing. | | [Skip Labels](docs/features/skip_labels.md) | Filtering | Exclude issues/PRs carrying configured labels from all release notes. | | [Service Chapters](docs/features/service_chapters.md) | Quality & Warnings | Surfaces gaps: issues without PRs, unlabeled items, PRs without notes, etc. | +| [Statistics & Anti-game](docs/features/stats_chapters.md) | Quality & Warnings | Surfaces skip-label usage statistics per PR author, issue author/assignee, type, and label. | | [Duplicity Handling](docs/features/duplicity_handling.md) | Quality & Warnings | Marks duplicate lines when the same issue appears in multiple chapters. | | [Tag Range Selection](docs/features/tag_range.md) | Time Range | Chooses scope via `tag-name`/`from-tag-name`. | | [Date Selection](docs/features/date_selection.md) | Time Range | Chooses scope via timestamps (`published-at` vs `created-at`). | diff --git a/action.yml b/action.yml index a3a6edd8..19c6ada7 100644 --- a/action.yml +++ b/action.yml @@ -69,6 +69,10 @@ inputs: description: 'Print service chapters if true.' required: false default: 'true' + show-stats-chapters: + description: 'Print Statistics & Anti-game chapters showing skip-label usage stats.' + required: false + default: 'true' hidden-service-chapters: description: | List of service chapter titles to hide from output (comma or newline separated). @@ -196,6 +200,7 @@ runs: INPUT_DUPLICITY_ICON: ${{ inputs.duplicity-icon }} INPUT_OPEN_HIERARCHY_SUB_ISSUE_ICON: ${{ inputs.open-hierarchy-sub-issue-icon }} INPUT_WARNINGS: ${{ inputs.warnings }} + INPUT_SHOW_STATS_CHAPTERS: ${{ inputs.show-stats-chapters }} INPUT_HIDDEN_SERVICE_CHAPTERS: ${{ inputs.hidden-service-chapters }} INPUT_SERVICE_CHAPTER_ORDER: ${{ inputs.service-chapter-order }} INPUT_PUBLISHED_AT: ${{ inputs.published-at }} diff --git a/docs/configuration_reference.md b/docs/configuration_reference.md index 02b778f8..cb016e3c 100644 --- a/docs/configuration_reference.md +++ b/docs/configuration_reference.md @@ -13,6 +13,7 @@ This page lists all action inputs and outputs with defaults. Grouped for readabi | `published-at` | No | `false` | Use previous release `published_at` timestamp instead of `created_at`. | | `skip-release-notes-labels` | No | `skip-release-notes` | Comma‑separated labels that fully exclude issues/PRs. | | `warnings` | No | `true` | Toggle Service Chapters generation. | +| `show-stats-chapters` | No | `true` | Toggle Statistics & Anti-game chapters showing skip-label usage stats. When `true` and at least one record was skipped, renders usage tables. Honors `print-empty-chapters` for individual sub-sections. | | `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`. | | `print-empty-chapters` | No | `true` | Print chapter headings even when empty. | diff --git a/docs/features/skip_labels.md b/docs/features/skip_labels.md index 0db1e9ec..26be99b4 100644 --- a/docs/features/skip_labels.md +++ b/docs/features/skip_labels.md @@ -47,6 +47,7 @@ https://github.com/org/repo/compare/v1.5.0...v2.0.0 > (Internal / skipped items not shown.) ## Related Features -- [Service Chapters](./service_chapters.md) – skipped items don’t appear as warnings. +- [Service Chapters](./service_chapters.md) – skipped items don't appear as warnings. +- [Statistics & Anti-game Chapters](./stats_chapters.md) – surfaces skip-label usage statistics per author, type, and label. ← [Back to Feature Tutorials](../../README.md#feature-tutorials) diff --git a/docs/features/stats_chapters.md b/docs/features/stats_chapters.md new file mode 100644 index 00000000..7a486563 --- /dev/null +++ b/docs/features/stats_chapters.md @@ -0,0 +1,109 @@ +# Feature: Statistics & Anti-game Chapters + +## Purpose +Surface skip-label usage statistics so that maintainers can audit who and what is being excluded from release notes via skip labels. Discourages overuse of the `skip-release-notes` label by making the practice visible. + +## How It Works +- Enabled when input `show-stats-chapters` is `true` (default). When `false`, stats chapters are omitted entirely. +- Iterates **all** records, including those with `skip=True`, to compute totals and skip counts. +- The chapter is only rendered when at least one record in the release window carries a skip label. If nothing was skipped, the entire section is omitted regardless of `print-empty-chapters`. +- Honors `print-empty-chapters` for individual sub-sections: when `true` (default), sub-sections where no records were skipped but records exist are still shown (with a zero skipped count). When `false`, only sub-sections with at least one skipped record are rendered. +- Rendered after Service Chapters (or after Custom Chapters if `warnings: false`). + +### Sub-sections + +The chapter contains four fixed sub-sections in the following order: + +#### 1. PR Authors +Compares PR authors and their usage of the skip label. + +| Column | Description | +|--------|-------------| +| Author | PR author (`@login`) or `(no author)` | +| Total PRs | All PRs by that author | +| Skipped PRs | PRs by that author carrying a skip label | + +#### 2. Issue Authors / Assignees +Compares issue authors and assignees and their usage of the skip label. A person appearing as both author and assignee of different issues is counted once per issue involvement. + +| Column | Description | +|--------|-------------| +| Author / Assignee | Person (`@login`) or `(no author)` | +| Total Issues | Issues where this person is author or assignee | +| Skipped Issues | Skipped issues where this person is author or assignee | + +#### 3. Issue Labels +Compares non-skip labels on issues and their usage of the skip label. + +| Column | Description | +|--------|-------------| +| Label | Non-skip label on the issue, or `(no label)` | +| Total Issues | Issues carrying that label | +| Skipped Issues | Skipped issues carrying that label | + +#### 4. PR Labels +Compares non-skip labels on PRs and their usage of the skip label. + +| Column | Description | +|--------|-------------| +| Label | Non-skip label on the PR, or `(no label)` | +| Total PRs | PRs carrying that label | +| Skipped PRs | Skipped PRs carrying that label | + +All sub-sections sort rows by skipped count descending, then by name ascending. Skip-label names are excluded from label buckets. + +## Configuration +```yaml +- name: Generate Release Notes + id: release_notes_scrapper + uses: AbsaOSS/generate-release-notes@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag-name: "v2.0.0" + chapters: | + - {"title": "Features", "label": "feature"} + - {"title": "Fixes", "label": "bug"} + show-stats-chapters: true # enabled by default + print-empty-chapters: true # show sub-sections even with zero skips +``` + +To disable: +```yaml + show-stats-chapters: false +``` + +## Example Result +```markdown +### Skip Release Notes Label Usage ⚠️ +#### PR Authors +| Author | Total PRs | Skipped PRs | +|--------|-----------|-------------| +| @alice | 5 | 3 | +| @bob | 2 | 0 | + +#### Issue Authors / Assignees +| Author / Assignee | Total Issues | Skipped Issues | +|--------------------|--------------|----------------| +| @alice | 6 | 3 | +| @bob | 4 | 2 | + +#### Issue Labels +| Label | Total Issues | Skipped Issues | +|-------|--------------|----------------| +| bug | 6 | 4 | +| enhancement | 3 | 1 | +| (no label) | 2 | 2 | + +#### PR Labels +| Label | Total PRs | Skipped PRs | +|-------|-----------|-------------| +| bug | 2 | 1 | +| (no label) | 1 | 1 | +``` + +## Related Features +- [Skip Labels](./skip_labels.md) – defines which labels mark records as skipped. +- [Service Chapters](./service_chapters.md) – quality diagnostics (skipped records are excluded there). + +← [Back to Feature Tutorials](../../README.md#feature-tutorials) diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index 0b7907cd..cd14fdd0 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -38,6 +38,7 @@ SERVICE_CHAPTER_ORDER, RUNNER_DEBUG, PRINT_EMPTY_CHAPTERS, + SHOW_STATS_CHAPTERS, DUPLICITY_SCOPE, DUPLICITY_ICON, OPEN_HIERARCHY_SUB_ISSUE_ICON, @@ -424,6 +425,13 @@ def get_print_empty_chapters() -> bool: """ return get_action_input(PRINT_EMPTY_CHAPTERS, "true").lower() == "true" + @staticmethod + def get_show_stats_chapters() -> bool: + """ + Get the show stats chapters parameter value from the action inputs. + """ + return get_action_input(SHOW_STATS_CHAPTERS, "true").lower() == "true" + @staticmethod def validate_input(input_value, expected_type: type, error_message: str, error_buffer: list) -> bool: """ @@ -588,6 +596,9 @@ def validate_inputs() -> None: print_empty_chapters = ActionInputs.get_print_empty_chapters() ActionInputs.validate_input(print_empty_chapters, bool, "Print empty chapters must be a boolean.", errors) + show_stats_chapters = ActionInputs.get_show_stats_chapters() + ActionInputs.validate_input(show_stats_chapters, bool, "Show stats chapters must be a boolean.", errors) + # Log errors if any if errors: for error in errors: @@ -606,6 +617,7 @@ def validate_inputs() -> None: logger.debug("Verbose logging: %s", verbose) logger.debug("Warnings: %s", warnings) logger.debug("Print empty chapters: %s", print_empty_chapters) + logger.debug("Show stats chapters: %s", show_stats_chapters) logger.debug("Release notes title: %s", release_notes_title) logger.debug("CodeRabbit support active: %s", coderabbit_support_active) logger.debug("CodeRabbit release notes title: %s", coderabbit_release_notes_title) diff --git a/release_notes_generator/builder/builder.py b/release_notes_generator/builder/builder.py index 999dd914..9b25f856 100644 --- a/release_notes_generator/builder/builder.py +++ b/release_notes_generator/builder/builder.py @@ -25,6 +25,7 @@ from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.chapters.custom_chapters import CustomChapters from release_notes_generator.chapters.service_chapters import ServiceChapters +from release_notes_generator.chapters.stats_chapters import StatsChapters from release_notes_generator.model.record.record import Record logger = logging.getLogger(__name__) @@ -49,6 +50,7 @@ def __init__( self.warnings = ActionInputs.get_warnings() self.print_empty_chapters = ActionInputs.get_print_empty_chapters() + self.show_stats_chapters = ActionInputs.get_show_stats_chapters() def build(self) -> str: """ @@ -77,13 +79,19 @@ def build(self) -> str: service_chapters_str = service_chapters.to_string() if len(service_chapters_str) > 0: - release_notes = ( - f"""{user_defined_chapters_str}\n\n{service_chapters_str}\n\n""" - f"""#### Full Changelog\n{self.changelog_url}\n""" - ) + body = f"""{user_defined_chapters_str}\n\n{service_chapters_str}""" else: - release_notes = f"""{user_defined_chapters_str}\n\n#### Full Changelog\n{self.changelog_url}\n""" + body = user_defined_chapters_str else: - release_notes = f"""{user_defined_chapters_str}\n\n#### Full Changelog\n{self.changelog_url}\n""" + body = user_defined_chapters_str + + if self.show_stats_chapters: + stats_chapters = StatsChapters(print_empty_chapters=self.print_empty_chapters) + stats_chapters.populate(self.records) + stats_str = stats_chapters.to_string() + if stats_str: + body = body.rstrip("\n") + "\n\n" + stats_str + + release_notes = f"""{body}\n\n#### Full Changelog\n{self.changelog_url}\n""" return release_notes.lstrip() diff --git a/release_notes_generator/chapters/stats_chapters.py b/release_notes_generator/chapters/stats_chapters.py new file mode 100644 index 00000000..cc53efa3 --- /dev/null +++ b/release_notes_generator/chapters/stats_chapters.py @@ -0,0 +1,199 @@ +# +# 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. +# + +""" +This module contains the StatsChapters class which is responsible for rendering +Statistics & Anti-game chapters in the release notes. +""" + +import logging +from collections import defaultdict + +from release_notes_generator.action_inputs import ActionInputs +from release_notes_generator.model.record.issue_record import IssueRecord +from release_notes_generator.model.record.pull_request_record import PullRequestRecord +from release_notes_generator.model.record.record import Record +from release_notes_generator.utils.constants import SKIP_RELEASE_NOTES_LABEL_STATS + +logger = logging.getLogger(__name__) + +NO_AUTHOR = "(no author)" +NO_LABEL = "(no label)" + + +class StatsChapters: + """ + Statistics & Anti-game chapters. + + Collects skip-label usage statistics from all records and renders + four sub-sections as markdown tables. + """ + + def __init__(self, print_empty_chapters: bool = True): + self.print_empty_chapters = print_empty_chapters + + # Sub-section 1: PR Authors {author: [total, skipped]} + self._pr_authors: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + + # Sub-section 2: Issue Authors/Assignees {person: [total, skipped]} + self._issue_people: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + + # Sub-section 3: Issue Labels {label: [total, skipped]} + self._issue_labels: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + + # Sub-section 4: PR Labels {label: [total, skipped]} + self._pr_labels: dict[str, list[int]] = defaultdict(lambda: [0, 0]) + + self._has_any_skipped = False + + def populate(self, records: dict[str, Record]) -> None: + """ + Populate statistics from all records. + + Parameters: + records: A dictionary of records. + """ + skip_labels = set(ActionInputs.get_skip_release_notes_labels()) + + for _record_id, record in records.items(): + is_skipped = record.skip + if is_skipped: + self._has_any_skipped = True + + non_skip_labels = [lbl for lbl in record.labels if lbl not in skip_labels] + + # Sub-section 1: PR Authors + if isinstance(record, PullRequestRecord): + author = record.author if record.author else NO_AUTHOR + self._pr_authors[author][0] += 1 + if is_skipped: + self._pr_authors[author][1] += 1 + + # Sub-section 2: Issue Authors/Assignees + if isinstance(record, IssueRecord): + people: set[str] = set() + author = record.author if record.author else NO_AUTHOR + people.add(author) + for assignee in record.assignees: + people.add(assignee) + + for person in people: + self._issue_people[person][0] += 1 + if is_skipped: + self._issue_people[person][1] += 1 + + # Sub-section 3: Issue Labels + label_buckets = non_skip_labels if non_skip_labels else [NO_LABEL] + for bucket in label_buckets: + self._issue_labels[bucket][0] += 1 + if is_skipped: + self._issue_labels[bucket][1] += 1 + + # Sub-section 4: PR Labels + if isinstance(record, PullRequestRecord): + label_buckets = non_skip_labels if non_skip_labels else [NO_LABEL] + for bucket in label_buckets: + self._pr_labels[bucket][0] += 1 + if is_skipped: + self._pr_labels[bucket][1] += 1 + + def to_string(self) -> str: + """ + Render the stats chapter as a markdown string. + + Returns: + The rendered markdown string, or empty string if nothing to show. + """ + if not self._has_any_skipped: + return "" + + sections: list[str] = [] + + pr_section = self._render_table( + "PR Authors", + ["Author", "Total PRs", "Skipped PRs"], + self._pr_authors, + ) + if pr_section: + sections.append(pr_section) + + issue_section = self._render_table( + "Issue Authors / Assignees", + ["Author / Assignee", "Total Issues", "Skipped Issues"], + self._issue_people, + ) + if issue_section: + sections.append(issue_section) + + type_section = self._render_table( + "Issue Labels", + ["Label", "Total Issues", "Skipped Issues"], + self._issue_labels, + ) + if type_section: + sections.append(type_section) + + label_section = self._render_table( + "PR Labels", + ["Label", "Total PRs", "Skipped PRs"], + self._pr_labels, + ) + if label_section: + sections.append(label_section) + + if not sections: + return "" + + header = f"### {SKIP_RELEASE_NOTES_LABEL_STATS}" + return header + "\n" + "\n".join(sections) + + def _render_table( + self, + title: str, + columns: list[str], + data: dict[str, list[int]], + ) -> str: + """ + Render a single sub-section table. + + Parameters: + title: The sub-section title. + columns: Column headers [name, total_col, skipped_col]. + data: The data dict {key: [total, skipped]}. + + Returns: + Rendered markdown or empty string if the sub-section should be omitted. + """ + if not data: + return "" + + has_any_skipped_in_section = any(counts[1] > 0 for counts in data.values()) + + if not has_any_skipped_in_section and not self.print_empty_chapters: + return "" + + # Sort: skipped desc, then key asc + sorted_rows = sorted(data.items(), key=lambda item: (-item[1][1], item[0])) + + lines: list[str] = [] + lines.append(f"#### {title}") + lines.append(f"| {columns[0]} | {columns[1]} | {columns[2]} |") + lines.append(f"|{'-' * (len(columns[0]) + 2)}|{'-' * (len(columns[1]) + 2)}|{'-' * (len(columns[2]) + 2)}|") + + for key, counts in sorted_rows: + lines.append(f"| {key} | {counts[0]} | {counts[1]} |") + + return "\n".join(lines) + "\n" diff --git a/release_notes_generator/utils/constants.py b/release_notes_generator/utils/constants.py index 5f11a912..a4b46c51 100644 --- a/release_notes_generator/utils/constants.py +++ b/release_notes_generator/utils/constants.py @@ -49,6 +49,7 @@ HIDDEN_SERVICE_CHAPTERS = "hidden-service-chapters" SERVICE_CHAPTER_ORDER = "service-chapter-order" PRINT_EMPTY_CHAPTERS = "print-empty-chapters" +SHOW_STATS_CHAPTERS = "show-stats-chapters" # Super chapter fallback heading UNCATEGORIZED_CHAPTER_TITLE: str = "Uncategorized" @@ -70,6 +71,9 @@ OTHERS_NO_TOPIC: str = "Others - No Topic ⚠️" +# Stats chapters titles +SKIP_RELEASE_NOTES_LABEL_STATS: str = "Skip Release Notes Label Usage ⚠️" + DEFAULT_SERVICE_CHAPTER_ORDER: list[str] = [ CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS, CLOSED_ISSUES_WITHOUT_PULL_REQUESTS, diff --git a/tests/unit/release_notes_generator/builder/conftest.py b/tests/unit/release_notes_generator/builder/conftest.py new file mode 100644 index 00000000..8cc07562 --- /dev/null +++ b/tests/unit/release_notes_generator/builder/conftest.py @@ -0,0 +1,25 @@ +# +# 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. +# +import pytest + + +@pytest.fixture(autouse=True) +def _disable_stats_chapters(mocker): + """Disable stats chapters for all existing builder tests to avoid output changes.""" + mocker.patch( + "release_notes_generator.builder.builder.ActionInputs.get_show_stats_chapters", + return_value=False, + ) diff --git a/tests/unit/release_notes_generator/chapters/conftest.py b/tests/unit/release_notes_generator/chapters/conftest.py new file mode 100644 index 00000000..9720b417 --- /dev/null +++ b/tests/unit/release_notes_generator/chapters/conftest.py @@ -0,0 +1,63 @@ +# +# 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. +# +from collections.abc import Callable + +import pytest +from pytest_mock import MockerFixture + +from github.Issue import Issue +from github.PullRequest import PullRequest +from github.Repository import Repository + +from release_notes_generator.model.record.issue_record import IssueRecord +from release_notes_generator.model.record.pull_request_record import PullRequestRecord + + +@pytest.fixture +def make_pr(mocker: MockerFixture) -> Callable[..., PullRequestRecord]: + """Factory fixture for lightweight PullRequestRecord mocks.""" + + def _factory(*, author=None, skip=False, labels=None) -> PullRequestRecord: + pull = mocker.Mock(spec=PullRequest) + if author: + user = mocker.Mock() + user.login = author + pull.user = user + else: + pull.user = None + pull.assignees = [] + repo = mocker.Mock(spec=Repository) + return PullRequestRecord(pull=pull, repo=repo, labels=labels if labels is not None else [], skip=skip) + + return _factory + + +@pytest.fixture +def make_issue(mocker: MockerFixture) -> Callable[..., IssueRecord]: + """Factory fixture for lightweight IssueRecord mocks.""" + + def _factory(*, author=None, assignees=None, skip=False, labels=None) -> IssueRecord: + issue = mocker.Mock(spec=Issue) + if author: + user = mocker.Mock() + user.login = author + issue.user = user + else: + issue.user = None + issue.assignees = [mocker.Mock(login=login) for login in (assignees or [])] + return IssueRecord(issue=issue, issue_labels=labels if labels is not None else [], skip=skip) + + return _factory diff --git a/tests/unit/release_notes_generator/chapters/test_stats_chapters.py b/tests/unit/release_notes_generator/chapters/test_stats_chapters.py new file mode 100644 index 00000000..c2edd6f3 --- /dev/null +++ b/tests/unit/release_notes_generator/chapters/test_stats_chapters.py @@ -0,0 +1,348 @@ +# +# 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. +# + +from release_notes_generator.chapters.stats_chapters import StatsChapters +from release_notes_generator.utils.constants import SKIP_RELEASE_NOTES_LABEL_STATS + +_SKIP_LABEL = "skip-release-notes" +_PATCH = "release_notes_generator.chapters.stats_chapters.ActionInputs.get_skip_release_notes_labels" + + +def _make_stats(mocker, *, skip_labels=None, print_empty=True): + mocker.patch(_PATCH, return_value=skip_labels if skip_labels is not None else [_SKIP_LABEL]) + return StatsChapters(print_empty_chapters=print_empty) + + +def test_switch_on_no_skipped(mocker, make_pr): + """Chapter omitted when no records carry a skip label.""" + sc = _make_stats(mocker) + pr = make_pr(author="alice", skip=False, labels=["bug"]) + sc.populate({0: pr}) + assert sc.to_string() == "" + + +def test_switch_on_with_skipped(mocker, make_pr): + """Chapter rendered when at least one record is skipped.""" + sc = _make_stats(mocker) + pr = make_pr(author="alice", skip=True, labels=[_SKIP_LABEL, "bug"]) + sc.populate({0: pr}) + assert SKIP_RELEASE_NOTES_LABEL_STATS in sc.to_string() + + +def test_switch_on_empty_records(mocker): + """Chapter omitted when records dict is empty.""" + sc = _make_stats(mocker) + sc.populate({}) + assert sc.to_string() == "" + + +def test_print_empty_true_shows_zero_skipped_subsection(mocker, make_pr, make_issue): + """Sub-section with records but none skipped is shown when print_empty=True.""" + sc = _make_stats(mocker, print_empty=True) + alice_pr1 = make_pr(author="alice", skip=False, labels=["bug"]) + alice_pr2 = make_pr(author="alice", skip=False, labels=["bug"]) + issue_skip = make_issue(author="bob", skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: alice_pr1, 1: alice_pr2, 2: issue_skip}) + result = sc.to_string() + assert "PR Authors" in result + assert "| @alice | 2 | 0 |" in result + + +def test_print_empty_false_hides_zero_skipped_subsection(mocker, make_pr, make_issue): + """Sub-section with records but none skipped is hidden when print_empty=False.""" + sc = _make_stats(mocker, print_empty=False) + alice_pr1 = make_pr(author="alice", skip=False, labels=["bug"]) + alice_pr2 = make_pr(author="alice", skip=False, labels=["bug"]) + issue_skip = make_issue(author="bob", skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: alice_pr1, 1: alice_pr2, 2: issue_skip}) + result = sc.to_string() + assert "PR Authors" not in result + + +def test_print_empty_false_still_shows_nonzero_skipped(mocker, make_pr): + """Sub-section where some records are skipped is shown regardless of print_empty.""" + sc = _make_stats(mocker, print_empty=False) + pr_skip = make_pr(author="alice", skip=True, labels=[_SKIP_LABEL]) + pr_normal = make_pr(author="alice", skip=False, labels=["bug"]) + sc.populate({0: pr_skip, 1: pr_normal}) + result = sc.to_string() + assert "PR Authors" in result + assert "| @alice | 2 | 1 |" in result + + +def test_no_pr_records(mocker, make_issue): + """No PullRequestRecord instances → PR Authors sub-section absent.""" + sc = _make_stats(mocker) + issue_skip = make_issue(author="alice", skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: issue_skip}) + result = sc.to_string() + assert SKIP_RELEASE_NOTES_LABEL_STATS in result + assert "PR Authors" not in result + + +def test_single_author_all_skipped(mocker, make_pr): + """One author with all PRs skipped → @alice | 3 | 3.""" + sc = _make_stats(mocker) + prs = [make_pr(author="alice", skip=True, labels=[_SKIP_LABEL]) for _ in range(3)] + sc.populate({i: pr for i, pr in enumerate(prs)}) + assert "| @alice | 3 | 3 |" in sc.to_string() + + +def test_multiple_authors_mixed_skip(mocker, make_pr): + """Multiple authors with different skip rates; sorted by skipped count descending.""" + sc = _make_stats(mocker) + alice_skip1 = make_pr(author="alice", skip=True, labels=[_SKIP_LABEL]) + alice_skip2 = make_pr(author="alice", skip=True, labels=[_SKIP_LABEL]) + alice_normal = make_pr(author="alice", skip=False, labels=["bug"]) + bob_normal1 = make_pr(author="bob", skip=False, labels=["bug"]) + bob_normal2 = make_pr(author="bob", skip=False, labels=["bug"]) + sc.populate({0: alice_skip1, 1: alice_skip2, 2: alice_normal, 3: bob_normal1, 4: bob_normal2}) + result = sc.to_string() + assert "| @alice | 3 | 2 |" in result + assert "| @bob | 2 | 0 |" in result + assert result.index("@alice") < result.index("@bob") + + +def test_pr_with_no_author(mocker, make_pr): + """PR with no resolvable author counted under '(no author)' bucket.""" + sc = _make_stats(mocker) + pr = make_pr(author=None, skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: pr}) + assert "| (no author) | 1 | 1 |" in sc.to_string() + + +def test_custom_skip_label(mocker, make_pr): + """Configured skip label other than default is correctly recognised.""" + sc = _make_stats(mocker, skip_labels=["custom-skip"]) + pr = make_pr(author="alice", skip=True, labels=["custom-skip"]) + sc.populate({0: pr}) + assert "| @alice | 1 | 1 |" in sc.to_string() + + +def test_no_issue_records(mocker, make_pr): + """No IssueRecord instances → Issue Authors sub-section absent.""" + sc = _make_stats(mocker) + pr_skip = make_pr(author="alice", skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: pr_skip}) + assert "Issue Authors" not in sc.to_string() + + +def test_no_skipped_issues(mocker, make_pr, make_issue): + """Issues exist but none skipped; with print_empty=False, sub-section absent.""" + sc = _make_stats(mocker, print_empty=False) + issue1 = make_issue(author="alice", skip=False, labels=["bug"]) + issue2 = make_issue(author="alice", skip=False, labels=["bug"]) + pr_skip = make_pr(author="charlie", skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: issue1, 1: issue2, 2: pr_skip}) + assert "Issue Authors" not in sc.to_string() + + +def test_author_only_skipped(mocker, make_issue): + """Issue with author only and skip=True → @alice | 1 | 1.""" + sc = _make_stats(mocker) + issue = make_issue(author="alice", assignees=[], skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: issue}) + assert "| @alice | 1 | 1 |" in sc.to_string() + + +def test_author_and_assignee_both_counted(mocker, make_issue): + """Author and assignee receive independent rows for the same issue.""" + sc = _make_stats(mocker) + issue = make_issue(author="alice", assignees=["bob"], skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: issue}) + result = sc.to_string() + assert "| @alice | 1 | 1 |" in result + assert "| @bob | 1 | 1 |" in result + + +def test_no_author_with_assignee(mocker, make_issue): + """No author but has assignee; (no author) bucket and assignee bucket both present.""" + sc = _make_stats(mocker) + issue = make_issue(author=None, assignees=["bob"], skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: issue}) + result = sc.to_string() + assert "| (no author) | 1 | 1 |" in result + assert "| @bob | 1 | 1 |" in result + + +def test_person_as_author_and_assignee(mocker, make_issue): + """Person as author of one issue (skipped) and assignee of another (not skipped) → @alice | 2 | 1.""" + sc = _make_stats(mocker) + issue_a = make_issue(author="alice", assignees=[], skip=True, labels=[_SKIP_LABEL]) + issue_b = make_issue(author="charlie", assignees=["alice"], skip=False, labels=["bug"]) + sc.populate({0: issue_a, 1: issue_b}) + assert "| @alice | 2 | 1 |" in sc.to_string() + + +def test_mixed_authors_mixed_skip(mocker, make_issue): + """Multiple people with partial skip; sorted by skipped count descending.""" + sc = _make_stats(mocker) + alice_skip = make_issue(author="alice", skip=True, labels=[_SKIP_LABEL]) + alice_normal = make_issue(author="alice", skip=False, labels=["bug"]) + bob_normal = make_issue(author="bob", skip=False, labels=["bug"]) + sc.populate({0: alice_skip, 1: alice_normal, 2: bob_normal}) + result = sc.to_string() + assert "| @alice | 2 | 1 |" in result + assert "| @bob | 1 | 0 |" in result + assert result.index("@alice") < result.index("@bob") + + +def test_no_issue_records_for_types(mocker, make_pr): + """No IssueRecord instances → Issue Labels sub-section absent.""" + sc = _make_stats(mocker) + pr_skip = make_pr(author="alice", skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: pr_skip}) + assert "Issue Labels" not in sc.to_string() + + +def test_no_skipped_issues_for_types(mocker, make_pr, make_issue): + """Issues with labels exist but none skipped; with print_empty=False, sub-section absent.""" + sc = _make_stats(mocker, print_empty=False) + issue1 = make_issue(author="alice", skip=False, labels=["bug"]) + issue2 = make_issue(author="alice", skip=False, labels=["bug"]) + pr_skip = make_pr(author="charlie", skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: issue1, 1: issue2, 2: pr_skip}) + assert "Issue Labels" not in sc.to_string() + + +def test_single_type_all_skipped(mocker, make_issue): + """All issues of one type skipped → bug | 3 | 3.""" + sc = _make_stats(mocker) + issues = [make_issue(author="alice", skip=True, labels=["bug", _SKIP_LABEL]) for _ in range(3)] + sc.populate({i: issue for i, issue in enumerate(issues)}) + assert "| bug | 3 | 3 |" in sc.to_string() + + +def test_issue_with_skip_label_only(mocker, make_issue): + """Issue has only the skip label → falls into (no label) bucket.""" + sc = _make_stats(mocker) + issue = make_issue(author="alice", skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: issue}) + assert "| (no label) | 1 | 1 |" in sc.to_string() + + +def test_issue_with_no_labels(mocker, make_issue): + """Issue with no labels at all falls into (no label) bucket.""" + sc = _make_stats(mocker, print_empty=True) + issue_no_label = make_issue(author="alice", skip=False, labels=[]) + issue_skip = make_issue(author="bob", skip=True, labels=[_SKIP_LABEL, "bug"]) + sc.populate({0: issue_no_label, 1: issue_skip}) + result = sc.to_string() + assert "| (no label) | 1 | 0 |" in result + assert "| bug | 1 | 1 |" in result + + +def test_issue_counts_in_multiple_type_buckets(mocker, make_issue): + """Issue with two non-skip labels contributes to both type buckets.""" + sc = _make_stats(mocker) + issue = make_issue(author="alice", skip=True, labels=["bug", "enhancement", _SKIP_LABEL]) + sc.populate({0: issue}) + result = sc.to_string() + assert "| bug | 1 | 1 |" in result + assert "| enhancement | 1 | 1 |" in result + + +def test_mixed_types_mixed_skip(mocker, make_issue): + """Multiple types with partial skip; sorted by skipped count descending.""" + sc = _make_stats(mocker) + bug_skip = make_issue(author="alice", skip=True, labels=["bug", _SKIP_LABEL]) + bug_normal = make_issue(author="alice", skip=False, labels=["bug"]) + enh_normal = make_issue(author="alice", skip=False, labels=["enhancement"]) + sc.populate({0: bug_skip, 1: bug_normal, 2: enh_normal}) + result = sc.to_string() + assert "| bug | 2 | 1 |" in result + assert "| enhancement | 1 | 0 |" in result + assert result.index("| bug |") < result.index("| enhancement |") + + +def test_skip_label_not_a_type_bucket(mocker, make_issue): + """Skip label name must not appear as a type bucket key.""" + sc = _make_stats(mocker) + issue = make_issue(author="alice", skip=True, labels=["bug", _SKIP_LABEL]) + sc.populate({0: issue}) + result = sc.to_string() + assert "| bug | 1 | 1 |" in result + assert _SKIP_LABEL not in result + + +def test_pr_label_with_skip(mocker, make_pr): + """Non-skip label on a skipped PR → bug | 1 | 1 in PR Labels section.""" + sc = _make_stats(mocker) + pr = make_pr(author="alice", skip=True, labels=["bug", _SKIP_LABEL]) + sc.populate({0: pr}) + assert "| bug | 1 | 1 |" in sc.to_string() + + +def test_pr_with_no_non_skip_label(mocker, make_pr): + """PR with only the skip label → (no label) | 1 | 1.""" + sc = _make_stats(mocker) + pr = make_pr(author="alice", skip=True, labels=[_SKIP_LABEL]) + sc.populate({0: pr}) + assert "| (no label) | 1 | 1 |" in sc.to_string() + + +def test_skip_label_not_a_label_bucket(mocker, make_pr): + """Skip label name must not appear as a label bucket key.""" + sc = _make_stats(mocker) + pr = make_pr(author="alice", skip=True, labels=["bug", _SKIP_LABEL]) + sc.populate({0: pr}) + result = sc.to_string() + assert "| bug | 1 | 1 |" in result + assert _SKIP_LABEL not in result + + +def test_pr_and_issue_same_label_in_separate_sections(mocker, make_pr, make_issue): + """PR and IssueRecord with the same label each go to their own section (§4 and §3).""" + sc = _make_stats(mocker) + pr = make_pr(author="alice", skip=True, labels=["bug", _SKIP_LABEL]) + issue = make_issue(author="bob", skip=False, labels=["bug"]) + sc.populate({0: pr, 1: issue}) + result = sc.to_string() + assert "Issue Labels" in result + assert "PR Labels" in result + # Issue Labels: 1 issue, 0 skipped + issue_labels_pos = result.index("Issue Labels") + pr_labels_pos = result.index("PR Labels") + issue_section = result[issue_labels_pos:pr_labels_pos] + assert "| bug | 1 | 0 |" in issue_section + # PR Labels: 1 PR, 1 skipped + pr_section = result[pr_labels_pos:] + assert "| bug | 1 | 1 |" in pr_section + + +def test_mixed_labels_mixed_skip(mocker, make_pr): + """Multiple labels with partial skip; sorted by skipped count descending.""" + sc = _make_stats(mocker) + bug_skip1 = make_pr(author="alice", skip=True, labels=["bug", _SKIP_LABEL]) + bug_skip2 = make_pr(author="alice", skip=True, labels=["bug", _SKIP_LABEL]) + bug_normal = make_pr(author="alice", skip=False, labels=["bug"]) + docs1 = make_pr(author="alice", skip=False, labels=["docs"]) + docs2 = make_pr(author="alice", skip=False, labels=["docs"]) + sc.populate({0: bug_skip1, 1: bug_skip2, 2: bug_normal, 3: docs1, 4: docs2}) + result = sc.to_string() + assert "| bug | 3 | 2 |" in result + assert "| docs | 2 | 0 |" in result + assert result.index("| bug |") < result.index("| docs |") + + +def test_record_counts_in_multiple_label_buckets(mocker, make_pr): + """Record with two non-skip labels contributes to both label buckets.""" + sc = _make_stats(mocker) + pr = make_pr(author="alice", skip=True, labels=["bug", "docs", _SKIP_LABEL]) + sc.populate({0: pr}) + result = sc.to_string() + assert "| bug | 1 | 1 |" in result + assert "| docs | 1 | 1 |" in result diff --git a/tests/unit/release_notes_generator/test_action_inputs.py b/tests/unit/release_notes_generator/test_action_inputs.py index 0ffdf1ad..0b085df8 100644 --- a/tests/unit/release_notes_generator/test_action_inputs.py +++ b/tests/unit/release_notes_generator/test_action_inputs.py @@ -398,6 +398,18 @@ def test_get_print_empty_chapters(mocker): assert ActionInputs.get_print_empty_chapters() is True +def test_get_show_stats_chapters_default_true(mocker): + """show-stats-chapters defaults to True when the input is absent (default 'true').""" + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="true") + assert ActionInputs.get_show_stats_chapters() is True + + +def test_get_show_stats_chapters_explicitly_false(mocker): + """show-stats-chapters returns False when explicitly set to 'false'.""" + mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="false") + assert ActionInputs.get_show_stats_chapters() is False + + def test_get_verbose_verbose_by_action_input(mocker): mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="true") mocker.patch("os.getenv", return_value=0)