From 361a8181b1ccc19bd8e4fa52b5af2905e49c7ef3 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Tue, 9 Sep 2025 16:11:51 +0200 Subject: [PATCH 01/33] #167 - Introduce regime for hierarchical release note - Introduced new regime `issue-hierarchy` --- README.md | 14 +++++++++++++- release_notes_generator/action_inputs.py | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6f2596ec..465b4a1a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Generate Release Notes action is dedicated to enhance the quality and organizati | `tag-name` | The name of the tag for which you want to generate release notes. This should be the same as the tag name used in the release workflow. | Yes | | | `from-tag-name` | The name of the tag from which you want to generate release notes. | No | '' | | `chapters` | An YAML array defining chapters and corresponding labels for categorization. Each chapter should have a title and a label matching your GitHub issues and PRs. | Yes | | -| `regime` | Controls the regime of the action. Options: `default`. See more about the [Regimes](#regimes). | No | `default` | +| `regime` | Controls the regime of the action. Options: `default`, `issue-hierarchy`. See more about the [Regimes](#regimes). | No | `default` | | `row-format-issue` | The format of the row for the issue in the release notes. The format can contain placeholders for the issue `number`, `title`, and issues `pull-requests`. The placeholders are case-sensitive. | No | `"{number} _{title}_ in {pull-requests}"` | | `row-format-pr` | The format of the row for the PR in the release notes. The format can contain placeholders for the PR `number`, and `title`. The placeholders are case-sensitive. | No | `"{number} _{title}_"` | | `row-format-link-pr` | If defined `true`, the PR row will begin with a `"PR: "` string. Otherwise, no prefix will be added. | No | true | @@ -96,6 +96,18 @@ The basic regime for this action. - Pull Request without linked Issue. - Direct commits (without Issue and Pull Request). +### Issue Hierarchy regime + +The regime is designed to handle issues with hierarchical relationships, such as Epics and their associated child issues. It ensures that release notes are organized in a way that reflects these relationships. + +- **Data management** + - TBD +- **Release notes** + - Organized by custom chapters defined by user using labels. + - Used these `types of rows`: + - TBD + + ## Outputs The output of the action is a markdown string containing the release notes for the specified tag. This string can be used in subsequent steps to publish the release notes to a file, create a GitHub release, or send notifications. diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index 8ad92019..e2680087 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -64,6 +64,7 @@ class ActionInputs: """ REGIME_DEFAULT = "default" + REGIME_ISSUE_HIERARCHY = "issue-hierarchy" _row_format_issue = None _row_format_pr = None From 5da964d8513206f7f2e80f8797caee5f0d028c42 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Thu, 11 Sep 2025 20:59:08 +0200 Subject: [PATCH 02/33] - Interim backup of state: unit test "test_build_hierarchy_issue_with_one_custom_label" is funvtional. Release Notes builder provides expected output for mocked data. --- README.md | 90 ++++++--- action.yml | 10 + main.py | 20 +- release_notes_generator/action_inputs.py | 29 ++- .../builder/base_builder.py | 53 ------ .../{default_builder.py => builder.py} | 23 ++- release_notes_generator/chapters/__init__.py | 0 .../{model => chapters}/base_chapters.py | 17 +- .../{model => chapters}/custom_chapters.py | 31 +++- .../{model => chapters}/service_chapters.py | 2 +- release_notes_generator/filter.py | 69 ++++++- release_notes_generator/generator.py | 48 ++--- release_notes_generator/miner.py | 51 ++++-- .../model/hierarchy_issue_record.py | 88 +++++++++ release_notes_generator/model/issue_record.py | 4 +- .../record/factory/__init__.py | 0 .../{ => factory}/default_record_factory.py | 14 +- .../factory/issue_hierarchy_record_factory.py | 173 ++++++++++++++++++ .../record/{ => factory}/record_factory.py | 18 +- release_notes_generator/utils/constants.py | 1 + tests/conftest.py | 120 +++++++++++- .../release_notes/model/test_base_chapters.py | 2 +- .../model/test_custom_chapters.py | 2 +- tests/release_notes/test_record_factory.py | 17 +- .../test_release_notes_builder.py | 51 +++++- tests/test_release_notes_generator.py | 3 +- 26 files changed, 757 insertions(+), 179 deletions(-) delete mode 100644 release_notes_generator/builder/base_builder.py rename release_notes_generator/builder/{default_builder.py => builder.py} (78%) create mode 100644 release_notes_generator/chapters/__init__.py rename release_notes_generator/{model => chapters}/base_chapters.py (88%) rename release_notes_generator/{model => chapters}/custom_chapters.py (71%) rename release_notes_generator/{model => chapters}/service_chapters.py (99%) create mode 100644 release_notes_generator/model/hierarchy_issue_record.py create mode 100644 release_notes_generator/record/factory/__init__.py rename release_notes_generator/record/{ => factory}/default_record_factory.py (91%) create mode 100644 release_notes_generator/record/factory/issue_hierarchy_record_factory.py rename release_notes_generator/record/{ => factory}/record_factory.py (75%) diff --git a/README.md b/README.md index 465b4a1a..5bf33809 100644 --- a/README.md +++ b/README.md @@ -42,25 +42,27 @@ Generate Release Notes action is dedicated to enhance the quality and organizati ## Inputs -| Name | Description | Required | Default | -|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------------------------------------------| -| `GITHUB_TOKEN` | Your GitHub token for authentication. Store it as a secret and reference it in the workflow file as secrets.GITHUB_TOKEN. | Yes | | -| `tag-name` | The name of the tag for which you want to generate release notes. This should be the same as the tag name used in the release workflow. | Yes | | -| `from-tag-name` | The name of the tag from which you want to generate release notes. | No | '' | -| `chapters` | An YAML array defining chapters and corresponding labels for categorization. Each chapter should have a title and a label matching your GitHub issues and PRs. | Yes | | -| `regime` | Controls the regime of the action. Options: `default`, `issue-hierarchy`. See more about the [Regimes](#regimes). | No | `default` | -| `row-format-issue` | The format of the row for the issue in the release notes. The format can contain placeholders for the issue `number`, `title`, and issues `pull-requests`. The placeholders are case-sensitive. | No | `"{number} _{title}_ in {pull-requests}"` | -| `row-format-pr` | The format of the row for the PR in the release notes. The format can contain placeholders for the PR `number`, and `title`. The placeholders are case-sensitive. | No | `"{number} _{title}_"` | -| `row-format-link-pr` | If defined `true`, the PR row will begin with a `"PR: "` string. Otherwise, no prefix will be added. | No | true | -| `duplicity-scope` | Set to `custom` to allow duplicity issue lines to be shown only in custom chapters. Options: `custom`, `service`, `both`, `none`. | No | `both` | -| `duplicity-icon` | The icon used to indicate duplicity issue lines in the release notes. Icon will be placed at the beginning of the line. | No | `🔔` | -| `published-at` | Set to true to enable the use of the `published-at` timestamp as the reference point for searching closed issues and PRs, instead of the `created-at` date of the latest release. If first release, repository creation date is used. | No | false | -| `skip-release-notes-labels` | List labels used for detection if issues or pull requests are ignored in the Release Notes generation process. Example: `skip-release-notes, question`. | No | `skip-release-notes` | -| `verbose` | Set to true to enable verbose logging for detailed output during the action's execution. | No | false | -| `release-notes-title` | The title of the release notes section in the PR description. | No | `[Rr]elease [Nn]otes:` | -| `coderabbit-support-active` | Enable CodeRabbit support. If true, the action will use CodeRabbit to generate release notes. | No | false | -| `coderabbit-release-notes-title` | The title of the CodeRabbit summary in the PR body. Value supports regex. | No | `Summary by CodeRabbit` | -| `coderabbit-summary-ignore-groups` | List of "group names" to be ignored by release notes detection logic. Example: `Documentation, Tests, Chores, Bug Fixes`. | No | '' | +| Name | Description | Required | Default | +|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------------------------------------------| +| `GITHUB_TOKEN` | Your GitHub token for authentication. Store it as a secret and reference it in the workflow file as secrets.GITHUB_TOKEN. | Yes | | +| `tag-name` | The name of the tag for which you want to generate release notes. This should be the same as the tag name used in the release workflow. | Yes | | +| `from-tag-name` | The name of the tag from which you want to generate release notes. | No | '' | +| `chapters` | An YAML array defining chapters and corresponding labels for categorization. Each chapter should have a title and a label matching your GitHub issues and PRs. | Yes | | +| `regime` | Controls the regime of the action. Options: `default`, `issue-hierarchy`. See more about the [Regimes](#regimes). | No | `default` | +| `issue-type-weights` | Used only when `regime=issue-hierarchy`. A comma-separated list defining ordered hierarchical issue types (highest first). Listed types become hierarchy nodes (parents) in release notes. Earlier types sit higher (e.g. `Epic` above `Feature`). Unlisted issue types (e.g. `Bug`, `Task`, `Chore`) are treated as normal leaf issues: they never act as parents, gain no extra structural indentation beyond their natural placement under the nearest defined ancestor, and are rendered like standard issues with/without linked PRs. Epics may contain other Epics. If empty, all issues are rendered flat (no hierarchy). Example value: `Epic`, `Feature`. | No | `Epic, Feature` | +| `row-format-hierarchy-issue` | The format of the row for the hierarchy issue in the release notes. The format can contain placeholders for the issue `number` and `title`. The placeholders are case-sensitive. | No | `"{type}: _{title}_ {number}"` | +| `row-format-issue` | The format of the row for the issue in the release notes. The format can contain placeholders for the issue `number`, `title`, and issues `pull-requests`. The placeholders are case-sensitive. | No | `"{number} _{title}_ in {pull-requests}"` | +| `row-format-pr` | The format of the row for the PR in the release notes. The format can contain placeholders for the PR `number`, and `title`. The placeholders are case-sensitive. | No | `"{number} _{title}_"` | +| `row-format-link-pr` | If defined `true`, the PR row will begin with a `"PR: "` string. Otherwise, no prefix will be added. | No | true | +| `duplicity-scope` | Set to `custom` to allow duplicity issue lines to be shown only in custom chapters. Options: `custom`, `service`, `both`, `none`. | No | `both` | +| `duplicity-icon` | The icon used to indicate duplicity issue lines in the release notes. Icon will be placed at the beginning of the line. | No | `🔔` | +| `published-at` | Set to true to enable the use of the `published-at` timestamp as the reference point for searching closed issues and PRs, instead of the `created-at` date of the latest release. If first release, repository creation date is used. | No | false | +| `skip-release-notes-labels` | List labels used for detection if issues or pull requests are ignored in the Release Notes generation process. Example: `skip-release-notes, question`. | No | `skip-release-notes` | +| `verbose` | Set to true to enable verbose logging for detailed output during the action's execution. | No | false | +| `release-notes-title` | The title of the release notes section in the PR description. | No | `[Rr]elease [Nn]otes:` | +| `coderabbit-support-active` | Enable CodeRabbit support. If true, the action will use CodeRabbit to generate release notes. | No | false | +| `coderabbit-release-notes-title` | The title of the CodeRabbit summary in the PR body. Value supports regex. | No | `Summary by CodeRabbit` | +| `coderabbit-summary-ignore-groups` | List of "group names" to be ignored by release notes detection logic. Example: `Documentation, Tests, Chores, Bug Fixes`. | No | '' | > **Notes** > - `skip-release-notes-labels` @@ -72,11 +74,11 @@ Generate Release Notes action is dedicated to enhance the quality and organizati ### Feature controls -| Name | Description | Required | Default | -|----------------|-----------------------------------------------------------------------------------------------------------------------------|----------|--------------------------------------| -| `warnings` | Set to true to print service chapters in the release notes. These warnings identify issues without release notes, without user-defined labels, or without associated pull requests, and PRs without linked issues. | No | true (Service chapters are printed.) | -| `print-empty-chapters` | Set it to true to print chapters with no issues or PRs. | No | true (Empty chapters are printed.) | - +| Name | Description | Required | Default | +|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------------------------------------| +| `warnings` | Set to true to print service chapters in the release notes. These warnings identify issues without release notes, without user-defined labels, or without associated pull requests, and PRs without linked issues. | No | true (Service chapters are printed.) | +| `print-empty-chapters` | Set it to true to print chapters with no issues or PRs. | No | true (Empty chapters are printed.) | + > [!WARNING] > - `warnings` > - **Disabling this feature will hide service chapter showing direct commits!** These cannot be visible in custom chapters as they do not have labels! @@ -96,17 +98,51 @@ The basic regime for this action. - Pull Request without linked Issue. - Direct commits (without Issue and Pull Request). +#### Row Examples +```markdown +TODO +``` + ### Issue Hierarchy regime The regime is designed to handle issues with hierarchical relationships, such as Epics and their associated child issues. It ensures that release notes are organized in a way that reflects these relationships. - **Data management** - - TBD + - The issue type is used to manage the hierarchy of issues. - **Release notes** - Organized by custom chapters defined by user using labels. + - The rows are build using the highest issue in the hierarchy. The hierarchy weight is defined by user using `issue-type-weights` input. - Used these `types of rows`: - - TBD - + - **Epic** + - Epic without Features + - Epic with Features + - Hint: Epic can have more Epics + - **Features** + - Feature without Epic with other types of issues + - Feature without Epic without any other types of issues + - **Any other types of issues `*`** + - `*` without Feature without PR + - `*` without Feature with PR + - **No issues** + - PR without Issue with 1+ commit + - Direct commit + +#### Row Examples +```markdown +- #120 Data Platform Revamp # Epic type + Feature #134 Incremental Extraction # Feature type + Issue #147 Split mining logic (links: #148) # Any other type of issue `*` + PR #155 new dataclass MinedData # Pull Request + Bug #162 Off‑by‑one in batch window # Can be Bug type of issue (depends on team setup) + Feature #150 UI Polish + Task #171 Add skeleton loader + Bug #176 Dark mode overflow + +Epic #200 Observability + Epic #205 Tracing Core # Example of Epic in Epic + Feature #210 OpenTelemetry Export + Issue #214 Validate span enrichment +``` ## Outputs The output of the action is a markdown string containing the release notes for the specified tag. This string can be used in subsequent steps to publish the release notes to a file, create a GitHub release, or send notifications. diff --git a/action.yml b/action.yml index f4a27c46..4d4be714 100644 --- a/action.yml +++ b/action.yml @@ -36,6 +36,10 @@ inputs: description: 'Regime of the release notes generation. Options: default.' required: false default: 'default' + issue-type-weights: + description: 'A comma-separated list defining the order of issue types by their weight' + required: false + default: 'Epic, Feature' duplicity-icon: description: 'Icon to be used for duplicity warning. Icon is placed before the record line.' required: false @@ -76,6 +80,10 @@ inputs: description: 'List of "group names" to be ignored by release notes detection logic.' required: false default: '' + row-format-hierarchy-issue: + description: 'Format of the hierarchy issue in the release notes. Available placeholders: {number}, {title}. Placeholders are case-insensitive.' + required: false + default: '{type}: _{title}_ {number}' row-format-issue: description: 'Format of the issue row in the release notes. Available placeholders: {number}, {title}, {pull-requests}. Placeholders are case-insensitive.' required: false @@ -138,6 +146,7 @@ runs: INPUT_CHAPTERS: ${{ inputs.chapters }} INPUT_FROM_TAG_NAME: ${{ inputs.from-tag-name }} INPUT_REGIME: ${{ inputs.regime }} + INPUT_ISSUE_TYPE_WEIGHTS: ${{ inputs.issue-type-weights }} INPUT_DUPLICITY_SCOPE: ${{ inputs.duplicity-scope }} INPUT_DUPLICITY_ICON: ${{ inputs.duplicity-icon }} INPUT_WARNINGS: ${{ inputs.warnings }} @@ -150,6 +159,7 @@ runs: INPUT_CODERABBIT_RELEASE_NOTES_TITLE: ${{ inputs.coderabbit-release-notes-title }} INPUT_CODERABBIT_SUMMARY_IGNORE_GROUPS: ${{ inputs.coderabbit-summary-ignore-groups }} INPUT_GITHUB_REPOSITORY: ${{ github.repository }} + INPUT_ROW_FORMAT_HIERARCHY_ISSUE: ${{ inputs.row-format-hierarchy-issue }} INPUT_ROW_FORMAT_ISSUE: ${{ inputs.row-format-issue }} INPUT_ROW_FORMAT_PR: ${{ inputs.row-format-pr }} INPUT_ROW_FORMAT_LINK_PR: ${{ inputs.row-format-link-pr }} diff --git a/main.py b/main.py index 7c0b4db4..9a011fc0 100644 --- a/main.py +++ b/main.py @@ -27,13 +27,25 @@ from urllib3.exceptions import InsecureRequestWarning from release_notes_generator.generator import ReleaseNotesGenerator -from release_notes_generator.model.custom_chapters import CustomChapters +from release_notes_generator.chapters.custom_chapters import CustomChapters from release_notes_generator.action_inputs import ActionInputs +from release_notes_generator.model.chapter import Chapter from release_notes_generator.utils.gh_action import set_action_output from release_notes_generator.utils.logging_config import setup_logging warnings.filterwarnings("ignore", category=InsecureRequestWarning) +def prepare_custom_chapters() -> CustomChapters: + custom_chapters = CustomChapters(print_empty_chapters=ActionInputs.get_print_empty_chapters()).from_yaml_array( + ActionInputs.get_chapters() + ) + if ActionInputs.get_regime() == ActionInputs.REGIME_ISSUE_HIERARCHY: + custom_chapters.chapters["New Epics"] = Chapter(title="New Epics") + custom_chapters.chapters["Silent Live Epics"] = Chapter(title="Silent Live") + custom_chapters.chapters["Closed Epics"] = Chapter(title="Closed Epics") + + return custom_chapters + def run() -> None: """ @@ -49,12 +61,8 @@ def run() -> None: py_github = Github(auth=Auth.Token(token=ActionInputs.get_github_token()), per_page=100, verify=False, timeout=60) ActionInputs.validate_inputs() - # Load custom chapters configuration - custom_chapters = CustomChapters(print_empty_chapters=ActionInputs.get_print_empty_chapters()).from_yaml_array( - ActionInputs.get_chapters() - ) - generator = ReleaseNotesGenerator(py_github, custom_chapters) + generator = ReleaseNotesGenerator(py_github, prepare_custom_chapters()) rls_notes = generator.generate() logger.debug("Generated release notes: \n%s", rls_notes) diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index e2680087..861007f3 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -48,7 +48,7 @@ CODERABBIT_SUPPORT_ACTIVE, CODERABBIT_RELEASE_NOTES_TITLE, CODERABBIT_RELEASE_NOTE_TITLE_DEFAULT, - CODERABBIT_SUMMARY_IGNORE_GROUPS, + CODERABBIT_SUMMARY_IGNORE_GROUPS, ROW_FORMAT_HIERARCHY_ISSUE, ) from release_notes_generator.utils.enums import DuplicityScopeEnum from release_notes_generator.utils.gh_action import get_action_input @@ -66,6 +66,7 @@ class ActionInputs: REGIME_DEFAULT = "default" REGIME_ISSUE_HIERARCHY = "issue-hierarchy" + _row_format_hierarchy_issue = None _row_format_issue = None _row_format_pr = None _row_format_link_pr = None @@ -170,6 +171,14 @@ def get_regime() -> str: """ return get_action_input("regime", "default") # type: ignore[return-value] # default defined + @staticmethod + def get_issue_type_weights() -> list[str]: + """ + Get the issue type weights from the action inputs. + """ + user_input = get_action_input("issue-type-weights", "Epic, Feature") + return [item.strip() for item in user_input.split(",")] if user_input else [] + @staticmethod def get_duplicity_scope() -> DuplicityScopeEnum: """ @@ -297,6 +306,22 @@ def validate_input(input_value, expected_type: type, error_message: str, error_b return False return True + @staticmethod + def get_row_format_hierarchy_issue() -> str: + """ + Get the hierarchy issue row format for the release notes. + """ + if ActionInputs._row_format_hierarchy_issue is None: + # TODO - introduce own list of valid keywords + ActionInputs._row_format_hierarchy_issue = ActionInputs._detect_row_format_invalid_keywords( + get_action_input( + ROW_FORMAT_HIERARCHY_ISSUE, "{type}: _{title}_ {number} " + ).strip(), # type: ignore[union-attr] + clean=True, + # mypy: string is returned as default + ) + return ActionInputs._row_format_issue + @staticmethod def get_row_format_issue() -> str: """ @@ -377,7 +402,7 @@ def validate_inputs() -> None: regime = ActionInputs.get_regime() ActionInputs.validate_input(regime, str, "Regime must be a string.", errors) - if regime not in [ActionInputs.REGIME_DEFAULT]: + if regime not in [ActionInputs.REGIME_DEFAULT,ActionInputs.REGIME_ISSUE_HIERARCHY]: errors.append(f"Regime '{regime}' is not supported.") warnings = ActionInputs.get_warnings() diff --git a/release_notes_generator/builder/base_builder.py b/release_notes_generator/builder/base_builder.py deleted file mode 100644 index e579f0f0..00000000 --- a/release_notes_generator/builder/base_builder.py +++ /dev/null @@ -1,53 +0,0 @@ -# -# 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 ReleaseNotesBuilder class which is responsible for building of the release notes. -""" -from abc import ABCMeta, abstractmethod - -from release_notes_generator.action_inputs import ActionInputs -from release_notes_generator.model.custom_chapters import CustomChapters -from release_notes_generator.model.record import Record - - -class ReleaseNotesBuilder(metaclass=ABCMeta): - """ - A class representing the Release Notes Builder. - The class is responsible for building the release notes based on the records, changelog URL, formatter, and custom - chapters. - """ - - def __init__( - self, - records: dict[int | str, Record], - changelog_url: str, - custom_chapters: CustomChapters, - ): - self.records = records - self.changelog_url = changelog_url - self.custom_chapters = custom_chapters - - self.warnings = ActionInputs.get_warnings() - self.print_empty_chapters = ActionInputs.get_print_empty_chapters() - - @abstractmethod - def build(self) -> str: - """ - Build the release notes based on the records, changelog URL, formatter, and custom chapters. - - @return: The release notes as a string. - """ diff --git a/release_notes_generator/builder/default_builder.py b/release_notes_generator/builder/builder.py similarity index 78% rename from release_notes_generator/builder/default_builder.py rename to release_notes_generator/builder/builder.py index 11c72de8..3e94915c 100644 --- a/release_notes_generator/builder/default_builder.py +++ b/release_notes_generator/builder/builder.py @@ -17,23 +17,38 @@ """ This module contains the ReleaseNotesBuilder class which is responsible for building of the release notes. """ - import logging + from itertools import chain -from release_notes_generator.builder.base_builder import ReleaseNotesBuilder -from release_notes_generator.model.service_chapters import ServiceChapters +from release_notes_generator.action_inputs import ActionInputs +from release_notes_generator.chapters.custom_chapters import CustomChapters +from release_notes_generator.model.record import Record +from release_notes_generator.chapters.service_chapters import ServiceChapters logger = logging.getLogger(__name__) -class DefaultReleaseNotesBuilder(ReleaseNotesBuilder): +class ReleaseNotesBuilder: """ A class representing the Release Notes Builder. The class is responsible for building the release notes based on the records, changelog URL, formatter, and custom chapters. """ + def __init__( + self, + records: dict[int | str, Record], + changelog_url: str, + custom_chapters: CustomChapters, + ): + self.records = records + self.changelog_url = changelog_url + self.custom_chapters = custom_chapters + + self.warnings = ActionInputs.get_warnings() + self.print_empty_chapters = ActionInputs.get_print_empty_chapters() + def build(self) -> str: """ Build the release notes based on the records, changelog URL, formatter, and custom chapters. diff --git a/release_notes_generator/chapters/__init__.py b/release_notes_generator/chapters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/release_notes_generator/model/base_chapters.py b/release_notes_generator/chapters/base_chapters.py similarity index 88% rename from release_notes_generator/model/base_chapters.py rename to release_notes_generator/chapters/base_chapters.py index 2ae28ecb..08ff3772 100644 --- a/release_notes_generator/model/base_chapters.py +++ b/release_notes_generator/chapters/base_chapters.py @@ -17,8 +17,10 @@ """ This module contains the BaseChapters class which is responsible for representing the base chapters. """ - from abc import ABC, abstractmethod +from typing import Optional +from datetime import datetime + from release_notes_generator.model.chapter import Chapter from release_notes_generator.model.record import Record @@ -34,6 +36,9 @@ def __init__(self, sort_ascending: bool = True, print_empty_chapters: bool = Tru self.chapters: dict[str, Chapter] = {} self.populated_record_numbers: list[int | str] = [] + # datetime point in time used as begin of release + self._since: Optional[datetime] = None + @property def populated_record_numbers_list(self) -> list[int | str]: """ @@ -43,6 +48,16 @@ def populated_record_numbers_list(self) -> list[int | str]: """ return self.populated_record_numbers + @property + def since(self) -> Optional[datetime]: + if self._since is None: + return datetime.min + return self._since + + @since.setter + def since(self, value: Optional[datetime]): + self._since = value + def add_row(self, chapter_key: str, number: int, row: str) -> None: """ Adds a row to a chapter. diff --git a/release_notes_generator/model/custom_chapters.py b/release_notes_generator/chapters/custom_chapters.py similarity index 71% rename from release_notes_generator/model/custom_chapters.py rename to release_notes_generator/chapters/custom_chapters.py index 45a42234..d73aec36 100644 --- a/release_notes_generator/model/custom_chapters.py +++ b/release_notes_generator/chapters/custom_chapters.py @@ -18,16 +18,19 @@ This module contains the CustomChapters class which is responsible for representing the custom chapters in the release notes. """ +import logging from typing import cast from release_notes_generator.action_inputs import ActionInputs -from release_notes_generator.model.base_chapters import BaseChapters +from release_notes_generator.chapters.base_chapters import BaseChapters from release_notes_generator.model.chapter import Chapter from release_notes_generator.model.commit_record import CommitRecord +from release_notes_generator.model.hierarchy_issue_record import HierarchyIssueRecord from release_notes_generator.model.issue_record import IssueRecord from release_notes_generator.model.record import Record from release_notes_generator.utils.enums import DuplicityScopeEnum +logger = logging.getLogger(__name__) class CustomChapters(BaseChapters): """ @@ -41,6 +44,7 @@ def populate(self, records: dict[int | str, Record]) -> None: @param records: A dictionary of records where the key is an integer and the value is a Record object. @return: None """ + hierarchy_active = ActionInputs.get_regime() == ActionInputs.REGIME_ISSUE_HIERARCHY for record_id, record in records.items(): # iterate all records # check if the record should be skipped if records[record_id].skip: @@ -57,6 +61,10 @@ def populate(self, records: dict[int | str, Record]) -> None: ): continue + if hierarchy_active and isinstance(records[record_id], HierarchyIssueRecord) and not records[record_id].is_present_in_chapters: + self._populate_hierarchy_issue(cast(HierarchyIssueRecord, records[record_id])) + continue + for record_label in records[record_id].labels: # iterate all labels of the record (issue, or 1st PR) pulls_count = 1 if isinstance(records[record_id], IssueRecord): @@ -67,6 +75,27 @@ def populate(self, records: dict[int | str, Record]) -> None: ch.add_row(record_id, records[record_id].to_chapter_row()) self.populated_record_numbers_list.append(record_id) + def _populate_hierarchy_issue(self, record: HierarchyIssueRecord) -> None: + # detect Closed "Epic" + if record.is_closed and not record.is_present_in_chapters: + ch = self.chapters["Closed Epics"] # TODO - add dynamic usage of 1st most weighted type instead of strings + + # detect New root "Epic" + elif record.is_open and record.issue.created_at > self.since: + ch = self.chapters["New Epics"] + + # detect Silent living "Epic" + else: + ch = self.chapters["Silent Live Epics"] + + ch.add_row(record.record_id, record.to_chapter_row()) + self._populate_record_numbers_list_by_hierarchy_issue(record) + + def _populate_record_numbers_list_by_hierarchy_issue(self, record: HierarchyIssueRecord) -> None: + # TODO + ... + + def from_yaml_array(self, chapters: list[dict[str, str]]) -> "CustomChapters": """ Populates the custom chapters from a JSON string. diff --git a/release_notes_generator/model/service_chapters.py b/release_notes_generator/chapters/service_chapters.py similarity index 99% rename from release_notes_generator/model/service_chapters.py rename to release_notes_generator/chapters/service_chapters.py index c52aa20b..f1b26716 100644 --- a/release_notes_generator/model/service_chapters.py +++ b/release_notes_generator/chapters/service_chapters.py @@ -21,7 +21,7 @@ from typing import Optional, cast from release_notes_generator.action_inputs import ActionInputs -from release_notes_generator.model.base_chapters import BaseChapters +from release_notes_generator.chapters.base_chapters import BaseChapters from release_notes_generator.model.chapter import Chapter from release_notes_generator.model.commit_record import CommitRecord from release_notes_generator.model.issue_record import IssueRecord diff --git a/release_notes_generator/filter.py b/release_notes_generator/filter.py index 107eb4bd..ae81710d 100644 --- a/release_notes_generator/filter.py +++ b/release_notes_generator/filter.py @@ -19,6 +19,10 @@ import logging from copy import deepcopy from typing import Optional + +from github.Issue import Issue + +from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.mined_data import MinedData logger = logging.getLogger(__name__) @@ -47,17 +51,18 @@ class FilterByRelease(Filter): def __init__(self, release_version: Optional[str] = None): self.release_version = release_version - def filter(self, data: MinedData) -> MinedData: + def filter(self, data: MinedData, keep_open_issues: bool = True) -> MinedData: """ Filters issues, pull requests, and commits based on the latest release date. If the release is not None, it filters out closed issues, merged pull requests, and commits that occurred before the release date. - @Parameters: - - data (MinedData): The mined data containing issues, pull requests, commits, and release information. + Parameters: + data (MinedData): The mined data containing issues, pull requests, commits, and release information. + keep_open_issues (bool): If True, open issues are retained regardless of the release date. - @Returns: - - MinedData: The filtered mined data with issues, pull requests, and commits reduced based on the release date. + Returns: + MinedData: The filtered mined data. """ md = MinedData() md.repository = data.repository @@ -67,10 +72,7 @@ def filter(self, data: MinedData) -> MinedData: if data.release is not None: logger.info("Starting issue, prs and commit reduction by the latest release since time.") - # filter out closed Issues before the date - issues_list = list( - filter(lambda issue: issue.closed_at is not None and issue.closed_at >= data.since, data.issues) - ) + issues_list = self._filter_issues(data) logger.debug("Count of issues reduced from %d to %d", len(data.issues), len(issues_list)) # filter out merged PRs and commits before the date @@ -110,3 +112,52 @@ def filter(self, data: MinedData) -> MinedData: md.commits = deepcopy(data.commits) return md + + def _filter_issues(self, data: MinedData) -> list: + """ + Filter issues based on the selected regime. + + @param data: The mined data containing issues. + @return: The filtered list of issues. + """ + # Currently, only the default regime is implemented. + if ActionInputs.get_regime() == ActionInputs.REGIME_ISSUE_HIERARCHY: + return self._filter_issues_issue_hierarchy(data) + + logger.debug("Used default issue filtering regime.") + return self._filter_issues_default(data) + + def _filter_issues_default(self, data: MinedData) -> list: + """ + Default filtering for issues: filter out closed issues before the release date. + + @param data: The mined data containing issues. + @return: The filtered list of issues. + """ + return list( + filter( + lambda issue: (issue.closed_at is not None and issue.closed_at >= data.since, data.issues) + ) + ) + + def _filter_issues_issue_hierarchy(self, data: MinedData) -> list: + """ + Filtering for issues in the 'issue-hierarchy' regime: + - filter out closed issues before the release date + - keep open issues + - keep issues with types defined in `issue-type-weights` + + @param data: The mined data containing issues. + @return: The filtered list of issues. + """ + issue_types = ActionInputs.get_issue_type_weights() + return list( + filter( + lambda issue: ( + (issue.closed_at is not None and issue.closed_at >= data.since) + or (issue.state == "open") + or (issue.issue_type in issue_types) + ), + data.issues, + ) + ) diff --git a/release_notes_generator/generator.py b/release_notes_generator/generator.py index fecd9997..c7f76e1d 100644 --- a/release_notes_generator/generator.py +++ b/release_notes_generator/generator.py @@ -28,12 +28,12 @@ from release_notes_generator.filter import FilterByRelease from release_notes_generator.miner import DataMiner from release_notes_generator.action_inputs import ActionInputs -from release_notes_generator.builder.base_builder import ReleaseNotesBuilder -from release_notes_generator.builder.default_builder import DefaultReleaseNotesBuilder -from release_notes_generator.model.custom_chapters import CustomChapters +from release_notes_generator.builder.builder import ReleaseNotesBuilder +from release_notes_generator.chapters.custom_chapters import CustomChapters from release_notes_generator.model.record import Record -from release_notes_generator.record.default_record_factory import DefaultRecordFactory -from release_notes_generator.record.record_factory import RecordFactory +from release_notes_generator.record.factory.default_record_factory import DefaultRecordFactory +from release_notes_generator.record.factory.issue_hierarchy_record_factory import IssueHierarchyRecordFactory +from release_notes_generator.record.factory.record_factory import RecordFactory from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter from release_notes_generator.utils.utils import get_change_url @@ -80,6 +80,7 @@ def generate(self) -> Optional[str]: data = miner.mine_data() if data.is_empty(): return None + self.custom_chapters.since = data.since filterer = FilterByRelease() data_filtered_by_release = filterer.filter(data=data) @@ -92,25 +93,28 @@ def generate(self) -> Optional[str]: assert data_filtered_by_release.repository is not None, "Repository must not be None" - # get record factory instance in dependency on selected regime - record_factory: RecordFactory = DefaultRecordFactory() - # This is a placeholder for future regimes - will be added in following issue - # match ActionInputs.get_regime(): - # case "TODO": - # record_factory = TBD - - rls_notes_records: dict[int | str, Record] = record_factory.generate( + # get record factory instance in dependency on active regime + rls_notes_records: dict[int | str, Record] = self._get_record_factory().generate( github=self._github_instance, data=data_filtered_by_release ) - return self._get_rls_notes_builder(rls_notes_records, changelog_url, self.custom_chapters).build() + return ReleaseNotesBuilder( + records=rls_notes_records, + custom_chapters=self._custom_chapters, + changelog_url=changelog_url, + ).build() - def _get_rls_notes_builder( - self, records: dict[int | str, Record], changelog_url: str, custom_chapters: CustomChapters - ) -> ReleaseNotesBuilder: + def _get_record_factory(self) -> RecordFactory: + """ + Determines and returns the appropriate RecordFactory instance based on the action inputs. - return DefaultReleaseNotesBuilder( - records=records, - custom_chapters=custom_chapters, - changelog_url=changelog_url, - ) + Returns: + RecordFactory: An instance of either IssueHierarchyRecordFactory or RecordFactory. + """ + match ActionInputs.get_regime(): + case ActionInputs.REGIME_ISSUE_HIERARCHY: + logger.info("Using IssueHierarchyRecordFactory based on action inputs.") + return IssueHierarchyRecordFactory() + case _: + logger.info("Using default RecordFactory based on action inputs.") + return DefaultRecordFactory() diff --git a/release_notes_generator/miner.py b/release_notes_generator/miner.py index cfd1ac3e..e6244f87 100644 --- a/release_notes_generator/miner.py +++ b/release_notes_generator/miner.py @@ -117,24 +117,49 @@ def get_latest_release(self, repository: Repository) -> Optional[GitRelease]: def _get_issues(self, data: MinedData): """ - Fetches issues from the repository and adds them to the mined data. + Populate data.issues. + + Logic: + - If no release: fetch all issues. + - If release exists: fetch issues updated since the release timestamp AND all currently open issues + (to include long-lived open issues not updated recently). De-duplicate by issue.number. """ assert data.repository is not None, "Repository must not be None" logger.info("Fetching issues from repository...") - # get all issues + if data.release is None: data.issues = list(self._safe_call(data.repository.get_issues)(state=IssueRecord.ISSUE_STATE_ALL)) - else: - # default is repository creation date if no releases OR created_at of latest release - data.since = data.release.created_at if data.release else data.repository.created_at - if data.release and ActionInputs.get_published_at(): - data.since = data.release.published_at - - data.issues = list( - self._safe_call(data.repository.get_issues)(state=IssueRecord.ISSUE_STATE_ALL, since=data.since) - ) - - logger.info("Fetched %d issues", len(data.issues)) + logger.info("Fetched %d issues", len(data.issues)) + return + + # Derive 'since' from release + data.since = data.release.created_at + if ActionInputs.get_published_at(): + data.since = data.release.published_at + + issues_since = self._safe_call(data.repository.get_issues)( + state=IssueRecord.ISSUE_STATE_ALL, + since=data.since, + ) + open_issues = self._safe_call(data.repository.get_issues)( + state=IssueRecord.ISSUE_STATE_OPEN, + ) + + issues_since = list(issues_since or []) + open_issues = list(open_issues or []) + + by_number = {} + for issue in issues_since: + num = getattr(issue, "number", None) + if num is not None and num not in by_number: + by_number[num] = issue + for issue in open_issues: + num = getattr(issue, "number", None) + if num is not None and num not in by_number: + by_number[num] = issue + + data.issues = list(by_number.values()) + logger.info("Fetched %d issues (deduplicated).", len(data.issues)) def __get_latest_semantic_release(self, releases) -> Optional[GitRelease]: published_releases = [release for release in releases if not release.draft and not release.prerelease] diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py new file mode 100644 index 00000000..edf07677 --- /dev/null +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -0,0 +1,88 @@ +""" +A module that defines the IssueRecord class, which represents an issue record in the release notes. +""" + +import re +from typing import Optional, Any + +from github.Commit import Commit +from github.Issue import Issue +from github.PullRequest import PullRequest + +from release_notes_generator.action_inputs import ActionInputs +from release_notes_generator.model.issue_record import IssueRecord + + +class HierarchyIssueRecord(IssueRecord): + """ + A class used to represent an hierarchy issue record in the release notes. + Inherits from IssueRecord and provides additional functionality specific to issues. + """ + + def __init__(self, issue: Issue, issue_type: Optional[str] = None, skip: bool = False, level: int = 0): + super().__init__(issue, issue_type, skip=skip) + + self._level: int = level + self._issues: dict[int, IssueRecord] = {} # sub-issues + self._hierarchy_issues: dict[int, HierarchyIssueRecord] = {} # sub-hierarchy issues + + # methods - override ancestor methods + def to_chapter_row(self) -> str: + self.added_into_chapters() + row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters() > 1 else "" + format_values: dict[str, Any] = {} + + # collect format values + format_values["number"] = f"#{self._issue.number}" + format_values["title"] = self._issue.title + format_values["type"] = self._issue.type.name + + list_pr_links = self.get_pr_links() + if len(list_pr_links) > 0: + format_values["pull-requests"] = ", ".join(list_pr_links) + else: + format_values["pull-requests"] = "" + + indent: str = " " * self._level + if self._level > 0: + indent += "- " + + # create first issue row + # TODO/Another Issue - add new service chapter for: + # - hierarchy issue which contains other hierarchy issues and normal issues or PRs + # Reason: hierarchy regime should improve readability of complex topics + row = f"{indent}{row_prefix}" + ActionInputs.get_row_format_hierarchy_issue().format(**format_values) + + # add extra section with release notes if detected + if self.contains_release_notes(): + sub_indent: str = " " * (self._level + 1) + row = f"{row}\n{sub_indent}- _Release Notes_:" + sub_indent: str = " " * (self._level + 2) + rls_block = "\n".join(f"{sub_indent}{line}" if line else "" for line in self.get_rls_notes().splitlines()) + row = f"{row}\n{rls_block}" + + # add sub-hierarchy issues + for sub_hierarchy_issue in self._hierarchy_issues.values(): + row = f"{row}\n{sub_hierarchy_issue.to_chapter_row()}" + + # add sub-issues + if len(self._hierarchy_issues) == 0: + sub_indent: str = " " * (self._level + 1) + for sub_issue in self._issues.values(): + sub_issue_block = "- " + sub_issue.to_chapter_row() + ind_child_block = "\n".join(f"{sub_indent}{line}" if line else "" for line in sub_issue_block.splitlines()) + row = f"{row}\n{ind_child_block}" + # else: this will be reported in service chapters as violation of hierarchy in this initial version + # No data loss - in service chapter there will be all detail not presented here + + return row + + def register_hierarchy_issue(self, issue: Issue) -> "HierarchyIssueRecord": + sub_rec = HierarchyIssueRecord(issue=issue, issue_type=issue.type.name, level=self._level + 1) + self._hierarchy_issues[issue.number] = sub_rec + return sub_rec + + def register_issue(self, issue: Issue) -> IssueRecord: + sub_rec = IssueRecord(issue=issue) + self._issues[issue.number] = sub_rec + return sub_rec diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py index 1b4c351c..1fe6f90f 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -23,11 +23,11 @@ class IssueRecord(Record): ISSUE_STATE_OPEN = "open" ISSUE_STATE_ALL = "all" - def __init__(self, issue: Issue, skip: bool = False): + def __init__(self, issue: Issue, issue_type: Optional[str] = None, skip: bool = False): super().__init__(skip=skip) self._issue: Issue = issue - self._issue_type: Optional[str] = None + self._issue_type: Optional[str] = issue_type if issue is not None and issue.type is not None: self._issue_type = issue.type.name diff --git a/release_notes_generator/record/factory/__init__.py b/release_notes_generator/record/factory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/release_notes_generator/record/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py similarity index 91% rename from release_notes_generator/record/default_record_factory.py rename to release_notes_generator/record/factory/default_record_factory.py index 872fadc4..a9c290fa 100644 --- a/release_notes_generator/record/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -15,7 +15,7 @@ # """ -This module contains the RecordFactory class which is responsible for generating records for release notes. +This module contains the DefaultRecordFactory class which is responsible for generating """ import logging @@ -32,7 +32,7 @@ from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.pull_request_record import PullRequestRecord from release_notes_generator.model.record import Record -from release_notes_generator.record.record_factory import RecordFactory +from release_notes_generator.record.factory.record_factory import RecordFactory from release_notes_generator.utils.decorators import safe_call_decorator from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter @@ -75,7 +75,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: safe_call(data.repository.get_issue)(parent_issue_number) if data.repository else None ) if parent_issue is not None: - DefaultRecordFactory._create_record_for_issue(records, parent_issue) + DefaultRecordFactory.create_record_for_issue(records, parent_issue) if parent_issue_number in records: cast(IssueRecord, records[parent_issue_number]).register_pull_request(pull) @@ -94,7 +94,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: logger.debug("Registering issues to records...") for issue in data.issues: - DefaultRecordFactory._create_record_for_issue(records, issue) + DefaultRecordFactory.create_record_for_issue(records, issue) logger.debug("Registering pull requests to records...") for pull in data.pull_requests: @@ -110,7 +110,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: logger.debug("Registering commits to records...") detected_direct_commits_count = sum( - not DefaultRecordFactory._register_commit_to_record(records, commit) for commit in data.commits + not DefaultRecordFactory.register_commit_to_record(records, commit) for commit in data.commits ) logger.info( @@ -123,7 +123,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: return records @staticmethod - def _register_commit_to_record(records: dict[int | str, Record], commit: Commit) -> bool: + def register_commit_to_record(records: dict[int | str, Record], commit: Commit) -> bool: """ Register a commit to a record. @@ -150,7 +150,7 @@ def _register_commit_to_record(records: dict[int | str, Record], commit: Commit) return False @staticmethod - def _create_record_for_issue(records: dict[int | str, Record], i: Issue) -> None: + def create_record_for_issue(records: dict[int | str, Record], i: Issue) -> None: """ Create a record for an issue. diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py new file mode 100644 index 00000000..b08311fc --- /dev/null +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -0,0 +1,173 @@ +# +# 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 DefaultRecordFactory class which is responsible for generating +""" + +import logging +from typing import cast + +from github import Github +from github.Issue import Issue +from github.PullRequest import PullRequest +from github.Commit import Commit + +from release_notes_generator.model.commit_record import CommitRecord +from release_notes_generator.model.issue_record import IssueRecord +from release_notes_generator.model.mined_data import MinedData +from release_notes_generator.action_inputs import ActionInputs +from release_notes_generator.model.pull_request_record import PullRequestRecord +from release_notes_generator.model.record import Record + +from release_notes_generator.utils.decorators import safe_call_decorator +from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter +from release_notes_generator.utils.pull_request_utils import get_issues_for_pr, extract_issue_numbers_from_body + +logger = logging.getLogger(__name__) + + +class IssueHierarchyRecordFactory: + """ + A class used to generate records for release notes. + """ + + def generate(self, github: Github, data: MinedData) -> dict[int | str, Record]: + """ + Generate records for release notes. + Parameters: + github (GitHub): The GitHub instance to generate records for. + data (MinedData): The MinedData instance containing repository, issues, pull requests, and commits. + Returns: + dict[int|str, Record]: A dictionary of records where the key is the issue or pull request number. + """ + + def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: + detected_issues = extract_issue_numbers_from_body(pull) + logger.debug("Detected issues - from body: %s", detected_issues) + detected_issues.update(safe_call(get_issues_for_pr)(pull_number=pull.number)) + logger.debug("Detected issues - final: %s", detected_issues) + + for parent_issue_number in detected_issues: + # create an issue record if not present for PR parent + if parent_issue_number not in records: + logger.warning( + "Detected PR %d linked to issue %d which is not in the list of received issues. " + "Fetching ...", + pull.number, + parent_issue_number, + ) + parent_issue = ( + safe_call(data.repository.get_issue)(parent_issue_number) if data.repository else None + ) + if parent_issue is not None: + IssueHierarchyRecordFactory.create_record_for_issue(records, parent_issue) + + if parent_issue_number in records: + cast(IssueRecord, records[parent_issue_number]).register_pull_request(pull) + logger.debug("Registering PR %d: %s to Issue %d", pull.number, pull.title, parent_issue_number) + else: + logger.debug( + "Registering stand-alone PR %d: %s as mentioned Issue %d not found.", + pull.number, + pull.title, + parent_issue_number, + ) + + records: dict[int | str, Record] = {} + rate_limiter = GithubRateLimiter(github) + safe_call = safe_call_decorator(rate_limiter) + + logger.debug("Registering issues to records...") + + # create hierarchy issue records first + for hierarchy_issue in ActionInputs.get_issue_type_weights(): + for issue in data.issues: + if issue.type.name == hierarchy_issue: + pass + + # create ordinary issue records + for issue in data.issues: + # TODO - skip placed ones + IssueHierarchyRecordFactory.create_record_for_issue(records, issue) + + logger.debug("Registering pull requests to records...") + for pull in data.pull_requests: + pull_labels = [label.name for label in pull.get_labels()] + skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) + + if not safe_call(get_issues_for_pr)(pull_number=pull.number) and not extract_issue_numbers_from_body(pull): + records[pull.number] = PullRequestRecord(pull, skip=skip_record) + logger.debug("Created record for PR %d: %s", pull.number, pull.title) + else: + logger.debug("Registering pull number: %s, title : %s", pull.number, pull.title) + register_pull_request(pull, skip_record) + + logger.debug("Registering commits to records...") + detected_direct_commits_count = sum( + not IssueHierarchyRecordFactory.register_commit_to_record(records, commit) for commit in data.commits + ) + + logger.info( + "Generated %d records from %d issues and %d PRs, with %d commits detected.", + len(records), + len(data.issues), + len(data.pull_requests), + detected_direct_commits_count, + ) + return records + + @staticmethod + def register_commit_to_record(records: dict[int | str, Record], commit: Commit) -> bool: + """ + Register a commit to a record. + + @param commit: The commit to register. + @return: True if the commit was registered to a record, False otherwise + """ + for record in records.values(): + if isinstance(record, IssueRecord): + rec_i = cast(IssueRecord, record) + for number in rec_i.get_pull_request_numbers(): + pr = rec_i.get_pull_request(number) + if pr and pr.merge_commit_sha == commit.sha: + rec_i.register_commit(pr, commit) + return True + + elif isinstance(record, PullRequestRecord): + rec_pr = cast(PullRequestRecord, record) + if rec_pr.is_commit_sha_present(commit.sha): + rec_pr.register_commit(commit) + return True + + records[commit.sha] = CommitRecord(commit=commit) + logger.debug("Created record for direct commit %s: %s", commit.sha, commit.commit.message) + return False + + @staticmethod + def create_record_for_issue(records: dict[int | str, Record], i: Issue) -> None: + """ + Create a record for an issue. + + @param i: Issue instance. + @return: None + """ + # check for skip labels presence and skip when detected + issue_labels = [label.name for label in i.get_labels()] + skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) + records[i.number] = IssueRecord(issue=i, skip=skip_record) + + logger.debug("Created record for issue %d: %s", i.number, i.title) diff --git a/release_notes_generator/record/record_factory.py b/release_notes_generator/record/factory/record_factory.py similarity index 75% rename from release_notes_generator/record/record_factory.py rename to release_notes_generator/record/factory/record_factory.py index cf06b4b9..db00d66a 100644 --- a/release_notes_generator/record/record_factory.py +++ b/release_notes_generator/record/factory/record_factory.py @@ -15,30 +15,32 @@ # """ -This module contains the RecordFactory class which is responsible for generating records for release notes. +This module contains the DefaultRecordFactory class which is responsible for generating """ -from abc import ABCMeta, abstractmethod +import abc +import logging from github import Github - from release_notes_generator.model.mined_data import MinedData from release_notes_generator.model.record import Record +logger = logging.getLogger(__name__) + -class RecordFactory(metaclass=ABCMeta): +class RecordFactory(metaclass=abc.ABCMeta): """ A class used to generate records for release notes. """ - @abstractmethod + @abc.abstractmethod def generate(self, github: Github, data: MinedData) -> dict[int | str, Record]: """ Generate records for release notes. - Parameters: - github (Github): GitHub instance. + github (GitHub): The GitHub instance to generate records for. data (MinedData): The MinedData instance containing repository, issues, pull requests, and commits. - Returns: dict[int|str, Record]: A dictionary of records where the key is the issue or pull request number. """ + + # TODO - do review of children and decide if more useful method could be defined here for inheritation diff --git a/release_notes_generator/utils/constants.py b/release_notes_generator/utils/constants.py index e5ca2318..0ac623a5 100644 --- a/release_notes_generator/utils/constants.py +++ b/release_notes_generator/utils/constants.py @@ -34,6 +34,7 @@ CODERABBIT_RELEASE_NOTES_TITLE = "coderabbit-release-notes-title" CODERABBIT_SUMMARY_IGNORE_GROUPS = "coderabbit-summary-ignore-groups" RUNNER_DEBUG = "RUNNER_DEBUG" +ROW_FORMAT_HIERARCHY_ISSUE = "row-format-hierarchy-issue" ROW_FORMAT_ISSUE = "row-format-issue" ROW_FORMAT_PR = "row-format-pr" ROW_FORMAT_LINK_PR = "row-format-link-pr" diff --git a/tests/conftest.py b/tests/conftest.py index cb2a179d..dcdb90bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,26 +14,23 @@ # limitations under the License. # -import time - from datetime import datetime, timedelta import pytest -from github import Github -from github.Commit import Commit +from github import Github, IssueType from github.GitRelease import GitRelease from github.Issue import Issue from github.PullRequest import PullRequest from github.Rate import Rate -from github.RateLimitOverview import RateLimitOverview from github.Repository import Repository +from release_notes_generator.model.hierarchy_issue_record import HierarchyIssueRecord from release_notes_generator.model.issue_record import IssueRecord from release_notes_generator.model.pull_request_record import PullRequestRecord -from release_notes_generator.model.service_chapters import ServiceChapters +from release_notes_generator.chapters.service_chapters import ServiceChapters from release_notes_generator.model.chapter import Chapter -from release_notes_generator.model.custom_chapters import CustomChapters +from release_notes_generator.chapters.custom_chapters import CustomChapters from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter @@ -62,6 +59,9 @@ def custom_chapters_not_print_empty_chapters(): chapters.chapters = { "Chapter 1": Chapter("Chapter 1 🛠", ["bug", "enhancement"]), "Chapter 2": Chapter("Chapter 2 🎉", ["feature"]), + "New Epics": Chapter("New Epics", []), + "Silent Live Epics": Chapter("Silent Live Epics", []), + "Closed Epics": Chapter("Closed Epics", []), } chapters.print_empty_chapters = False return chapters @@ -208,6 +208,96 @@ def mock_issue_closed_i1_bug_and_skip(mocker): return issue +@pytest.fixture +def mock_open_hierarchy_issue_epic(mocker): + issue_type = mocker.Mock(spec=IssueType) + issue_type.name = "Epic" + + issue = mocker.Mock(spec=Issue) + issue.state = IssueRecord.ISSUE_STATE_OPEN + issue.number = 200 + issue.title = "HI200 open" + issue.state_reason = None + issue.body = "I200 open/nRelease Notes:\n- Epic level release note" + issue.type = issue_type + issue.created_at = datetime.now() + + label1 = mocker.Mock(spec=MockLabel) + label1.name = "label1" + label2 = mocker.Mock(spec=MockLabel) + label2.name = "label2" + issue.get_labels.return_value = [label1, label2] + + return issue + + +@pytest.fixture +def mock_open_hierarchy_issue_feature(mocker): + issue_type = mocker.Mock(spec=IssueType) + issue_type.name = "Feature" + + issue = mocker.Mock(spec=Issue) + issue.state = IssueRecord.ISSUE_STATE_OPEN + issue.number = 201 + issue.title = "HI201 open" + issue.state_reason = None + issue.body = "HI201 open/nRelease Notes:\n- Feature level release note" + issue.type = issue_type + issue.created_at = datetime.now() + + label1 = mocker.Mock(spec=MockLabel) + label1.name = "label1" + label2 = mocker.Mock(spec=MockLabel) + label2.name = "label2" + issue.get_labels.return_value = [label1, label2] + + return issue + + +@pytest.fixture +def mock_closed_issue_type_task(mocker): + issue_type = mocker.Mock(spec=IssueType) + issue_type.name = "Task" + + issue = mocker.Mock(spec=Issue) + issue.state = IssueRecord.ISSUE_STATE_CLOSED + issue.title = "Do this task" + issue.number = 202 + issue.body = "Some issue body text" + issue.type = issue_type + issue.created_at = datetime.now() + + label1 = mocker.Mock(spec=MockLabel) + label1.name = "label1" + label2 = mocker.Mock(spec=MockLabel) + label2.name = "label2" + issue.get_labels.return_value = [label1, label2] + + return issue + + +@pytest.fixture +def mock_closed_issue_type_bug(mocker): + issue_type = mocker.Mock(spec=IssueType) + issue_type.name = "Bug" + + issue = mocker.Mock(spec=Issue) + issue.state = IssueRecord.ISSUE_STATE_CLOSED + issue.title = "Fix the bug" + issue.number = 203 + issue.body = "Some issue body text\nRelease Notes:\n- Fixed bug\n- Improved performance\n+ More nice code\n * Awesome architecture" + issue.type = issue_type + issue.created_at = datetime.now() + + label1 = mocker.Mock(spec=MockLabel) + label1.name = "label1" + label2 = mocker.Mock(spec=MockLabel) + label2.name = "bug" + issue.get_labels.return_value = [label1, label2] + + return issue + + # Fixtures for GitHub Pull Request(s) @pytest.fixture def mock_pull_closed(mocker): @@ -441,6 +531,22 @@ def record_with_issue_closed_two_pulls(request): rec.register_pull_request(request.getfixturevalue("mock_pull_closed_with_rls_notes_102")) return rec +@pytest.fixture +def record_with_two_open_hierarchy_issues_one_closed_issue_two_closed_pulls(request): + rec = HierarchyIssueRecord(issue=request.getfixturevalue("mock_open_hierarchy_issue_epic")) # nr:200 + rec_hierarchy_issue = rec.register_hierarchy_issue(request.getfixturevalue("mock_open_hierarchy_issue_feature")) # nr:201 + + issue_task = request.getfixturevalue("mock_closed_issue_type_task") # nr:202 + rec_issue_task = rec_hierarchy_issue.register_issue(issue_task) + issue_bug = request.getfixturevalue("mock_closed_issue_type_bug") # nr:203 + rec_issue_bug = rec_hierarchy_issue.register_issue(issue_bug) + + # not description keyword used - registration simulate API way (relation) + rec_issue_task.register_pull_request(request.getfixturevalue("mock_pull_closed_with_rls_notes_101")) + rec_issue_bug.register_pull_request(request.getfixturevalue("mock_pull_closed_with_rls_notes_102")) + + return rec + @pytest.fixture def record_with_issue_open_one_pull_closed(request): diff --git a/tests/release_notes/model/test_base_chapters.py b/tests/release_notes/model/test_base_chapters.py index 512a9c0c..d41755e1 100644 --- a/tests/release_notes/model/test_base_chapters.py +++ b/tests/release_notes/model/test_base_chapters.py @@ -14,7 +14,7 @@ # limitations under the License. # -from release_notes_generator.model.base_chapters import BaseChapters +from release_notes_generator.chapters.base_chapters import BaseChapters # Local Record class for testing diff --git a/tests/release_notes/model/test_custom_chapters.py b/tests/release_notes/model/test_custom_chapters.py index 920ea37e..628de4f2 100644 --- a/tests/release_notes/model/test_custom_chapters.py +++ b/tests/release_notes/model/test_custom_chapters.py @@ -15,7 +15,7 @@ # from release_notes_generator.model.chapter import Chapter -from release_notes_generator.model.custom_chapters import CustomChapters +from release_notes_generator.chapters.custom_chapters import CustomChapters from release_notes_generator.model.record import Record from release_notes_generator.utils.enums import DuplicityScopeEnum diff --git a/tests/release_notes/test_record_factory.py b/tests/release_notes/test_record_factory.py index 70a4cd66..d586d771 100644 --- a/tests/release_notes/test_record_factory.py +++ b/tests/release_notes/test_record_factory.py @@ -28,8 +28,7 @@ from release_notes_generator.model.issue_record import IssueRecord from release_notes_generator.model.mined_data import MinedData from release_notes_generator.model.pull_request_record import PullRequestRecord -from release_notes_generator.record.default_record_factory import DefaultRecordFactory -from release_notes_generator.record.record_factory import RecordFactory +from release_notes_generator.record.factory.record_factory import RecordFactory def setup_no_issues_pulls_commits(mocker): @@ -189,7 +188,7 @@ def test_generate_with_issues_and_pulls_and_commits(mocker, mock_repo): data.commits = [commit1, commit2, commit3] data.repository = mock_repo - records = DefaultRecordFactory().generate(mock_github_client, data) + records = RecordFactory().generate(mock_github_client, data) # Check if records for issues and PRs were created assert len(records) == 3 @@ -229,7 +228,7 @@ def test_generate_with_issues_and_pulls_and_commits_with_skip_labels(mocker, moc data.pull_requests = [pr1, pr2] data.commits = [commit1, commit2, commit3] - records = DefaultRecordFactory().generate(mock_github_client,data) + records = RecordFactory().generate(mock_github_client, data) # Check if records for issues and PRs were created assert len(records) == 3 @@ -279,7 +278,7 @@ def test_generate_with_no_commits(mocker, mock_repo): data.commits = [] # No commits data.repository = mock_repo mocker.patch("release_notes_generator.record.default_record_factory.get_issues_for_pr", return_value=[2]) - records = DefaultRecordFactory().generate(mock_github_client, data) + records = RecordFactory().generate(mock_github_client, data) assert 2 == len(records) @@ -314,7 +313,7 @@ def test_generate_with_no_commits_with_wrong_issue_number_in_pull_body_mention(m data.commits = [] # No commits data.repository = mock_repo mocker.patch("release_notes_generator.record.default_record_factory.get_issues_for_pr", return_value=[2]) - records = DefaultRecordFactory().generate(mock_github_client, data) + records = RecordFactory().generate(mock_github_client, data) assert 2 == len(records) @@ -352,7 +351,7 @@ def test_generate_with_no_issues(mocker, request): data.repository = request.getfixturevalue("mock_repo") data.issues = [] # No issues - records = DefaultRecordFactory().generate(mock_github_client, data) + records = RecordFactory().generate(mock_github_client, data) # Verify the record creation assert 2 == len(records) @@ -394,7 +393,7 @@ def test_generate_with_no_issues_skip_labels(mocker, request): data.repository = request.getfixturevalue("mock_repo") data.issues = [] # No issues - records = DefaultRecordFactory().generate(mock_github_client, data) + records = RecordFactory().generate(mock_github_client, data) # Verify the record creation assert 2 == len(records) @@ -424,7 +423,7 @@ def test_generate_with_no_pulls(mocker, mock_repo): data.repository = mock_repo data.pull_requests = [] # No pull requests data.commits = [] # No commits - records = DefaultRecordFactory().generate(mock_github_client, data) + records = RecordFactory().generate(mock_github_client, data) # Verify the record creation assert 2 == len(records) diff --git a/tests/release_notes/test_release_notes_builder.py b/tests/release_notes/test_release_notes_builder.py index 8f552c16..f386b6e3 100644 --- a/tests/release_notes/test_release_notes_builder.py +++ b/tests/release_notes/test_release_notes_builder.py @@ -13,9 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # - -from release_notes_generator.model.custom_chapters import CustomChapters -from release_notes_generator.builder.default_builder import DefaultReleaseNotesBuilder +from release_notes_generator.action_inputs import ActionInputs +from release_notes_generator.builder.builder import ReleaseNotesBuilder +from release_notes_generator.chapters.custom_chapters import CustomChapters # pylint: disable=pointless-string-statement """ @@ -151,6 +151,24 @@ def __init__(self, name): http://example.com/changelog """ +RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_ONE_LABEL_HIERARCHY = """### Chapter 1 🛠 + - #122 _I1+bug_ in #123 + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + - Fixed bug + - Improved performance + + + +### New Epics + - 🔔 _HI200 open_ #200 + +#### Full Changelog +http://example.com/changelog +""" + RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_MORE_LABELS_DUPLICITY_REDUCTION_ON = """### Chapter 1 🛠 - #122 _I1+bug-enhancement_ in #101, #102 - Fixed bug @@ -940,3 +958,30 @@ def test_build_closed_pr_service_chapter_without_issue_with_skip_label_on_pr( actual_release_notes = builder.build() assert expected_release_notes == actual_release_notes + + +def test_build_hierarchy_issue_with_one_custom_label( + custom_chapters_not_print_empty_chapters, + record_with_two_open_hierarchy_issues_one_closed_issue_two_closed_pulls, + record_with_issue_closed_one_pull_merged, mocker +): + expected_release_notes = RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_ONE_LABEL_HIERARCHY + rec_1 = record_with_two_open_hierarchy_issues_one_closed_issue_two_closed_pulls + rec_2 = record_with_issue_closed_one_pull_merged + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_regime", return_value=ActionInputs.REGIME_ISSUE_HIERARCHY) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") + + builder = ReleaseNotesBuilder( + records={rec_1.record_id: rec_1, rec_2.record_id: rec_2}, + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters_not_print_empty_chapters, + ) + + actual_release_notes = builder.build() + + print("XXX - actual release notes") + print(actual_release_notes) + print("XXX") + + assert expected_release_notes == actual_release_notes diff --git a/tests/test_release_notes_generator.py b/tests/test_release_notes_generator.py index 1103f65f..ed661883 100644 --- a/tests/test_release_notes_generator.py +++ b/tests/test_release_notes_generator.py @@ -20,10 +20,9 @@ from github import Github from release_notes_generator.generator import ReleaseNotesGenerator -from release_notes_generator.model.custom_chapters import CustomChapters +from release_notes_generator.chapters.custom_chapters import CustomChapters from release_notes_generator.utils.constants import ROW_FORMAT_ISSUE -from release_notes_generator.filter import FilterByRelease # generate_release_notes tests From 893517b4f3be30f973a1dbdbac1e6a334ecfc29b Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 12 Sep 2025 11:38:57 +0200 Subject: [PATCH 03/33] - Fixes of logical issues in code. First unit test is now working. --- .../chapters/base_chapters.py | 3 +- .../chapters/custom_chapters.py | 7 +-- .../model/hierarchy_issue_record.py | 14 ++--- .../factory/issue_hierarchy_record_factory.py | 48 +++++++++++++--- .../record/factory/record_factory.py | 1 + .../utils/pull_request_utils.py | 2 +- tests/conftest.py | 55 ++++++++++++++++--- .../test_release_notes_builder.py | 13 +++-- 8 files changed, 106 insertions(+), 37 deletions(-) diff --git a/release_notes_generator/chapters/base_chapters.py b/release_notes_generator/chapters/base_chapters.py index 08ff3772..73e51256 100644 --- a/release_notes_generator/chapters/base_chapters.py +++ b/release_notes_generator/chapters/base_chapters.py @@ -82,7 +82,8 @@ def to_string(self) -> str: chapter_string = chapter.to_string( sort_ascending=self.sort_ascending, print_empty_chapters=self.print_empty_chapters ) - result += chapter_string + "\n\n" + if len(chapter_string) > 0: + result += chapter_string + "\n\n" # Note: strip is required to remove leading newline chars when empty chapters are not printed option return result.strip() diff --git a/release_notes_generator/chapters/custom_chapters.py b/release_notes_generator/chapters/custom_chapters.py index d73aec36..af729b84 100644 --- a/release_notes_generator/chapters/custom_chapters.py +++ b/release_notes_generator/chapters/custom_chapters.py @@ -89,12 +89,7 @@ def _populate_hierarchy_issue(self, record: HierarchyIssueRecord) -> None: ch = self.chapters["Silent Live Epics"] ch.add_row(record.record_id, record.to_chapter_row()) - self._populate_record_numbers_list_by_hierarchy_issue(record) - - def _populate_record_numbers_list_by_hierarchy_issue(self, record: HierarchyIssueRecord) -> None: - # TODO - ... - + self.populated_record_numbers_list.append(record.record_id) def from_yaml_array(self, chapters: list[dict[str, str]]) -> "CustomChapters": """ diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index edf07677..91c7696b 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -23,8 +23,8 @@ def __init__(self, issue: Issue, issue_type: Optional[str] = None, skip: bool = super().__init__(issue, issue_type, skip=skip) self._level: int = level - self._issues: dict[int, IssueRecord] = {} # sub-issues - self._hierarchy_issues: dict[int, HierarchyIssueRecord] = {} # sub-hierarchy issues + self._sub_issues: dict[int, IssueRecord] = {} # sub-issues - no more sub-issues + self._sub_hierarchy_issues: dict[int, HierarchyIssueRecord] = {} # sub-hierarchy issues - have sub-issues # methods - override ancestor methods def to_chapter_row(self) -> str: @@ -62,13 +62,13 @@ def to_chapter_row(self) -> str: row = f"{row}\n{rls_block}" # add sub-hierarchy issues - for sub_hierarchy_issue in self._hierarchy_issues.values(): + for sub_hierarchy_issue in self._sub_hierarchy_issues.values(): row = f"{row}\n{sub_hierarchy_issue.to_chapter_row()}" # add sub-issues - if len(self._hierarchy_issues) == 0: + if len(self._sub_issues) > 0: sub_indent: str = " " * (self._level + 1) - for sub_issue in self._issues.values(): + for sub_issue in self._sub_issues.values(): sub_issue_block = "- " + sub_issue.to_chapter_row() ind_child_block = "\n".join(f"{sub_indent}{line}" if line else "" for line in sub_issue_block.splitlines()) row = f"{row}\n{ind_child_block}" @@ -79,10 +79,10 @@ def to_chapter_row(self) -> str: def register_hierarchy_issue(self, issue: Issue) -> "HierarchyIssueRecord": sub_rec = HierarchyIssueRecord(issue=issue, issue_type=issue.type.name, level=self._level + 1) - self._hierarchy_issues[issue.number] = sub_rec + self._sub_hierarchy_issues[issue.number] = sub_rec return sub_rec def register_issue(self, issue: Issue) -> IssueRecord: sub_rec = IssueRecord(issue=issue) - self._issues[issue.number] = sub_rec + self._sub_issues[issue.number] = sub_rec return sub_rec diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index b08311fc..790f7671 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -22,11 +22,12 @@ from typing import cast from github import Github -from github.Issue import Issue +from github.Issue import Issue, SubIssue from github.PullRequest import PullRequest from github.Commit import Commit from release_notes_generator.model.commit_record import CommitRecord +from release_notes_generator.model.hierarchy_issue_record import HierarchyIssueRecord from release_notes_generator.model.issue_record import IssueRecord from release_notes_generator.model.mined_data import MinedData from release_notes_generator.action_inputs import ActionInputs @@ -90,19 +91,31 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: records: dict[int | str, Record] = {} rate_limiter = GithubRateLimiter(github) safe_call = safe_call_decorator(rate_limiter) + registered_issues: list[int] = [] logger.debug("Registering issues to records...") - - # create hierarchy issue records first + # create hierarchy issue records first => dict of hierarchy issues with registered sub-issues + # 1. round - the most heavy ones (e.g. Epic) + # 2. round - the second most heavy ones (e.g. Features) + # 3. round - the rest (e.g. Bugs, Tasks, etc.) but can be just another hierarchy - depend on configuration for hierarchy_issue in ActionInputs.get_issue_type_weights(): for issue in data.issues: - if issue.type.name == hierarchy_issue: - pass - - # create ordinary issue records + if issue.type.name == hierarchy_issue and issue.number not in registered_issues: + self.create_record_for_issue(records, issue) + registered_issues.append(issue.number) + rec: HierarchyIssueRecord = cast(HierarchyIssueRecord, records[issue.number]) + sub_issues = list(rec.issue.get_sub_issues()) + if len(sub_issues) > 0: + self._solve_sub_issues(rec, data, registered_issues, sub_issues) + + # create or register non-hierarchy issue related records for issue in data.issues: - # TODO - skip placed ones - IssueHierarchyRecordFactory.create_record_for_issue(records, issue) + if issue.number not in registered_issues: + parent_issue = issue.get_sub_issues() + if parent_issue is not None: + pass # TODO - find parent and register to it + else: + IssueHierarchyRecordFactory.create_record_for_issue(records, issue) logger.debug("Registering pull requests to records...") for pull in data.pull_requests: @@ -130,6 +143,23 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: ) return records + def _solve_sub_issues(self, record: IssueRecord | HierarchyIssueRecord, data: MinedData, + registered_issues: list[int], sub_issues: list[SubIssue]) -> None: + for sub_issue in sub_issues: # closed in previous rls, in current one, open ones + if sub_issue.number in registered_issues: # already registered + continue + if sub_issue.number not in data.issues: # not closed in current rls or not opened == not in mined data + continue + + sub_sub_issues = list(sub_issue.get_sub_issues()) + if len(sub_sub_issues) > 0: + rec = record.register_hierarchy_issue(sub_issue) + registered_issues.append(sub_issue.number) + self._solve_sub_issues(rec, data, registered_issues, sub_sub_issues) + else: + record.register_issue(sub_issue) + registered_issues.append(sub_issue.number) + @staticmethod def register_commit_to_record(records: dict[int | str, Record], commit: Commit) -> bool: """ diff --git a/release_notes_generator/record/factory/record_factory.py b/release_notes_generator/record/factory/record_factory.py index db00d66a..3a90ec24 100644 --- a/release_notes_generator/record/factory/record_factory.py +++ b/release_notes_generator/record/factory/record_factory.py @@ -44,3 +44,4 @@ def generate(self, github: Github, data: MinedData) -> dict[int | str, Record]: """ # TODO - do review of children and decide if more useful method could be defined here for inheritation + # fix unit test first to detect breaking changes diff --git a/release_notes_generator/utils/pull_request_utils.py b/release_notes_generator/utils/pull_request_utils.py index e66997ee..ce305bc8 100644 --- a/release_notes_generator/utils/pull_request_utils.py +++ b/release_notes_generator/utils/pull_request_utils.py @@ -15,7 +15,7 @@ # """ -This module contains the PullRequestRecord class which is responsible for representing a record in the release notes. +This module contains utility functions for extracting issue numbers from pull request bodies. """ import re diff --git a/tests/conftest.py b/tests/conftest.py index dcdb90bb..419c8c55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,7 @@ from github.Rate import Rate from github.Repository import Repository +from release_notes_generator.model.commit_record import CommitRecord from release_notes_generator.model.hierarchy_issue_record import HierarchyIssueRecord from release_notes_generator.model.issue_record import IssueRecord from release_notes_generator.model.pull_request_record import PullRequestRecord @@ -276,6 +277,25 @@ def mock_closed_issue_type_task(mocker): return issue +@pytest.fixture +def mock_closed_issue_type_none(mocker): + issue = mocker.Mock(spec=Issue) + issue.state = IssueRecord.ISSUE_STATE_CLOSED + issue.title = "Do this issue" + issue.number = 204 + issue.body = "Some sub issue body text" + issue.type = None + issue.created_at = datetime.now() + + label1 = mocker.Mock(spec=MockLabel) + label1.name = "label1" + label2 = mocker.Mock(spec=MockLabel) + label2.name = "label2" + issue.get_labels.return_value = [label1, label2] + + return issue + + @pytest.fixture def mock_closed_issue_type_bug(mocker): issue_type = mocker.Mock(spec=IssueType) @@ -490,9 +510,9 @@ def mock_commit(mocker): commit = mocker.Mock() commit.author = "author" commit.sha = "merge_commit_sha" + commit.message = "Fixed bug" return commit - # Fixtures for Record(s) @pytest.fixture def record_with_issue_open_no_pull(request): @@ -502,6 +522,13 @@ def record_with_issue_open_no_pull(request): def record_with_issue_closed_no_pull(request): return IssueRecord(issue=request.getfixturevalue("mock_issue_closed")) +@pytest.fixture +def record_with_pr_only(request): + return PullRequestRecord(pull=request.getfixturevalue("mock_pull_merged_with_rls_notes_101")) + +@pytest.fixture +def record_with_direct_commit(request): + return CommitRecord(commit=request.getfixturevalue("mock_commit")) @pytest.fixture def record_with_issue_closed_one_pull(request): @@ -532,20 +559,30 @@ def record_with_issue_closed_two_pulls(request): return rec @pytest.fixture -def record_with_two_open_hierarchy_issues_one_closed_issue_two_closed_pulls(request): - rec = HierarchyIssueRecord(issue=request.getfixturevalue("mock_open_hierarchy_issue_epic")) # nr:200 - rec_hierarchy_issue = rec.register_hierarchy_issue(request.getfixturevalue("mock_open_hierarchy_issue_feature")) # nr:201 +def record_with_hierarchy_issues(request): + rec_epic_issue = HierarchyIssueRecord(issue=request.getfixturevalue("mock_open_hierarchy_issue_epic")) # nr:200 + rec_feature_issue = rec_epic_issue.register_hierarchy_issue(request.getfixturevalue("mock_open_hierarchy_issue_feature")) # nr:201 issue_task = request.getfixturevalue("mock_closed_issue_type_task") # nr:202 - rec_issue_task = rec_hierarchy_issue.register_issue(issue_task) + rec_task_issue = rec_feature_issue.register_hierarchy_issue(issue_task) + # add sub_issue + sub_issue_no_type = request.getfixturevalue("mock_closed_issue_type_none") # nr:204 + rec_sub_issue_no_type = rec_task_issue.register_issue(sub_issue_no_type) + # add pr to sub_issue + sub_issue_merged_pr = request.getfixturevalue("mock_pull_merged_with_rls_notes_102") # nr:205 + sub_issue_merged_pr.number = 205 # simulate PR closing sub-issue nr:204 + sub_issue_merged_pr.body = "Closes #204\n\nRelease Notes:\n- Sub issue 204 closed by merged PR" + sub_issue_merged_pr.title = "Sub issue 204 closed by merged PR" + rec_sub_issue_no_type.register_pull_request(sub_issue_merged_pr) + issue_bug = request.getfixturevalue("mock_closed_issue_type_bug") # nr:203 - rec_issue_bug = rec_hierarchy_issue.register_issue(issue_bug) + rec_bug_issue = rec_feature_issue.register_issue(issue_bug) # not description keyword used - registration simulate API way (relation) - rec_issue_task.register_pull_request(request.getfixturevalue("mock_pull_closed_with_rls_notes_101")) - rec_issue_bug.register_pull_request(request.getfixturevalue("mock_pull_closed_with_rls_notes_102")) + rec_task_issue.register_pull_request(request.getfixturevalue("mock_pull_closed_with_rls_notes_101")) + rec_bug_issue.register_pull_request(request.getfixturevalue("mock_pull_closed_with_rls_notes_102")) - return rec + return rec_epic_issue @pytest.fixture diff --git a/tests/release_notes/test_release_notes_builder.py b/tests/release_notes/test_release_notes_builder.py index f386b6e3..6c570f1a 100644 --- a/tests/release_notes/test_release_notes_builder.py +++ b/tests/release_notes/test_release_notes_builder.py @@ -962,18 +962,23 @@ def test_build_closed_pr_service_chapter_without_issue_with_skip_label_on_pr( def test_build_hierarchy_issue_with_one_custom_label( custom_chapters_not_print_empty_chapters, - record_with_two_open_hierarchy_issues_one_closed_issue_two_closed_pulls, - record_with_issue_closed_one_pull_merged, mocker + record_with_hierarchy_issues, + record_with_issue_closed_one_pull_merged, + record_with_pr_only, + record_with_direct_commit, + mocker ): expected_release_notes = RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_ONE_LABEL_HIERARCHY - rec_1 = record_with_two_open_hierarchy_issues_one_closed_issue_two_closed_pulls + rec_1 = record_with_hierarchy_issues rec_2 = record_with_issue_closed_one_pull_merged + rec_3 = record_with_pr_only + rec_4 = record_with_direct_commit mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_regime", return_value=ActionInputs.REGIME_ISSUE_HIERARCHY) mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") builder = ReleaseNotesBuilder( - records={rec_1.record_id: rec_1, rec_2.record_id: rec_2}, + records={rec_1.record_id: rec_1, rec_2.record_id: rec_2, rec_3.record_id: rec_3, rec_4.record_id: rec_4}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, ) From 64717e29f5a0b97188f62b991d83c0712a0ef3ea Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 12 Sep 2025 13:10:50 +0200 Subject: [PATCH 04/33] - Fixes all mypy alerts and applied black. --- main.py | 1 + release_notes_generator/action_inputs.py | 29 ++++++++++++++----- .../chapters/base_chapters.py | 2 +- .../chapters/custom_chapters.py | 7 ++++- release_notes_generator/filter.py | 7 +++-- .../model/hierarchy_issue_record.py | 12 ++++---- .../factory/issue_hierarchy_record_factory.py | 19 ++++++++---- release_notes_generator/utils/constants.py | 4 ++- 8 files changed, 58 insertions(+), 23 deletions(-) diff --git a/main.py b/main.py index 9a011fc0..0d8bbeec 100644 --- a/main.py +++ b/main.py @@ -35,6 +35,7 @@ warnings.filterwarnings("ignore", category=InsecureRequestWarning) + def prepare_custom_chapters() -> CustomChapters: custom_chapters = CustomChapters(print_empty_chapters=ActionInputs.get_print_empty_chapters()).from_yaml_array( ActionInputs.get_chapters() diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index 861007f3..ba3b4fd8 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -43,12 +43,13 @@ SKIP_RELEASE_NOTES_LABELS, RELEASE_NOTES_TITLE, RELEASE_NOTE_TITLE_DEFAULT, - SUPPORTED_ROW_FORMAT_KEYS, FROM_TAG_NAME, CODERABBIT_SUPPORT_ACTIVE, CODERABBIT_RELEASE_NOTES_TITLE, CODERABBIT_RELEASE_NOTE_TITLE_DEFAULT, - CODERABBIT_SUMMARY_IGNORE_GROUPS, ROW_FORMAT_HIERARCHY_ISSUE, + CODERABBIT_SUMMARY_IGNORE_GROUPS, + ROW_FORMAT_HIERARCHY_ISSUE, SUPPORTED_ROW_FORMAT_KEYS_ISSUE, SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST, + SUPPORTED_ROW_FORMAT_KEYS_HIERARCHY_ISSUE, ) from release_notes_generator.utils.enums import DuplicityScopeEnum from release_notes_generator.utils.gh_action import get_action_input @@ -66,6 +67,10 @@ class ActionInputs: REGIME_DEFAULT = "default" REGIME_ISSUE_HIERARCHY = "issue-hierarchy" + ROW_TYPE_ISSUE = "Issue" + ROW_TYPE_PR = "PR" + ROW_TYPE_HIERARCHY_ISSUE = "HierarchyIssue" + _row_format_hierarchy_issue = None _row_format_issue = None _row_format_pr = None @@ -312,15 +317,14 @@ def get_row_format_hierarchy_issue() -> str: Get the hierarchy issue row format for the release notes. """ if ActionInputs._row_format_hierarchy_issue is None: - # TODO - introduce own list of valid keywords ActionInputs._row_format_hierarchy_issue = ActionInputs._detect_row_format_invalid_keywords( get_action_input( ROW_FORMAT_HIERARCHY_ISSUE, "{type}: _{title}_ {number} " ).strip(), # type: ignore[union-attr] + row_type=ActionInputs.ROW_TYPE_HIERARCHY_ISSUE, clean=True, - # mypy: string is returned as default ) - return ActionInputs._row_format_issue + return ActionInputs._row_format_hierarchy_issue @staticmethod def get_row_format_issue() -> str: @@ -345,6 +349,7 @@ def get_row_format_pr() -> str: if ActionInputs._row_format_pr is None: ActionInputs._row_format_pr = ActionInputs._detect_row_format_invalid_keywords( get_action_input(ROW_FORMAT_PR, "{number} _{title}_").strip(), # type: ignore[union-attr] + row_type=ActionInputs.ROW_TYPE_PR, clean=True, # mypy: string is returned as default ) @@ -402,7 +407,7 @@ def validate_inputs() -> None: regime = ActionInputs.get_regime() ActionInputs.validate_input(regime, str, "Regime must be a string.", errors) - if regime not in [ActionInputs.REGIME_DEFAULT,ActionInputs.REGIME_ISSUE_HIERARCHY]: + if regime not in [ActionInputs.REGIME_DEFAULT, ActionInputs.REGIME_ISSUE_HIERARCHY]: errors.append(f"Regime '{regime}' is not supported.") warnings = ActionInputs.get_warnings() @@ -482,7 +487,17 @@ def _detect_row_format_invalid_keywords(row_format: str, row_type: str = "Issue" @return: If clean is True, the cleaned row format. Otherwise, the original row format. """ keywords_in_braces = re.findall(r"\{(.*?)\}", row_format) - invalid_keywords = [keyword for keyword in keywords_in_braces if keyword not in SUPPORTED_ROW_FORMAT_KEYS] + + supported_row_format_keys = [] + match row_type: + case ActionInputs.ROW_TYPE_ISSUE: + supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_ISSUE + case ActionInputs.ROW_TYPE_PR: + supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST + case ActionInputs.ROW_TYPE_HIERARCHY_ISSUE: + supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_HIERARCHY_ISSUE + + invalid_keywords = [keyword for keyword in keywords_in_braces if keyword not in supported_row_format_keys] cleaned_row_format = row_format for invalid_keyword in invalid_keywords: logger.error( diff --git a/release_notes_generator/chapters/base_chapters.py b/release_notes_generator/chapters/base_chapters.py index 73e51256..a1f924fb 100644 --- a/release_notes_generator/chapters/base_chapters.py +++ b/release_notes_generator/chapters/base_chapters.py @@ -49,7 +49,7 @@ def populated_record_numbers_list(self) -> list[int | str]: return self.populated_record_numbers @property - def since(self) -> Optional[datetime]: + def since(self) -> datetime: if self._since is None: return datetime.min return self._since diff --git a/release_notes_generator/chapters/custom_chapters.py b/release_notes_generator/chapters/custom_chapters.py index af729b84..f6b71092 100644 --- a/release_notes_generator/chapters/custom_chapters.py +++ b/release_notes_generator/chapters/custom_chapters.py @@ -32,6 +32,7 @@ logger = logging.getLogger(__name__) + class CustomChapters(BaseChapters): """ A class used to represent the custom chapters in the release notes. @@ -61,7 +62,11 @@ def populate(self, records: dict[int | str, Record]) -> None: ): continue - if hierarchy_active and isinstance(records[record_id], HierarchyIssueRecord) and not records[record_id].is_present_in_chapters: + if ( + hierarchy_active + and isinstance(records[record_id], HierarchyIssueRecord) + and not records[record_id].is_present_in_chapters + ): self._populate_hierarchy_issue(cast(HierarchyIssueRecord, records[record_id])) continue diff --git a/release_notes_generator/filter.py b/release_notes_generator/filter.py index ae81710d..dada8824 100644 --- a/release_notes_generator/filter.py +++ b/release_notes_generator/filter.py @@ -136,7 +136,10 @@ def _filter_issues_default(self, data: MinedData) -> list: """ return list( filter( - lambda issue: (issue.closed_at is not None and issue.closed_at >= data.since, data.issues) + lambda issue: ( + issue.closed_at is not None and issue.closed_at >= data.since, data.issues + ), + data.issues, ) ) @@ -156,7 +159,7 @@ def _filter_issues_issue_hierarchy(self, data: MinedData) -> list: lambda issue: ( (issue.closed_at is not None and issue.closed_at >= data.since) or (issue.state == "open") - or (issue.issue_type in issue_types) + or (issue.type.name in issue_types) ), data.issues, ) diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index 91c7696b..33f84685 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -23,8 +23,8 @@ def __init__(self, issue: Issue, issue_type: Optional[str] = None, skip: bool = super().__init__(issue, issue_type, skip=skip) self._level: int = level - self._sub_issues: dict[int, IssueRecord] = {} # sub-issues - no more sub-issues - self._sub_hierarchy_issues: dict[int, HierarchyIssueRecord] = {} # sub-hierarchy issues - have sub-issues + self._sub_issues: dict[int, IssueRecord] = {} # sub-issues - no more sub-issues + self._sub_hierarchy_issues: dict[int, HierarchyIssueRecord] = {} # sub-hierarchy issues - have sub-issues # methods - override ancestor methods def to_chapter_row(self) -> str: @@ -57,7 +57,7 @@ def to_chapter_row(self) -> str: if self.contains_release_notes(): sub_indent: str = " " * (self._level + 1) row = f"{row}\n{sub_indent}- _Release Notes_:" - sub_indent: str = " " * (self._level + 2) + sub_indent = " " * (self._level + 2) rls_block = "\n".join(f"{sub_indent}{line}" if line else "" for line in self.get_rls_notes().splitlines()) row = f"{row}\n{rls_block}" @@ -67,10 +67,12 @@ def to_chapter_row(self) -> str: # add sub-issues if len(self._sub_issues) > 0: - sub_indent: str = " " * (self._level + 1) + sub_indent = " " * (self._level + 1) for sub_issue in self._sub_issues.values(): sub_issue_block = "- " + sub_issue.to_chapter_row() - ind_child_block = "\n".join(f"{sub_indent}{line}" if line else "" for line in sub_issue_block.splitlines()) + ind_child_block = "\n".join( + f"{sub_indent}{line}" if line else "" for line in sub_issue_block.splitlines() + ) row = f"{row}\n{ind_child_block}" # else: this will be reported in service chapters as violation of hierarchy in this initial version # No data loss - in service chapter there will be all detail not presented here diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 790f7671..9e210d43 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -33,6 +33,7 @@ from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.pull_request_record import PullRequestRecord from release_notes_generator.model.record import Record +from release_notes_generator.record.factory.default_record_factory import DefaultRecordFactory from release_notes_generator.utils.decorators import safe_call_decorator from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter @@ -41,7 +42,8 @@ logger = logging.getLogger(__name__) -class IssueHierarchyRecordFactory: +# TODO - code review - check if it beneficial to inherit from DefaultRecordFactory +class IssueHierarchyRecordFactory(DefaultRecordFactory): """ A class used to generate records for release notes. """ @@ -113,7 +115,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: if issue.number not in registered_issues: parent_issue = issue.get_sub_issues() if parent_issue is not None: - pass # TODO - find parent and register to it + pass # TODO - find parent and register to it else: IssueHierarchyRecordFactory.create_record_for_issue(records, issue) @@ -143,10 +145,15 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: ) return records - def _solve_sub_issues(self, record: IssueRecord | HierarchyIssueRecord, data: MinedData, - registered_issues: list[int], sub_issues: list[SubIssue]) -> None: - for sub_issue in sub_issues: # closed in previous rls, in current one, open ones - if sub_issue.number in registered_issues: # already registered + def _solve_sub_issues( + self, + record: HierarchyIssueRecord, + data: MinedData, + registered_issues: list[int], + sub_issues: list[SubIssue], + ) -> None: + for sub_issue in sub_issues: # closed in previous rls, in current one, open ones + if sub_issue.number in registered_issues: # already registered continue if sub_issue.number not in data.issues: # not closed in current rls or not opened == not in mined data continue diff --git a/release_notes_generator/utils/constants.py b/release_notes_generator/utils/constants.py index 0ac623a5..26242b8e 100644 --- a/release_notes_generator/utils/constants.py +++ b/release_notes_generator/utils/constants.py @@ -38,7 +38,9 @@ ROW_FORMAT_ISSUE = "row-format-issue" ROW_FORMAT_PR = "row-format-pr" ROW_FORMAT_LINK_PR = "row-format-link-pr" -SUPPORTED_ROW_FORMAT_KEYS = ["number", "title", "pull-requests"] +SUPPORTED_ROW_FORMAT_KEYS_HIERARCHY_ISSUE = ["type", "number", "title"] +SUPPORTED_ROW_FORMAT_KEYS_ISSUE = ["number", "title", "pull-requests"] +SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST = ["number", "title", "pull-requests"] # Features WARNINGS = "warnings" From ff66d7fa2b6e3163e346f9ee3ca2f25e9bca474a Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 12 Sep 2025 13:30:32 +0200 Subject: [PATCH 05/33] - Partial fix of pylint. --- .../chapters/base_chapters.py | 6 ++++++ release_notes_generator/filter.py | 5 +---- .../model/hierarchy_issue_record.py | 20 +++++++++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/release_notes_generator/chapters/base_chapters.py b/release_notes_generator/chapters/base_chapters.py index a1f924fb..90209ca5 100644 --- a/release_notes_generator/chapters/base_chapters.py +++ b/release_notes_generator/chapters/base_chapters.py @@ -50,6 +50,12 @@ def populated_record_numbers_list(self) -> list[int | str]: @property def since(self) -> datetime: + """ + Gets the since datetime. + + Returns: + The since datetime or datetime.min if not set. + """ if self._since is None: return datetime.min return self._since diff --git a/release_notes_generator/filter.py b/release_notes_generator/filter.py index dada8824..7e468711 100644 --- a/release_notes_generator/filter.py +++ b/release_notes_generator/filter.py @@ -20,8 +20,6 @@ from copy import deepcopy from typing import Optional -from github.Issue import Issue - from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.mined_data import MinedData @@ -51,7 +49,7 @@ class FilterByRelease(Filter): def __init__(self, release_version: Optional[str] = None): self.release_version = release_version - def filter(self, data: MinedData, keep_open_issues: bool = True) -> MinedData: + def filter(self, data: MinedData) -> MinedData: """ Filters issues, pull requests, and commits based on the latest release date. If the release is not None, it filters out closed issues, merged pull requests, and commits @@ -59,7 +57,6 @@ def filter(self, data: MinedData, keep_open_issues: bool = True) -> MinedData: Parameters: data (MinedData): The mined data containing issues, pull requests, commits, and release information. - keep_open_issues (bool): If True, open issues are retained regardless of the release date. Returns: MinedData: The filtered mined data. diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index 33f84685..ee33be14 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -2,12 +2,8 @@ A module that defines the IssueRecord class, which represents an issue record in the release notes. """ -import re from typing import Optional, Any - -from github.Commit import Commit from github.Issue import Issue -from github.PullRequest import PullRequest from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.issue_record import IssueRecord @@ -80,11 +76,27 @@ def to_chapter_row(self) -> str: return row def register_hierarchy_issue(self, issue: Issue) -> "HierarchyIssueRecord": + """ + Registers a sub-hierarchy issue. + + Parameters: + issue: The sub-hierarchy issue to register. + Returns: + The registered sub-hierarchy issue record. + """ sub_rec = HierarchyIssueRecord(issue=issue, issue_type=issue.type.name, level=self._level + 1) self._sub_hierarchy_issues[issue.number] = sub_rec return sub_rec def register_issue(self, issue: Issue) -> IssueRecord: + """ + Registers a sub-issue. + + Parameters: + issue: The sub-issue to register. + Returns: + The registered sub-issue record. + """ sub_rec = IssueRecord(issue=issue) self._sub_issues[issue.number] = sub_rec return sub_rec From 953a926281428d2ed8fb430a9b939e0ed2ac8cb9 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 12 Sep 2025 14:15:55 +0200 Subject: [PATCH 06/33] - Fixed all historical unit tests. --- release_notes_generator/filter.py | 14 +-- tests/release_notes/test_record_factory.py | 32 +++--- .../test_release_notes_builder.py | 104 +++++++++--------- tests/test_filter.py | 6 +- tests/test_release_notes_generator.py | 3 - 5 files changed, 77 insertions(+), 82 deletions(-) diff --git a/release_notes_generator/filter.py b/release_notes_generator/filter.py index 7e468711..45105912 100644 --- a/release_notes_generator/filter.py +++ b/release_notes_generator/filter.py @@ -121,6 +121,7 @@ def _filter_issues(self, data: MinedData) -> list: if ActionInputs.get_regime() == ActionInputs.REGIME_ISSUE_HIERARCHY: return self._filter_issues_issue_hierarchy(data) + logger.debug("Used default issue filtering regime.") return self._filter_issues_default(data) @@ -131,14 +132,11 @@ def _filter_issues_default(self, data: MinedData) -> list: @param data: The mined data containing issues. @return: The filtered list of issues. """ - return list( - filter( - lambda issue: ( - issue.closed_at is not None and issue.closed_at >= data.since, data.issues - ), - data.issues, - ) - ) + return [ + issue + for issue in data.issues + if (issue.closed_at is None) or (issue.closed_at >= data.since) + ] def _filter_issues_issue_hierarchy(self, data: MinedData) -> list: """ diff --git a/tests/release_notes/test_record_factory.py b/tests/release_notes/test_record_factory.py index d586d771..9c8ebdeb 100644 --- a/tests/release_notes/test_record_factory.py +++ b/tests/release_notes/test_record_factory.py @@ -28,7 +28,7 @@ from release_notes_generator.model.issue_record import IssueRecord from release_notes_generator.model.mined_data import MinedData from release_notes_generator.model.pull_request_record import PullRequestRecord -from release_notes_generator.record.factory.record_factory import RecordFactory +from release_notes_generator.record.factory.default_record_factory import DefaultRecordFactory def setup_no_issues_pulls_commits(mocker): @@ -171,7 +171,7 @@ def mock_get_issues_for_pr(pull_number: int) -> list[int]: return [] def test_generate_with_issues_and_pulls_and_commits(mocker, mock_repo): - mocker.patch("release_notes_generator.record.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) mock_github_client = mocker.Mock(spec=Github) issue1, issue2, pr1, pr2, commit1, commit2 = setup_issues_pulls_commits(mocker) @@ -188,7 +188,7 @@ def test_generate_with_issues_and_pulls_and_commits(mocker, mock_repo): data.commits = [commit1, commit2, commit3] data.repository = mock_repo - records = RecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory().generate(mock_github_client, data) # Check if records for issues and PRs were created assert len(records) == 3 @@ -208,8 +208,8 @@ def test_generate_with_issues_and_pulls_and_commits(mocker, mock_repo): assert commit1 == rec_i1.get_commit(101, 'abc123') def test_generate_with_issues_and_pulls_and_commits_with_skip_labels(mocker, mock_repo): - mocker.patch("release_notes_generator.record.default_record_factory.ActionInputs.get_skip_release_notes_labels", return_value=["skip-release-notes"]) - mocker.patch("release_notes_generator.record.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mocker.patch("release_notes_generator.record.factory.default_record_factory.ActionInputs.get_skip_release_notes_labels", return_value=["skip-release-notes"]) + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) mock_github_client = mocker.Mock(spec=Github) issue1, issue2, pr1, pr2, commit1, commit2 = setup_issues_pulls_commits(mocker) @@ -228,7 +228,7 @@ def test_generate_with_issues_and_pulls_and_commits_with_skip_labels(mocker, moc data.pull_requests = [pr1, pr2] data.commits = [commit1, commit2, commit3] - records = RecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory().generate(mock_github_client, data) # Check if records for issues and PRs were created assert len(records) == 3 @@ -277,8 +277,8 @@ def test_generate_with_no_commits(mocker, mock_repo): data.commits = [] # No commits data.repository = mock_repo - mocker.patch("release_notes_generator.record.default_record_factory.get_issues_for_pr", return_value=[2]) - records = RecordFactory().generate(mock_github_client, data) + mocker.patch("release_notes_generator.record.factory.default_record_factory.get_issues_for_pr", return_value=[2]) + records = DefaultRecordFactory().generate(mock_github_client, data) assert 2 == len(records) @@ -312,8 +312,8 @@ def test_generate_with_no_commits_with_wrong_issue_number_in_pull_body_mention(m data.commits = [] # No commits data.repository = mock_repo - mocker.patch("release_notes_generator.record.default_record_factory.get_issues_for_pr", return_value=[2]) - records = RecordFactory().generate(mock_github_client, data) + mocker.patch("release_notes_generator.record.factory.default_record_factory.get_issues_for_pr", return_value=[2]) + records = DefaultRecordFactory().generate(mock_github_client, data) assert 2 == len(records) @@ -342,7 +342,7 @@ def mock_get_issues_for_pr_no_issues(pull_number: int) -> list[int]: def test_generate_with_no_issues(mocker, request): - mocker.patch("release_notes_generator.record.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator_no_issues) + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator_no_issues) mock_github_client = mocker.Mock(spec=Github) data = MinedData() pr1, pr2, commit1, commit2 = setup_no_issues_pulls_commits(mocker) @@ -351,7 +351,7 @@ def test_generate_with_no_issues(mocker, request): data.repository = request.getfixturevalue("mock_repo") data.issues = [] # No issues - records = RecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory().generate(mock_github_client, data) # Verify the record creation assert 2 == len(records) @@ -373,8 +373,8 @@ def test_generate_with_no_issues(mocker, request): assert commit2 == rec_pr2.get_commit('def456') def test_generate_with_no_issues_skip_labels(mocker, request): - mocker.patch("release_notes_generator.record.default_record_factory.ActionInputs.get_skip_release_notes_labels", return_value=["skip-release-notes", "another-skip-label"]) - mocker.patch("release_notes_generator.record.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator_no_issues) + mocker.patch("release_notes_generator.record.factory.default_record_factory.ActionInputs.get_skip_release_notes_labels", return_value=["skip-release-notes", "another-skip-label"]) + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator_no_issues) mock_github_client = mocker.Mock(spec=Github) data = MinedData() pr1, pr2, commit1, commit2 = setup_no_issues_pulls_commits(mocker) @@ -393,7 +393,7 @@ def test_generate_with_no_issues_skip_labels(mocker, request): data.repository = request.getfixturevalue("mock_repo") data.issues = [] # No issues - records = RecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory().generate(mock_github_client, data) # Verify the record creation assert 2 == len(records) @@ -423,7 +423,7 @@ def test_generate_with_no_pulls(mocker, mock_repo): data.repository = mock_repo data.pull_requests = [] # No pull requests data.commits = [] # No commits - records = RecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory().generate(mock_github_client, data) # Verify the record creation assert 2 == len(records) diff --git a/tests/release_notes/test_release_notes_builder.py b/tests/release_notes/test_release_notes_builder.py index 6c570f1a..e7368941 100644 --- a/tests/release_notes/test_release_notes_builder.py +++ b/tests/release_notes/test_release_notes_builder.py @@ -339,7 +339,7 @@ def test_build_no_data(): expected_release_notes = RELEASE_NOTES_NO_DATA - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={}, # empty record data set changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters, @@ -352,11 +352,11 @@ def test_build_no_data(): def test_build_no_data_no_warnings(mocker): custom_chapters = CustomChapters() custom_chapters.from_yaml_array(default_chapters) - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_warnings", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_warnings", return_value=False) expected_release_notes = RELEASE_NOTES_NO_DATA_NO_WARNING - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={}, # empty record data set changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters, @@ -370,12 +370,12 @@ def test_build_no_data_no_warnings_no_empty_chapters(mocker): custom_chapters_no_empty_chapters = CustomChapters() custom_chapters_no_empty_chapters.from_yaml_array(default_chapters) custom_chapters_no_empty_chapters.print_empty_chapters = False - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_warnings", return_value=False) - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_warnings", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) expected_release_notes = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_no_empty_chapters, @@ -389,11 +389,11 @@ def test_build_no_data_no_empty_chapters(mocker): custom_chapters_no_empty_chapters = CustomChapters() custom_chapters_no_empty_chapters.from_yaml_array(default_chapters) custom_chapters_no_empty_chapters.print_empty_chapters = False - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) expected_release_notes = RELEASE_NOTES_NO_DATA_NO_EMPTY_CHAPTERS - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_no_empty_chapters, @@ -563,9 +563,9 @@ def test_build_closed_issue_with_one_custom_label( ): expected_release_notes = RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_ONE_LABEL rec = record_with_issue_closed_two_pulls - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -583,9 +583,9 @@ def test_build_closed_issue_with_more_custom_labels_duplicity_reduction_on( rec = record_with_issue_closed_two_pulls rec.issue.labels.append(MockLabel("enhancement")) rec.issue.title = "I1+bug-enhancement" - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -601,9 +601,9 @@ def test_build_closed_issue_service_chapter_without_pull_request_and_user_define ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_ISSUE_NO_PR_NO_USER_LABELS rec = record_with_issue_closed_no_pull - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -619,9 +619,9 @@ def test_build_merged_pr_service_chapter_without_issue_and_user_labels( ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_MERGED_PR_NO_ISSUE_NO_USER_LABELS rec = pull_request_record_merged - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -637,9 +637,9 @@ def test_build_closed_pr_service_chapter_without_issue_and_user_labels( ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_NO_USER_LABELS rec = pull_request_record_closed - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -655,9 +655,9 @@ def test_build_open_issue_with_merged_pr_service_chapter_linked_to_not_closed_is ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_OPEN_ISSUE_AND_MERGED_PR_NO_USER_LABELS rec = record_with_issue_open_two_pulls_closed - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -671,9 +671,9 @@ def test_build_open_issue_with_merged_pr_service_chapter_linked_to_not_closed_is def test_build_open_issue(custom_chapters_not_print_empty_chapters, record_with_issue_open_no_pull, mocker): expected_release_notes = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS rec = record_with_issue_open_no_pull - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -687,9 +687,9 @@ def test_build_open_issue(custom_chapters_not_print_empty_chapters, record_with_ def test_build_closed_issue(custom_chapters_not_print_empty_chapters, record_with_issue_closed_no_pull, mocker): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_ISSUE_NO_PR_NO_USER_LABELS rec = record_with_issue_closed_no_pull - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -706,9 +706,9 @@ def test_build_reopened_issue(custom_chapters_not_print_empty_chapters, record_w expected_release_notes = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS rec = record_with_issue_open_no_pull rec.issue.state_reason = "reopened" - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -725,9 +725,9 @@ def test_build_closed_not_planned_issue( expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_ISSUE_NO_PR_NO_USER_LABELS rec = record_with_issue_closed_no_pull rec.issue.state_reason = "not_planned" - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -744,9 +744,9 @@ def test_build_closed_issue_with_user_labels_no_prs( expected_release_notes = RELEASE_NOTES_DATA_CLOSED_ISSUE_NO_PR_WITH_USER_LABELS rec = record_with_issue_closed_no_pull rec._labels = {"bug", "breaking-changes"} - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -764,9 +764,9 @@ def test_build_closed_issue_with_prs_without_user_label( rec = record_with_issue_closed_two_pulls rec._labels = {"label1", "label2"} rec.issue.title = "I1" - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -782,9 +782,9 @@ def test_build_open_pr_without_issue( ): expected_release_notes = RELEASE_NOTES_DATA_OPEN_PR_WITHOUT_ISSUE rec = pull_request_record_open - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -800,9 +800,9 @@ def test_build_merged_pr_without_issue_ready_for_review( ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_MERGED_PR_NO_ISSUE_NO_USER_LABELS rec = pull_request_record_merged - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -818,9 +818,9 @@ def test_build_closed_pr_without_issue_ready_for_review( ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_NO_USER_LABELS rec = pull_request_record_closed - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -837,9 +837,9 @@ def test_build_closed_pr_without_issue_non_draft( expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_NO_USER_LABELS rec = pull_request_record_closed rec.pull_request.draft = False - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -858,9 +858,9 @@ def test_merged_pr_without_issue_with_more_user_labels_duplicity_reduction_on( expected_release_notes = RELEASE_NOTES_DATA_MERGED_PR_WITH_USER_LABELS_DUPLICITY_REDUCTION_ON rec = pull_request_record_merged rec._labels = {"bug", "enhancement"} - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -876,9 +876,9 @@ def test_merged_pr_with_open_init_issue_mention( ): expected_release_notes = RELEASE_NOTES_DATA_MERGED_PRS_WITH_OPEN_ISSUES records = record_with_two_issue_open_two_pulls_closed - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records=records, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -894,9 +894,9 @@ def test_merged_pr_with_closed_issue_mention_without_user_labels( ): expected_release_notes = RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_MERGED_PRS_WITHOUT_USER_LABELS rec = record_with_issue_closed_one_pull - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -912,9 +912,9 @@ def test_merged_pr_with_closed_issue_mention_with_user_labels( ): expected_release_notes = RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_MERGED_PRS_WITH_USER_LABELS rec = record_with_issue_closed_one_pull_merged - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -929,9 +929,9 @@ def test_merged_pr_with_closed_issue_mention_with_user_labels_with_skip_label_on ): expected_release_notes = RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_MERGED_PRS_WITH_USER_LABELS_WITH_SKIP_LABEL rec = record_with_issue_closed_one_pull_merged_skip - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, @@ -947,9 +947,9 @@ def test_build_closed_pr_service_chapter_without_issue_with_skip_label_on_pr( ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_SKIP_USER_LABELS rec = pull_request_record_closed_with_skip_label - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - builder = DefaultReleaseNotesBuilder( + builder = ReleaseNotesBuilder( records={rec.record_id: rec}, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, diff --git a/tests/test_filter.py b/tests/test_filter.py index 6f1de1e4..7baa7b85 100644 --- a/tests/test_filter.py +++ b/tests/test_filter.py @@ -85,6 +85,6 @@ def test_filter_with_release(mocker): assert filtered_data.pull_requests[0].merged_at == datetime(2023, 2, 3) assert filtered_data.commits[0].commit.author.date == datetime(2024, 1, 4) assert ('Starting issue, prs and commit reduction by the latest release since time.',) == mock_log_info.call_args_list[0][0] - assert ('Count of issues reduced from %d to %d', 2, 1) == mock_log_debug.call_args_list[0][0] - assert ('Count of pulls reduced from %d to %d', 2, 1) == mock_log_debug.call_args_list[1][0] - assert ('Count of commits reduced from %d to %d', 2, 1) == mock_log_debug.call_args_list[2][0] + assert ('Count of issues reduced from %d to %d', 2, 1) == mock_log_debug.call_args_list[1][0] + assert ('Count of pulls reduced from %d to %d', 2, 1) == mock_log_debug.call_args_list[2][0] + assert ('Count of commits reduced from %d to %d', 2, 1) == mock_log_debug.call_args_list[3][0] diff --git a/tests/test_release_notes_generator.py b/tests/test_release_notes_generator.py index ed661883..65a39157 100644 --- a/tests/test_release_notes_generator.py +++ b/tests/test_release_notes_generator.py @@ -66,7 +66,6 @@ def test_generate_release_notes_latest_release_not_found( mock_pull_closed_with_rls_notes_102.merged_at = mock_repo.created_at + timedelta(days=7) mocker.patch("release_notes_generator.miner.DataMiner.get_latest_release", return_value=None) - mocker.patch("release_notes_generator.record.default_record_factory.get_issues_for_pr", return_value=[]) mock_rate_limit = mocker.Mock() mock_rate_limit.rate.remaining = 1000 github_mock.get_rate_limit.return_value = mock_rate_limit @@ -111,7 +110,6 @@ def test_generate_release_notes_latest_release_found_by_created_at( mock_git_release.created_at = mock_repo.created_at + timedelta(days=5) mock_git_release.published_at = mock_repo.created_at + timedelta(days=5) mocker.patch("release_notes_generator.miner.DataMiner.get_latest_release", return_value=mock_git_release) - mocker.patch("release_notes_generator.record.default_record_factory.get_issues_for_pr", return_value=[]) mock_rate_limit = mocker.Mock() mock_rate_limit.rate.remaining = 1000 @@ -163,7 +161,6 @@ def test_generate_release_notes_latest_release_found_by_published_at( mock_git_release.created_at = mock_repo.created_at + timedelta(days=5) mock_git_release.published_at = mock_repo.created_at + timedelta(days=5) mocker.patch("release_notes_generator.miner.DataMiner.get_latest_release", return_value=mock_git_release) - mocker.patch("release_notes_generator.record.default_record_factory.get_issues_for_pr", return_value=[]) mock_rate_limit = mocker.Mock() mock_rate_limit.rate.remaining = 1000 From adc7c5d77f244d2ac09301baa2f1c3385939891f Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 12 Sep 2025 15:42:06 +0200 Subject: [PATCH 07/33] - Solved two TODOs. --- main.py | 6 +++--- release_notes_generator/action_inputs.py | 10 ++++++++++ release_notes_generator/chapters/custom_chapters.py | 6 +++--- release_notes_generator/model/commit_record.py | 2 +- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/main.py b/main.py index 0d8bbeec..e173e337 100644 --- a/main.py +++ b/main.py @@ -41,9 +41,9 @@ def prepare_custom_chapters() -> CustomChapters: ActionInputs.get_chapters() ) if ActionInputs.get_regime() == ActionInputs.REGIME_ISSUE_HIERARCHY: - custom_chapters.chapters["New Epics"] = Chapter(title="New Epics") - custom_chapters.chapters["Silent Live Epics"] = Chapter(title="Silent Live") - custom_chapters.chapters["Closed Epics"] = Chapter(title="Closed Epics") + custom_chapters.chapters[f"New {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title="New Epics") + custom_chapters.chapters[f"Silent Live {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title="Silent Live") + custom_chapters.chapters[f"Closed {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title="Closed Epics") return custom_chapters diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index ba3b4fd8..ef6b8455 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -184,6 +184,16 @@ def get_issue_type_weights() -> list[str]: user_input = get_action_input("issue-type-weights", "Epic, Feature") return [item.strip() for item in user_input.split(",")] if user_input else [] + @staticmethod + def get_issue_type_first_level() -> str: + """ + Get the issue type first level from the action inputs. + """ + types = ActionInputs.get_issue_type_weights() + if len(types) == 0: + return "Epic" + return types[0] + @staticmethod def get_duplicity_scope() -> DuplicityScopeEnum: """ diff --git a/release_notes_generator/chapters/custom_chapters.py b/release_notes_generator/chapters/custom_chapters.py index f6b71092..cad4f8f8 100644 --- a/release_notes_generator/chapters/custom_chapters.py +++ b/release_notes_generator/chapters/custom_chapters.py @@ -83,15 +83,15 @@ def populate(self, records: dict[int | str, Record]) -> None: def _populate_hierarchy_issue(self, record: HierarchyIssueRecord) -> None: # detect Closed "Epic" if record.is_closed and not record.is_present_in_chapters: - ch = self.chapters["Closed Epics"] # TODO - add dynamic usage of 1st most weighted type instead of strings + ch = self.chapters[f"Closed {ActionInputs.get_issue_type_first_level()}s"] # detect New root "Epic" elif record.is_open and record.issue.created_at > self.since: - ch = self.chapters["New Epics"] + ch = self.chapters[f"New {ActionInputs.get_issue_type_first_level()}s"] # detect Silent living "Epic" else: - ch = self.chapters["Silent Live Epics"] + ch = self.chapters[f"Silent Live {ActionInputs.get_issue_type_first_level()}s"] ch.add_row(record.record_id, record.to_chapter_row()) self.populated_record_numbers_list.append(record.record_id) diff --git a/release_notes_generator/model/commit_record.py b/release_notes_generator/model/commit_record.py index 3fe666cf..f9ea2760 100644 --- a/release_notes_generator/model/commit_record.py +++ b/release_notes_generator/model/commit_record.py @@ -65,7 +65,7 @@ def to_chapter_row(self) -> str: return row def get_rls_notes(self, line_marks: Optional[list[str]] = None) -> str: - # TODO - in this version - direct commits does not support release notes + # Hint: direct commits does not support release notes return "" # methods - specific to CommitRecord From 01c5fd968733d68a6dfe5491decbf4db6bccb7d5 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 12 Sep 2025 16:17:26 +0200 Subject: [PATCH 08/33] - Fixed issue found during real runtime. --- release_notes_generator/filter.py | 2 +- .../record/factory/issue_hierarchy_record_factory.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/release_notes_generator/filter.py b/release_notes_generator/filter.py index 45105912..c88ed185 100644 --- a/release_notes_generator/filter.py +++ b/release_notes_generator/filter.py @@ -154,7 +154,7 @@ def _filter_issues_issue_hierarchy(self, data: MinedData) -> list: lambda issue: ( (issue.closed_at is not None and issue.closed_at >= data.since) or (issue.state == "open") - or (issue.type.name in issue_types) + or (issue.type is not None and issue.type.name in issue_types) ), data.issues, ) diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 9e210d43..9549c203 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -102,7 +102,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: # 3. round - the rest (e.g. Bugs, Tasks, etc.) but can be just another hierarchy - depend on configuration for hierarchy_issue in ActionInputs.get_issue_type_weights(): for issue in data.issues: - if issue.type.name == hierarchy_issue and issue.number not in registered_issues: + if issue.type is not None and issue.type.name == hierarchy_issue and issue.number not in registered_issues: self.create_record_for_issue(records, issue) registered_issues.append(issue.number) rec: HierarchyIssueRecord = cast(HierarchyIssueRecord, records[issue.number]) From 69e799aceab3bf499f84fc034f586ad54c1aafa4 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Fri, 12 Sep 2025 17:43:20 +0200 Subject: [PATCH 09/33] - Fixed form real repo run. --- .../model/hierarchy_issue_record.py | 1 + .../factory/issue_hierarchy_record_factory.py | 28 +++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index ee33be14..3e7c8b87 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -18,6 +18,7 @@ class HierarchyIssueRecord(IssueRecord): def __init__(self, issue: Issue, issue_type: Optional[str] = None, skip: bool = False, level: int = 0): super().__init__(issue, issue_type, skip=skip) + self.type: Optional[str] = None self._level: int = level self._sub_issues: dict[int, IssueRecord] = {} # sub-issues - no more sub-issues self._sub_hierarchy_issues: dict[int, HierarchyIssueRecord] = {} # sub-hierarchy issues - have sub-issues diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 9549c203..d484082d 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -19,7 +19,7 @@ """ import logging -from typing import cast +from typing import cast, Optional from github import Github from github.Issue import Issue, SubIssue @@ -102,11 +102,15 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: # 3. round - the rest (e.g. Bugs, Tasks, etc.) but can be just another hierarchy - depend on configuration for hierarchy_issue in ActionInputs.get_issue_type_weights(): for issue in data.issues: - if issue.type is not None and issue.type.name == hierarchy_issue and issue.number not in registered_issues: - self.create_record_for_issue(records, issue) + issue_type = self._get_issue_type(issue) + if issue_type is not None and issue_type == hierarchy_issue and issue.number not in registered_issues: + sub_issues = list(issue.get_sub_issues()) + if len(sub_issues) == 0: + continue # not a hierarchy issue even if labeled or issue type say so + + self.create_record_for_hierarchy_issue(records, issue, issue_type) registered_issues.append(issue.number) rec: HierarchyIssueRecord = cast(HierarchyIssueRecord, records[issue.number]) - sub_issues = list(rec.issue.get_sub_issues()) if len(sub_issues) > 0: self._solve_sub_issues(rec, data, registered_issues, sub_issues) @@ -195,7 +199,7 @@ def register_commit_to_record(records: dict[int | str, Record], commit: Commit) return False @staticmethod - def create_record_for_issue(records: dict[int | str, Record], i: Issue) -> None: + def create_record_for_hierarchy_issue(records: dict[int | str, Record], i: Issue, issue_type: Optional[str]) -> None: """ Create a record for an issue. @@ -205,6 +209,18 @@ def create_record_for_issue(records: dict[int | str, Record], i: Issue) -> None: # check for skip labels presence and skip when detected issue_labels = [label.name for label in i.get_labels()] skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) - records[i.number] = IssueRecord(issue=i, skip=skip_record) + records[i.number] = HierarchyIssueRecord(issue=i, issue_type=issue_type, skip=skip_record) logger.debug("Created record for issue %d: %s", i.number, i.title) + + @staticmethod + def _get_issue_type(issue: Issue) -> Optional[str]: + if issue.type is not None: + return issue.type.name + if issue.labels is not None: + issue_labels = [label.name for label in issue.get_labels()] + for label in issue_labels: + if label in ActionInputs.get_issue_type_weights(): + return label + # TODO - check multiple labels, not just first - select the highest weight one + return None From 9d793dfde919875689c7fb581977c5e65f4bf321 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 15 Sep 2025 10:24:09 +0200 Subject: [PATCH 10/33] - Implemented Hierarchy in rls notes output. Service chapters will be expanded. --- Unify-default.md | 135 ++++++++++++++ Unify-issue-hierarchy-v1.md | 169 ++++++++++++++++++ Unify-issue-hierarchy-v2.md | 147 +++++++++++++++ Unify-issue-hierarchy-v3.md | 141 +++++++++++++++ main.py | 6 +- .../chapters/custom_chapters.py | 4 +- .../model/commit_record.py | 8 +- .../model/hierarchy_issue_record.py | 65 ++++++- release_notes_generator/model/issue_record.py | 25 ++- .../model/pull_request_record.py | 13 +- release_notes_generator/model/record.py | 19 +- .../factory/issue_hierarchy_record_factory.py | 158 +++++++++------- .../utils/pull_request_utils.py | 7 + 13 files changed, 802 insertions(+), 95 deletions(-) create mode 100644 Unify-default.md create mode 100644 Unify-issue-hierarchy-v1.md create mode 100644 Unify-issue-hierarchy-v2.md create mode 100644 Unify-issue-hierarchy-v3.md diff --git a/Unify-default.md b/Unify-default.md new file mode 100644 index 00000000..2c93447b --- /dev/null +++ b/Unify-default.md @@ -0,0 +1,135 @@ +### Breaking Changes 💥 +No entries detected. + +### New Features 🎉 +- #3186 _Improve authorization in FeedManagement context_ in #3701 + - Improves and optimizes the authorization in the feed management context. +- #3207 _Allow Unify app admins and data ops roles to run TriggerManualExecutionCommand_ in #3762 + - Added app-admins and data-ops to allowed users +- PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ + - Improves and optimizes the authorization in the feed management context. +- #3470 _FE: Ability to delete draft/testing version_ in #3663 +- #3479 _Implement model layer domain major versions merge and mergeablility functionality_ in #3633 + - Introduces functionality related to merging of domain major versions to Domain model layer +- #3481 _Implement `MergeBackDomainMajorVersionCommand` handler_ in #3666 + - Introduces `MergeBackDomainMajorVersionCommandHandler` in domain-management bounded context + +### Bugfixes 🛠 +- #3637 _Partitions fetched from the glue catalog are not imported correctly_ in #3699 + - Fix importing partitions from glue table +- #3644 _Model validation failing after removal of non-latest major version_ in #3645 +- #3683 _Owners and stewards cannot edit domain through UI if change involves PatchDomainCommand_ in #3692 + - fixed domain secondary owners and stewards not being able to edit certain domain properties through UI even though they should be authorized to do so (like description), by not performing authorization on `PatchDomainCommand` parts that didn't change but were sent in the request anyway +- #3714 _SchemaCompatibilityChecker should return incompatible when columns are being removed._ in #3733 + - Currently the SchemaCompatibilityChecker returns compatible if fields are removed but this can be seen as a breaking change to existing schema's, therefore it has been changed to return incompatible when fields are removed. +- #3726 _Output partition column is missing after editing target schema_ in #3743 + - Bug Fix: fix output partition column disappearing after editing target schema and add a dedicated Cypress scenario to prevent regressions. +- #3750 _Final DF is not cached in the Spark Job_ in #3751 + - removed cache on primary dataset DF and added one on final (post-interpretation) DF to improve dual-write performence +- #3756 _DB: Missing DB function permissions (ownership / execution)_ in #3757 + +### Closed Issues without Pull Request ⚠️ +- #3226 _`TriggerManualExecutionCommand` should return actual `executionID` not a random UUID_ in +- #3281 _Take into account Cats Effect Runtime metrics in health endpoint_ in +- #3456 _Domain management - Delete domain version_ in +- #3580 _Sending events about runs to EventBus_ in +- #3608 _Make GET /api-v2/domain-execution/queries/job-error-details/{jobID} more universal_ in +- #3609 _Adjust dependencies of original GET /api-v2/domain-execution/queries/job-error-details/{jobID} to the new one_ in +- #3617 _PWD Rotation [dev] - Pwd change_ in +- #3618 _PWD Rotation [dev] - keytabs regeneration_ in +- #3620 _Create an ADR for Event-based scheduling of domains_ in +- #3622 _Consult an ADR about event-based domain scheduling with the team for having the consensus_ in +- #3638 _Add location type into GetJobRunsResult and GetJobErrorDetailsResult_ in +- #3643 _Create UAT+PROD ldap-svc records_ in +- #3660 _Migrate hive-keytab usage to password-rotation compatible location_ in +- #3661 _Employ password/keytab rotation solution_ in +- #3662 _Create a wokflow integrating rotation step_ in +- #3665 _KarinKersten access to Curation and Consumption stuff_ in +- #3667 _Solve 'SMTP Lock Down' for Unify_ in +- #3693 _setup hive-service account params in param store to be rotated_ in +- #3700 _Migrate Unify EC2 instances to Graviton_ in +- #3702 _Implement DB function that resets the domain freshness_ in +- #3741 _Manual rotation of `svc-ursa-unify-prd` and `svc-ursa-aul-dev`_ in + +### Closed Issues without User Defined Labels ⚠️ +- #2836 _Fix ESLint config _ in #3731 +- #3300 _Add `executionID` match assertions to domain major versions flow API tests_ in #3728, #3523 + - added tests in getJobs to assert executionID returned in response is evaluated to match actual vs expected + - Timeout increased to wait few mins for eventual consistency +- #3483 _Handle `DomainMajorVersionMergedBackEvent` event in data-cataloging_ in #3696 + - Add and implement new service: `updateDomainMajorVersionLiveTables` + - Add Appropriate controller endpoint for the above + - Utilize the above in domainmajorversionmergedbackevent handler to update tables in place + - Truncate and delete merging data after + - implement tetst +- #3485 _Handle `DomainMajorVersionMergedBackEvent` in feed-management_ in #3573 + - Introduces `DomainMajorVersionMergedBackEventHandler` to handle `DomainMajorVersionMergedBackEvent` + - Adds `feed_management.update_feeds_for_merged_domain_major_versions` plpgsql function that updates feed versions for domain major versions related to merge operation +- #3555 _Update Login e2e test_ in #3604 +- 🔔 #3608 _Make GET /api-v2/domain-execution/queries/job-error-details/{jobID} more universal_ in +- 🔔 #3609 _Adjust dependencies of original GET /api-v2/domain-execution/queries/job-error-details/{jobID} to the new one_ in +- 🔔 #3617 _PWD Rotation [dev] - Pwd change_ in +- 🔔 #3618 _PWD Rotation [dev] - keytabs regeneration_ in +- #3621 _Create an ADR for event-based domain scheduling_ in #3636 +- 🔔 #3622 _Consult an ADR about event-based domain scheduling with the team for having the consensus_ in +- #3630 _Remove redundant DTOs from DE bounded ctx_ in #3631 +- 🔔 #3638 _Add location type into GetJobRunsResult and GetJobErrorDetailsResult_ in +- #3640 _Migrate LDAP account to rotatable ssm param object_ in #3670, #3641 + - Unify LDAP service account credentials are now loaded from /datassets/{aul-dev|aul-uat|unify}/service-accounts/ad-service-account-credentials. + - Unify LDAP service account credentials are now loaded from `/datassets/aul-dev/ad-service-account-credentials`. +- 🔔 #3643 _Create UAT+PROD ldap-svc records_ in +- #3646 _Update Dashboard system test_ in #3668 +- #3647 _Update Data-feed system test_ in #3673 +- 🔔 #3660 _Migrate hive-keytab usage to password-rotation compatible location_ in +- 🔔 #3661 _Employ password/keytab rotation solution_ in +- 🔔 #3662 _Create a wokflow integrating rotation step_ in +- 🔔 #3665 _KarinKersten access to Curation and Consumption stuff_ in +- 🔔 #3667 _Solve 'SMTP Lock Down' for Unify_ in +- #3678 _Remove typescript files from /assets folder_ in #3677 +- #3680 _DEV+UAT to share common non-prod service accounts space (config change)_ in #3681 + - Unify LDAP service account credentials for UAT and DEV are now loaded from `/datassets/aul-nonprod/service-accounts/ad-service-account-credentials`. +- #3690 _Implement DDL for the state management_ in #3711 +- #3691 _Implement DB function for the state management_ in #3729 + * EventBus integration: implementing state management DB functions for reacting on incoming events and changes in domain automatic triggering +- 🔔 #3693 _setup hive-service account params in param store to be rotated_ in +- 🔔 #3700 _Migrate Unify EC2 instances to Graviton_ in +- 🔔 #3702 _Implement DB function that resets the domain freshness_ in +- #3703 _Implement BE DTO & API that will represent `ProcessAvailableDatasetCommand`_ in #3713 + - added `ProcessAvailableDatasetCommand` API (without implementation) +- #3705 _Make `DataSource#location` an ADT instead of String_ in #3715 + - Introduces possibility to define location of a data source by either using Glue table or S3 access point. +- #3716 _Add and implement `GetDomainMajorVersionDefaultDataSourcesQuery`_ in #3763 + - added `GetDomainMajorVersionDefaultDataSourcesQuery` which returns all data sources used in latest `Ready` feed versions for a given domain major version +- #3719 _Add event based scheduling option to `Schedule` DTO_ in #3727 + - added Schedule.DailyWindowEventBasedSchedule option of Schedule in domain-execution API +- #3721 _Implement DB function to upsert scheduling in EBS DB tables for a domain major version_ in #3760 +- #3722 _Implement DB function to remove domain major version from EBS DB tables_ in #3759 +- #3730 _Add an ESLint rule to check for skipped/focused unit tests_ in #3731 +- #3740 _svc-ursa-unify accounts rotation support_ in #3748 + - webapp-svc-account credentials are now loaded from `/datassets/{aul-nonprod|unify}/service-accounts/spring-service-account-credentials` +- 🔔 #3741 _Manual rotation of `svc-ursa-unify-prd` and `svc-ursa-aul-dev`_ in + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #3639 _#3638 Add location type to runs service, DB, and UI_ + - added location type (Live/Testing) for which a run was triggered to related endpoints and UI view +- PR: #3732 _Add UI team ownership for ci-ui-checks.yml_ +- PR: #3739 _rename db file_ +- PR: #3752 _Add missing MergeBackDomainMajorVersionCommand to AuthorizationManager_ +- PR: #3753 _Fix order of arguments in DomainMajorVersionMergedBack_ +- PR: #3761 _api tests fixes_ + +### Closed PRs without Issue and User Defined Labels ⚠️ +All closed PRs are linked to issues. + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +- #3571 _Update System tests_ in #3634 + +### Direct commits ⚠️ +All direct commits are linked pull requests. + +### Others - No Topic ⚠️ +- 🔔 PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ + - Improves and optimizes the authorization in the feed management context. + +#### Full Changelog +https://github.com/absa-group/AUL/compare/v1.2.0...v1.3.0 diff --git a/Unify-issue-hierarchy-v1.md b/Unify-issue-hierarchy-v1.md new file mode 100644 index 00000000..d8c36812 --- /dev/null +++ b/Unify-issue-hierarchy-v1.md @@ -0,0 +1,169 @@ +### Breaking Changes 💥 +No entries detected. + +### New Features 🎉 +- PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ + - Improves and optimizes the authorization in the feed management context. +- #3470 _FE: Ability to delete draft/testing version_ in #3663 +- #3479 _Implement model layer domain major versions merge and mergeablility functionality_ in #3633 + - Introduces functionality related to merging of domain major versions to Domain model layer +- #3481 _Implement `MergeBackDomainMajorVersionCommand` handler_ in #3666 + - Introduces `MergeBackDomainMajorVersionCommandHandler` in domain-management bounded context + +### Bugfixes 🛠 +- #3637 _Partitions fetched from the glue catalog are not imported correctly_ in #3699 + - Fix importing partitions from glue table +- #3644 _Model validation failing after removal of non-latest major version_ in #3645 +- #3683 _Owners and stewards cannot edit domain through UI if change involves PatchDomainCommand_ in #3692 + - fixed domain secondary owners and stewards not being able to edit certain domain properties through UI even though they should be authorized to do so (like description), by not performing authorization on `PatchDomainCommand` parts that didn't change but were sent in the request anyway +- #3714 _SchemaCompatibilityChecker should return incompatible when columns are being removed._ in #3733 + - Currently the SchemaCompatibilityChecker returns compatible if fields are removed but this can be seen as a breaking change to existing schema's, therefore it has been changed to return incompatible when fields are removed. +- #3726 _Output partition column is missing after editing target schema_ in #3743 + - Bug Fix: fix output partition column disappearing after editing target schema and add a dedicated Cypress scenario to prevent regressions. +- #3750 _Final DF is not cached in the Spark Job_ in #3751 + - removed cache on primary dataset DF and added one on final (post-interpretation) DF to improve dual-write performence +- #3756 _DB: Missing DB function permissions (ownership / execution)_ in #3757 + +### New Epics +- Feature: _Promote from Pre-prod to prod and copy from Prod to IJAT_ #3655 +- Feature: _Add SQL function: Sum_ #3656 +- Feature: _Create a function creating timestamp from date and time representing column_ #3657 +- Feature: _Rollback feature for a domain_ #3658 +- Feature: _'Save As' capability for copying existing domains_ #3659 +- Feature: _Add SQL Function: Dense_Rank_ #3671 +- Feature: _Use Glue Tables explicitly as feed data sources_ #3674 +- Epic: _Add Iceberg support (AWS-only)_ #3675 +- Feature: _Add support for Iceberg tables as sources (from AWS)_ #3679 +- Feature: _Add support for writing Iceberg tables (AWS)_ #3685 +- Feature: _Marking dataset ready for the current day and triggering runs_ #3686 +- Feature: _`runs` topic events consumption_ #3687 +- Feature: _Event-based scheduling mode selection_ #3688 +- Feature: _Exclusion of data sources from the scheduling_ #3689 +- Feature: _Technical debt in Feed management DB functions_ #3695 +- Feature: _Send only changed properties in PatchDomainCommand from the UI_ #3697 +- Feature: _Add retry mechanism to domain major versions flow API tests_ #3734 +- Feature: _Delete created domain after API and E2E tests_ #3749 +- Feature: _Domain Major Schema / Output Partitioning Change: Check compatibility of all Feed Versions, not just the latest one_ #3758 + +### Silent Live +- Epic: _Make formula builder more user-friendly_ #1188 +- Epic: _Group By + Aggregations after Join(s)_ #1668 +- Epic: _Add automation to GitHub project_ #1674 +- Epic: _Implement data controls for foundation domains_ #1857 +- Epic: _UI unit tests_ #2319 +- Epic: _Update Cypress tests_ #2847 +- Epic: _Manual once-off run for a range of dates_ #2873 +- Epic: _Allow cross-account granting & revoking READ access to domain Glue tables_ #3006 +- Feature: _FM API: initialize_new_feed_version: think about a better name potentially - API, DB function, server part_ #3141 +- Feature: _Listing AWS accounts with access to a domain's Glue table_ #3246 +- Feature: _Database changes to store and record the desired stats._ #3264 +- Feature: _Create a REST API endpoint returning the overall stats_ #3265 +- Feature: _Change the landing page to display the overall stats_ #3266 +- Feature: _Manually add Glue & Hive partitions when possible_ #3284 +- Feature: _Allow active domain major version compatible schema evolution_ #3292 +- Feature: _Country code selection for ARO_ #3297 +- Feature: _Domain Major Version made Cold - Archive data into S3 Glacier to save some S3 storage costs_ #3299 +- Feature: _Add Execution ID into Plugins, for Atum Partitioning and later Checkpoints_ #3317 +- Feature: _Feed Management: Remove redundant Domain Major Version Attribute from some DTOs and Queries_ #3373 +- Feature: _Feed management - Delete data feed version_ #3457 +- Feature: _Data feed rollback_ #3473 +- Feature: _Change domain status from COLD to ACTIVE._ #3474 +- Epic: _Improve UI testing_ #3498 +- Feature: _Revoking readonly access for domain's Glue tables to AWS account_ #3525 +- Epic: _Reduce Aquasec Findings for Unify repos_ #3539 +- Feature: _Consumption of Atum measurements via odbc/jdbc and API_ #3544 +- Feature: _Domain: add created by information on the UI_ #3553 +- Epic: _Update System tests_ #3571 +- Feature: _Extend grid view to include elapsed runtime_ #3598 +- Feature: _Access Control Mgmt. - Automated PWD Rotation_ #3599 +- Feature: _Trigger manual execution for multiple domains - batch execute_ #3600 +- Feature: _PWD Rotation [dev] - keytab replacement and unify service rotation_ #3619 + +### Closed Epics +- Feature: _Improve authorization in FeedManagement context_ #3186 + - _Release Notes_: + - Improves and optimizes the authorization in the feed management context. +- Feature: _Allow Unify app admins and data ops roles to run TriggerManualExecutionCommand_ #3207 + - _Release Notes_: + - Added app-admins and data-ops to allowed users +- Feature: _Take into account Cats Effect Runtime metrics in health endpoint_ #3281 +- Feature: _Include error message in failed runs endpoint and view_ #3426 +- Feature: _Domain management - Delete domain version_ #3456 +- Feature: _Sending events about runs to EventBus_ #3580 +- Feature: _Create an ADR for Event-based scheduling of domains_ #3620 + +### Closed Issues without Pull Request ⚠️ +- 🔔 Feature: _Take into account Cats Effect Runtime metrics in health endpoint_ #3281 +- 🔔 Feature: _Include error message in failed runs endpoint and view_ #3426 +- 🔔 Feature: _Domain management - Delete domain version_ #3456 +- 🔔 Feature: _Sending events about runs to EventBus_ #3580 +- 🔔 Feature: _Create an ADR for Event-based scheduling of domains_ #3620 + +### Closed Issues without User Defined Labels ⚠️ +- #2836 _Fix ESLint config _ in #3731 +- #3300 _Add `executionID` match assertions to domain major versions flow API tests_ in #3728, #3523 + - added tests in getJobs to assert executionID returned in response is evaluated to match actual vs expected + - Timeout increased to wait few mins for eventual consistency +- 🔔 Feature: _Include error message in failed runs endpoint and view_ #3426 +- #3483 _Handle `DomainMajorVersionMergedBackEvent` event in data-cataloging_ in #3696 + - Add and implement new service: `updateDomainMajorVersionLiveTables` + - Add Appropriate controller endpoint for the above + - Utilize the above in domainmajorversionmergedbackevent handler to update tables in place + - Truncate and delete merging data after + - implement tetst +- #3485 _Handle `DomainMajorVersionMergedBackEvent` in feed-management_ in #3573 + - Introduces `DomainMajorVersionMergedBackEventHandler` to handle `DomainMajorVersionMergedBackEvent` + - Adds `feed_management.update_feeds_for_merged_domain_major_versions` plpgsql function that updates feed versions for domain major versions related to merge operation +- #3555 _Update Login e2e test_ in #3604 +- #3621 _Create an ADR for event-based domain scheduling_ in #3636 +- #3630 _Remove redundant DTOs from DE bounded ctx_ in #3631 +- #3640 _Migrate LDAP account to rotatable ssm param object_ in #3670, #3641 + - Unify LDAP service account credentials are now loaded from /datassets/{aul-dev|aul-uat|unify}/service-accounts/ad-service-account-credentials. + - Unify LDAP service account credentials are now loaded from `/datassets/aul-dev/ad-service-account-credentials`. +- #3646 _Update Dashboard system test_ in #3668 +- #3647 _Update Data-feed system test_ in #3673 +- #3678 _Remove typescript files from /assets folder_ in #3677 +- #3680 _DEV+UAT to share common non-prod service accounts space (config change)_ in #3681 + - Unify LDAP service account credentials for UAT and DEV are now loaded from `/datassets/aul-nonprod/service-accounts/ad-service-account-credentials`. +- #3690 _Implement DDL for the state management_ in #3711 +- #3691 _Implement DB function for the state management_ in #3729 + * EventBus integration: implementing state management DB functions for reacting on incoming events and changes in domain automatic triggering +- #3703 _Implement BE DTO & API that will represent `ProcessAvailableDatasetCommand`_ in #3713 + - added `ProcessAvailableDatasetCommand` API (without implementation) +- #3705 _Make `DataSource#location` an ADT instead of String_ in #3715 + - Introduces possibility to define location of a data source by either using Glue table or S3 access point. +- #3716 _Add and implement `GetDomainMajorVersionDefaultDataSourcesQuery`_ in #3763 + - added `GetDomainMajorVersionDefaultDataSourcesQuery` which returns all data sources used in latest `Ready` feed versions for a given domain major version +- #3719 _Add event based scheduling option to `Schedule` DTO_ in #3727 + - added Schedule.DailyWindowEventBasedSchedule option of Schedule in domain-execution API +- #3721 _Implement DB function to upsert scheduling in EBS DB tables for a domain major version_ in #3760 +- #3722 _Implement DB function to remove domain major version from EBS DB tables_ in #3759 +- #3730 _Add an ESLint rule to check for skipped/focused unit tests_ in #3731 +- #3740 _svc-ursa-unify accounts rotation support_ in #3748 + - webapp-svc-account credentials are now loaded from `/datassets/{aul-nonprod|unify}/service-accounts/spring-service-account-credentials` +- #3768 _Unify: Add business value chapter to issue templates_ in #3769 + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #3639 _#3638 Add location type to runs service, DB, and UI_ + - added location type (Live/Testing) for which a run was triggered to related endpoints and UI view +- PR: #3732 _Add UI team ownership for ci-ui-checks.yml_ +- PR: #3739 _rename db file_ +- PR: #3752 _Add missing MergeBackDomainMajorVersionCommand to AuthorizationManager_ +- PR: #3753 _Fix order of arguments in DomainMajorVersionMergedBack_ +- PR: #3761 _api tests fixes_ + +### Closed PRs without Issue and User Defined Labels ⚠️ +All closed PRs are linked to issues. + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +- 🔔 Epic: _Update System tests_ #3571 + +### Direct commits ⚠️ +All direct commits are linked pull requests. + +### Others - No Topic ⚠️ +- 🔔 PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ + - Improves and optimizes the authorization in the feed management context. + +#### Full Changelog +https://github.com/absa-group/AUL/compare/v1.2.0...v1.3.0 diff --git a/Unify-issue-hierarchy-v2.md b/Unify-issue-hierarchy-v2.md new file mode 100644 index 00000000..6f8f91a0 --- /dev/null +++ b/Unify-issue-hierarchy-v2.md @@ -0,0 +1,147 @@ +### No entry 🚫 +No entries detected. + +### Breaking Changes 💥 +No entries detected. + +### New Features 🎉 +- #3186 _Improve authorization in FeedManagement context_ in #3701 + - Improves and optimizes the authorization in the feed management context. +- #3207 _Allow Unify app admins and data ops roles to run TriggerManualExecutionCommand_ in #3762 + - Added app-admins and data-ops to allowed users +- PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ + - Improves and optimizes the authorization in the feed management context. +- #3470 _FE: Ability to delete draft/testing version_ in #3663 +- #3479 _Implement model layer domain major versions merge and mergeablility functionality_ in #3633 + - Introduces functionality related to merging of domain major versions to Domain model layer +- #3481 _Implement `MergeBackDomainMajorVersionCommand` handler_ in #3666 + - Introduces `MergeBackDomainMajorVersionCommandHandler` in domain-management bounded context + +### Bugfixes 🛠 +- #3637 _Partitions fetched from the glue catalog are not imported correctly_ in #3699 + - Fix importing partitions from glue table +- #3644 _Model validation failing after removal of non-latest major version_ in #3645 +- #3683 _Owners and stewards cannot edit domain through UI if change involves PatchDomainCommand_ in #3692 + - fixed domain secondary owners and stewards not being able to edit certain domain properties through UI even though they should be authorized to do so (like description), by not performing authorization on `PatchDomainCommand` parts that didn't change but were sent in the request anyway +- #3714 _SchemaCompatibilityChecker should return incompatible when columns are being removed._ in #3733 + - Currently the SchemaCompatibilityChecker returns compatible if fields are removed but this can be seen as a breaking change to existing schema's, therefore it has been changed to return incompatible when fields are removed. +- #3726 _Output partition column is missing after editing target schema_ in #3743 + - Bug Fix: fix output partition column disappearing after editing target schema and add a dedicated Cypress scenario to prevent regressions. +- #3750 _Final DF is not cached in the Spark Job_ in #3751 + - removed cache on primary dataset DF and added one on final (post-interpretation) DF to improve dual-write performence +- #3756 _DB: Missing DB function permissions (ownership / execution)_ in #3757 + +### Infrastructure ⚙️ +- PR: #3732 _Add UI team ownership for ci-ui-checks.yml_ +- #3768 _Unify: Add business value chapter to issue templates_ in #3769 + +### Silent-live 🤫 +No entries detected. + +### Documentation 📜 +- #3621 _Create an ADR for event-based domain scheduling_ in #3636 + +### New Epics +- Feature: _Promote from Pre-prod to prod and copy from Prod to IJAT_ #3655 +- Feature: _Use Glue Tables explicitly as feed data sources_ #3674 +- Feature: _Marking dataset ready for the current day and triggering runs_ #3686 +- Feature: _`runs` topic events consumption_ #3687 +- Feature: _Event-based scheduling mode selection_ #3688 +- Feature: _Exclusion of data sources from the scheduling_ #3689 + +### Silent Live +- Epic: _Make formula builder more user-friendly_ #1188 +- Epic: _Group By + Aggregations after Join(s)_ #1668 +- Epic: _Add automation to GitHub project_ #1674 +- Epic: _Implement data controls for foundation domains_ #1857 +- Epic: _UI unit tests_ #2319 +- Epic: _Update Cypress tests_ #2847 +- Epic: _Manual once-off run for a range of dates_ #2873 +- Epic: _Allow cross-account granting & revoking READ access to domain Glue tables_ #3006 +- Feature: _Listing AWS accounts with access to a domain's Glue table_ #3246 +- Feature: _Manually add Glue & Hive partitions when possible_ #3284 +- Feature: _Allow active domain major version compatible schema evolution_ #3292 +- Feature: _Country code selection for ARO_ #3297 +- Feature: _Feed management - Delete data feed version_ #3457 +- Epic: _Improve UI testing_ #3498 +- Feature: _Revoking readonly access for domain's Glue tables to AWS account_ #3525 +- Epic: _Update System tests_ #3571 +- Feature: _PWD Rotation [dev] - keytab replacement and unify service rotation_ #3619 + +### Closed Epics +- Feature: _Include error message in failed runs endpoint and view_ #3426 +- Feature: _Domain management - Delete domain version_ #3456 +- Feature: _Sending events about runs to EventBus_ #3580 +- Feature: _Create an ADR for Event-based scheduling of domains_ #3620 + +### Closed Issues without Pull Request ⚠️ +- 🔔 Feature: _Include error message in failed runs endpoint and view_ #3426 +- 🔔 Feature: _Domain management - Delete domain version_ #3456 +- 🔔 Feature: _Sending events about runs to EventBus_ #3580 +- 🔔 Feature: _Create an ADR for Event-based scheduling of domains_ #3620 + +### Closed Issues without User Defined Labels ⚠️ +- #2836 _Fix ESLint config _ in #3731 +- #3300 _Add `executionID` match assertions to domain major versions flow API tests_ in #3728, #3523 + - added tests in getJobs to assert executionID returned in response is evaluated to match actual vs expected + - Timeout increased to wait few mins for eventual consistency +- 🔔 Feature: _Include error message in failed runs endpoint and view_ #3426 +- #3483 _Handle `DomainMajorVersionMergedBackEvent` event in data-cataloging_ in #3696 + - Add and implement new service: `updateDomainMajorVersionLiveTables` + - Add Appropriate controller endpoint for the above + - Utilize the above in domainmajorversionmergedbackevent handler to update tables in place + - Truncate and delete merging data after + - implement tetst +- #3485 _Handle `DomainMajorVersionMergedBackEvent` in feed-management_ in #3573 + - Introduces `DomainMajorVersionMergedBackEventHandler` to handle `DomainMajorVersionMergedBackEvent` + - Adds `feed_management.update_feeds_for_merged_domain_major_versions` plpgsql function that updates feed versions for domain major versions related to merge operation +- #3555 _Update Login e2e test_ in #3604 +- #3630 _Remove redundant DTOs from DE bounded ctx_ in #3631 +- #3640 _Migrate LDAP account to rotatable ssm param object_ in #3670, #3641 + - Unify LDAP service account credentials are now loaded from /datassets/{aul-dev|aul-uat|unify}/service-accounts/ad-service-account-credentials. + - Unify LDAP service account credentials are now loaded from `/datassets/aul-dev/ad-service-account-credentials`. +- #3646 _Update Dashboard system test_ in #3668 +- #3647 _Update Data-feed system test_ in #3673 +- #3678 _Remove typescript files from /assets folder_ in #3677 +- #3680 _DEV+UAT to share common non-prod service accounts space (config change)_ in #3681 + - Unify LDAP service account credentials for UAT and DEV are now loaded from `/datassets/aul-nonprod/service-accounts/ad-service-account-credentials`. +- #3690 _Implement DDL for the state management_ in #3711 +- #3691 _Implement DB function for the state management_ in #3729 + * EventBus integration: implementing state management DB functions for reacting on incoming events and changes in domain automatic triggering +- #3703 _Implement BE DTO & API that will represent `ProcessAvailableDatasetCommand`_ in #3713 + - added `ProcessAvailableDatasetCommand` API (without implementation) +- #3705 _Make `DataSource#location` an ADT instead of String_ in #3715 + - Introduces possibility to define location of a data source by either using Glue table or S3 access point. +- #3716 _Add and implement `GetDomainMajorVersionDefaultDataSourcesQuery`_ in #3763 + - added `GetDomainMajorVersionDefaultDataSourcesQuery` which returns all data sources used in latest `Ready` feed versions for a given domain major version +- #3719 _Add event based scheduling option to `Schedule` DTO_ in #3727 + - added Schedule.DailyWindowEventBasedSchedule option of Schedule in domain-execution API +- #3721 _Implement DB function to upsert scheduling in EBS DB tables for a domain major version_ in #3760 +- #3722 _Implement DB function to remove domain major version from EBS DB tables_ in #3759 +- #3730 _Add an ESLint rule to check for skipped/focused unit tests_ in #3731 +- #3740 _svc-ursa-unify accounts rotation support_ in #3748 + - webapp-svc-account credentials are now loaded from `/datassets/{aul-nonprod|unify}/service-accounts/spring-service-account-credentials` + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #3639 _#3638 Add location type to runs service, DB, and UI_ + - added location type (Live/Testing) for which a run was triggered to related endpoints and UI view +- PR: #3739 _rename db file_ +- PR: #3752 _Add missing MergeBackDomainMajorVersionCommand to AuthorizationManager_ +- PR: #3753 _Fix order of arguments in DomainMajorVersionMergedBack_ +- PR: #3761 _api tests fixes_ + +### Closed PRs without Issue and User Defined Labels ⚠️ +All closed PRs are linked to issues. + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +- 🔔 Epic: _Update System tests_ #3571 + +### Direct commits ⚠️ +All direct commits are linked pull requests. + +### Others - No Topic ⚠️ +- 🔔 PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ + - Improves and optimizes the authorization in the feed management context. + +#### Full Changelog +https://github.com/absa-group/AUL/compare/v1.2.0...v1.3.0 diff --git a/Unify-issue-hierarchy-v3.md b/Unify-issue-hierarchy-v3.md new file mode 100644 index 00000000..de5d8448 --- /dev/null +++ b/Unify-issue-hierarchy-v3.md @@ -0,0 +1,141 @@ +### No entry 🚫 +No entries detected. + +### Breaking Changes 💥 +No entries detected. + +### New Features 🎉 +- PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ + - Improves and optimizes the authorization in the feed management context. + +### Bugfixes 🛠 +No entries detected. + +### Infrastructure ⚙️ +No entries detected. + +### Silent-live 🤫 +No entries detected. + +### Documentation 📜 +No entries detected. + +### New Epics +- Feature: _Promote from Pre-prod to prod and copy from Prod to IJAT_ #3655 + +### Silent Live Epics +- Epic: _Make formula builder more user-friendly_ #1188 +- Epic: _Implement some of the missing info about individual domains_ #1343 +- Epic: _Group By + Aggregations after Join(s)_ #1668 +- Epic: _Add automation to GitHub project_ #1674 +- Epic: _Implement data controls for foundation domains_ #1857 +- Epic: _UI unit tests_ #2319 +- Epic: _Update Cypress tests_ #2847 + - #3555 _Update Login e2e test_ in #3604 +- Epic: _Manual once-off run for a range of dates_ #2873 +- Epic: _Support AD security group access assignment for domain roles and consumers_ #2883 +- Epic: _Event-based scheduling MVP _ #2960 + - Feature: _Sending events about runs to EventBus_ #3580 + - #3638 _Add location type into GetJobRunsResult and GetJobErrorDetailsResult_ in + - Feature: _Create an ADR for Event-based scheduling of domains_ #3620 + - #3621 _Create an ADR for event-based domain scheduling_ in #3636 + - #3622 _Consult an ADR about event-based domain scheduling with the team for having the consensus_ in + - Feature: _Use Glue Tables explicitly as feed data sources_ #3674 + - #3705 _Make `DataSource#location` an ADT instead of String_ in #3715 + - Introduces possibility to define location of a data source by either using Glue table or S3 access point. + - Feature: _Marking dataset ready for the current day and triggering runs_ #3686 + - #3690 _Implement DDL for the state management_ in #3711 + - #3691 _Implement DB function for the state management_ in #3729 + * EventBus integration: implementing state management DB functions for reacting on incoming events and changes in domain automatic triggering + - #3702 _Implement DB function that resets the domain freshness_ in + - #3703 _Implement BE DTO & API that will represent `ProcessAvailableDatasetCommand`_ in #3713 + - added `ProcessAvailableDatasetCommand` API (without implementation) + - Feature: _Event-based scheduling mode selection_ #3688 + - #3716 _Add and implement `GetDomainMajorVersionDefaultDataSourcesQuery`_ in #3763 + - added `GetDomainMajorVersionDefaultDataSourcesQuery` which returns all data sources used in latest `Ready` feed versions for a given domain major version + - #3719 _Add event based scheduling option to `Schedule` DTO_ in #3727 + - added Schedule.DailyWindowEventBasedSchedule option of Schedule in domain-execution API + - #3721 _Implement DB function to upsert scheduling in EBS DB tables for a domain major version_ in #3760 + - #3722 _Implement DB function to remove domain major version from EBS DB tables_ in #3759 + - Feature: _`runs` topic events consumption_ #3687 + - Feature: _Exclusion of data sources from the scheduling_ #3689 +- Epic: _Allow cross-account granting & revoking READ access to domain Glue tables_ #3006 + - Feature: _Listing AWS accounts with access to a domain's Glue table_ #3246 + - Feature: _Revoking readonly access for domain's Glue tables to AWS account_ #3525 +- Epic: _Solution for externalized configs_ #3201 +- Epic: _Send execution completion events to an tenant-specified SQS queue_ #3203 +- Epic: _API 1-shot test - reach 100% coverage_ #3206 +- Epic: _Setup Glue jobs and enable running the Spark compute engine on Glue_ #3219 +- Epic: _Enable ARO Data segregation - RTSIS (Tanzania ABT requirement)_ #3251 + - Feature: _Country code selection for ARO_ #3297 +- Epic: _Display overall stats on the landing page_ #3262 +- Feature: _Manually add Glue & Hive partitions when possible_ #3284 +- Feature: _Allow active domain major version compatible schema evolution_ #3292 + - #3479 _Implement model layer domain major versions merge and mergeablility functionality_ in #3633 + - Introduces functionality related to merging of domain major versions to Domain model layer + - #3481 _Implement `MergeBackDomainMajorVersionCommand` handler_ in #3666 + - Introduces `MergeBackDomainMajorVersionCommandHandler` in domain-management bounded context + - #3483 _Handle `DomainMajorVersionMergedBackEvent` event in data-cataloging_ in #3696 + - Add and implement new service: `updateDomainMajorVersionLiveTables` + - Add Appropriate controller endpoint for the above + - Utilize the above in domainmajorversionmergedbackevent handler to update tables in place + - Truncate and delete merging data after + - implement tetst + - #3485 _Handle `DomainMajorVersionMergedBackEvent` in feed-management_ in #3573 + - Introduces `DomainMajorVersionMergedBackEventHandler` to handle `DomainMajorVersionMergedBackEvent` + - Adds `feed_management.update_feeds_for_merged_domain_major_versions` plpgsql function that updates feed versions for domain major versions related to merge operation + - #3714 _SchemaCompatibilityChecker should return incompatible when columns are being removed._ in #3733 + - Currently the SchemaCompatibilityChecker returns compatible if fields are removed but this can be seen as a breaking change to existing schema's, therefore it has been changed to return incompatible when fields are removed. +- Feature: _Feed management - Delete data feed version_ #3457 +- Epic: _Improve UI testing_ #3498 +- Feature: _PWD Rotation [dev] - keytab replacement and unify service rotation_ #3619 + - #3640 _Migrate LDAP account to rotatable ssm param object_ in #3670, #3641 + - Unify LDAP service account credentials are now loaded from /datassets/{aul-dev|aul-uat|unify}/service-accounts/ad-service-account-credentials. + - Unify LDAP service account credentials are now loaded from `/datassets/aul-dev/ad-service-account-credentials`. + - #3643 _Create UAT+PROD ldap-svc records_ in + - #3680 _DEV+UAT to share common non-prod service accounts space (config change)_ in #3681 + - Unify LDAP service account credentials for UAT and DEV are now loaded from `/datassets/aul-nonprod/service-accounts/ad-service-account-credentials`. + - #3660 _Migrate hive-keytab usage to password-rotation compatible location_ in + - #3661 _Employ password/keytab rotation solution_ in + - #3662 _Create a wokflow integrating rotation step_ in + - #3693 _setup hive-service account params in param store to be rotated_ in + - #3741 _Manual rotation of `svc-ursa-unify-prd` and `svc-ursa-aul-dev`_ in + - #3740 _svc-ursa-unify accounts rotation support_ in #3748 + - webapp-svc-account credentials are now loaded from `/datassets/{aul-nonprod|unify}/service-accounts/spring-service-account-credentials` + +### Closed Epics +- Feature: _Include error message in failed runs endpoint and view_ #3426 +- Feature: _Domain management - Delete domain version_ #3456 + - #3470 _FE: Ability to delete draft/testing version_ in #3663 + - #3644 _Model validation failing after removal of non-latest major version_ in #3645 + +### Closed Issues without Pull Request ⚠️ +- Feature: _Include error message in failed runs endpoint and view_ #3426 +- Feature: _Domain management - Delete domain version_ #3456 + - #3470 _FE: Ability to delete draft/testing version_ in #3663 + - #3644 _Model validation failing after removal of non-latest major version_ in #3645 + +### Closed Issues without User Defined Labels ⚠️ +- Feature: _Include error message in failed runs endpoint and view_ #3426 + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #3639 _#3638 Add location type to runs service, DB, and UI_ + - added location type (Live/Testing) for which a run was triggered to related endpoints and UI view + +### Closed PRs without Issue and User Defined Labels ⚠️ +All closed PRs are linked to issues. + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +All merged PRs are linked to Closed issues. + +### Direct commits ⚠️ +All direct commits are linked pull requests. + +### Others - No Topic ⚠️ +- PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ + - Improves and optimizes the authorization in the feed management context. +- PR: #3639 _#3638 Add location type to runs service, DB, and UI_ + - added location type (Live/Testing) for which a run was triggered to related endpoints and UI view + +#### Full Changelog +https://github.com/absa-group/AUL/compare/v1.2.0...v1.3.0 diff --git a/main.py b/main.py index e173e337..0203b74d 100644 --- a/main.py +++ b/main.py @@ -41,9 +41,9 @@ def prepare_custom_chapters() -> CustomChapters: ActionInputs.get_chapters() ) if ActionInputs.get_regime() == ActionInputs.REGIME_ISSUE_HIERARCHY: - custom_chapters.chapters[f"New {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title="New Epics") - custom_chapters.chapters[f"Silent Live {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title="Silent Live") - custom_chapters.chapters[f"Closed {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title="Closed Epics") + custom_chapters.chapters[f"New {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title=f"New {ActionInputs.get_issue_type_first_level()}s") + custom_chapters.chapters[f"Silent Live {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title=f"Silent Live {ActionInputs.get_issue_type_first_level()}s") + custom_chapters.chapters[f"Closed {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title=f"Closed {ActionInputs.get_issue_type_first_level()}s") return custom_chapters diff --git a/release_notes_generator/chapters/custom_chapters.py b/release_notes_generator/chapters/custom_chapters.py index cad4f8f8..31a041eb 100644 --- a/release_notes_generator/chapters/custom_chapters.py +++ b/release_notes_generator/chapters/custom_chapters.py @@ -77,7 +77,7 @@ def populate(self, records: dict[int | str, Record]) -> None: if record_label in ch.labels and pulls_count > 0: if not records[record_id].is_present_in_chapters: - ch.add_row(record_id, records[record_id].to_chapter_row()) + ch.add_row(record_id, records[record_id].to_chapter_row(True)) self.populated_record_numbers_list.append(record_id) def _populate_hierarchy_issue(self, record: HierarchyIssueRecord) -> None: @@ -93,7 +93,7 @@ def _populate_hierarchy_issue(self, record: HierarchyIssueRecord) -> None: else: ch = self.chapters[f"Silent Live {ActionInputs.get_issue_type_first_level()}s"] - ch.add_row(record.record_id, record.to_chapter_row()) + ch.add_row(record.record_id, record.to_chapter_row(True)) self.populated_record_numbers_list.append(record.record_id) def from_yaml_array(self, chapters: list[dict[str, str]]) -> "CustomChapters": diff --git a/release_notes_generator/model/commit_record.py b/release_notes_generator/model/commit_record.py index f9ea2760..dcc4e74c 100644 --- a/release_notes_generator/model/commit_record.py +++ b/release_notes_generator/model/commit_record.py @@ -51,8 +51,9 @@ def commit(self) -> Commit: # methods - override Record methods - def to_chapter_row(self) -> str: - super().to_chapter_row() + def to_chapter_row(self, add_into_chapters: bool = False) -> str: + if add_into_chapters: + self.added_into_chapters() row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters() > 1 else "" # collecting values for formatting @@ -68,4 +69,7 @@ def get_rls_notes(self, line_marks: Optional[list[str]] = None) -> str: # Hint: direct commits does not support release notes return "" + def get_labels(self) -> set[str]: + return set() + # methods - specific to CommitRecord diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index 3e7c8b87..28283bae 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -1,13 +1,17 @@ """ A module that defines the IssueRecord class, which represents an issue record in the release notes. """ - +import logging +from functools import lru_cache from typing import Optional, Any from github.Issue import Issue +from github.PullRequest import PullRequest from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.issue_record import IssueRecord +logger = logging.getLogger(__name__) + class HierarchyIssueRecord(IssueRecord): """ @@ -23,16 +27,36 @@ def __init__(self, issue: Issue, issue_type: Optional[str] = None, skip: bool = self._sub_issues: dict[int, IssueRecord] = {} # sub-issues - no more sub-issues self._sub_hierarchy_issues: dict[int, HierarchyIssueRecord] = {} # sub-hierarchy issues - have sub-issues + @lru_cache(maxsize=None) + def get_labels(self) -> set[str]: + labels = set() + labels.update(self._issue.get_labels()) + + for sub_issue in self._sub_issues.values(): + labels.update(sub_issue.labels) + + for sub_hierarchy_issue in self._sub_hierarchy_issues.values(): + labels.update(sub_hierarchy_issue.labels) + + for pull in self._pull_requests.values(): + labels.update(pull.get_labels()) + + return labels + # methods - override ancestor methods - def to_chapter_row(self) -> str: - self.added_into_chapters() + def to_chapter_row(self, add_into_chapters: bool = False) -> str: + if add_into_chapters: + self.added_into_chapters() row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters() > 1 else "" format_values: dict[str, Any] = {} # collect format values - format_values["number"] = f"#{self._issue.number}" - format_values["title"] = self._issue.title - format_values["type"] = self._issue.type.name + format_values["number"] = f"#{self.issue.number}" + format_values["title"] = self.issue.title + if self.issue_type is not None: + format_values["type"] = self.issue_type + else: + format_values["type"] = "None" list_pr_links = self.get_pr_links() if len(list_pr_links) > 0: @@ -45,9 +69,6 @@ def to_chapter_row(self) -> str: indent += "- " # create first issue row - # TODO/Another Issue - add new service chapter for: - # - hierarchy issue which contains other hierarchy issues and normal issues or PRs - # Reason: hierarchy regime should improve readability of complex topics row = f"{indent}{row_prefix}" + ActionInputs.get_row_format_hierarchy_issue().format(**format_values) # add extra section with release notes if detected @@ -66,6 +87,9 @@ def to_chapter_row(self) -> str: if len(self._sub_issues) > 0: sub_indent = " " * (self._level + 1) for sub_issue in self._sub_issues.values(): + if sub_issue.is_open: + continue # only closed issues are reported in release notes + sub_issue_block = "- " + sub_issue.to_chapter_row() ind_child_block = "\n".join( f"{sub_indent}{line}" if line else "" for line in sub_issue_block.splitlines() @@ -87,6 +111,7 @@ def register_hierarchy_issue(self, issue: Issue) -> "HierarchyIssueRecord": """ sub_rec = HierarchyIssueRecord(issue=issue, issue_type=issue.type.name, level=self._level + 1) self._sub_hierarchy_issues[issue.number] = sub_rec + logger.debug("Registered sub-hierarchy issue '%d' to parent issue '%d'", issue.number, self.issue.number) return sub_rec def register_issue(self, issue: Issue) -> IssueRecord: @@ -100,4 +125,26 @@ def register_issue(self, issue: Issue) -> IssueRecord: """ sub_rec = IssueRecord(issue=issue) self._sub_issues[issue.number] = sub_rec + logger.debug("Registered sub-issue '%d' to parent issue '%d'", issue.number, self.issue.number) return sub_rec + + def register_pull_request_in_hierarchy(self, issue_number: int, pull: PullRequest) -> None: + if issue_number in self._sub_issues.keys(): + self._sub_issues[issue_number].register_pull_request(pull) + return + + if issue_number in self._sub_hierarchy_issues.keys(): + self._sub_hierarchy_issues[issue_number].register_pull_request(pull) + return + + def find_issue(self, issue_number: int) -> Optional["IssueRecord"]: + if issue_number in self._sub_issues.keys(): + return self._sub_issues[issue_number] + elif issue_number in self._sub_hierarchy_issues.keys(): + return self._sub_hierarchy_issues[issue_number] + else: + for rec in self._sub_hierarchy_issues.values(): + found = rec.find_issue(issue_number) + if found is not None: + return found + return None diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py index 1fe6f90f..f6ad165f 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -1,8 +1,9 @@ """ A module that defines the IssueRecord class, which represents an issue record in the release notes. """ - +import logging import re +from functools import lru_cache from typing import Optional, Any from github.Commit import Commit @@ -12,6 +13,8 @@ from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.record import Record +logger = logging.getLogger(__name__) + class IssueRecord(Record): """ @@ -32,8 +35,6 @@ def __init__(self, issue: Issue, issue_type: Optional[str] = None, skip: bool = if issue is not None and issue.type is not None: self._issue_type = issue.type.name - self._labels = {label.name for label in self._issue.get_labels()} - self._pull_requests: dict[int, PullRequest] = {} self._commits: dict[int, dict[str, Commit]] = {} @@ -78,8 +79,19 @@ def issue_type(self) -> Optional[str]: # methods - override Record methods - def to_chapter_row(self) -> str: - super().to_chapter_row() + @lru_cache(maxsize=None) + def get_labels(self) -> set[str]: + return {label.name for label in self._issue.get_labels()} + + def find_issue(self, issue_number: int) -> Optional["IssueRecord"]: + if self._issue.number == issue_number: + return self + else: + return None + + def to_chapter_row(self, add_into_chapters: bool = False) -> str: + if add_into_chapters: + self.added_into_chapters() row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters() > 1 else "" format_values: dict[str, Any] = {} @@ -177,7 +189,6 @@ def register_pull_request(self, pull: PullRequest) -> None: Returns: None """ self._pull_requests[pull.number] = pull - self._labels.update({label.name for label in pull.get_labels()}) def register_commit(self, pull: PullRequest, commit: Commit) -> None: """ @@ -209,7 +220,7 @@ def get_pr_links(self) -> list[str]: Returns: list[str]: A list of pull request links associated with the issue. """ - if len(self._pull_requests) == 0: + if len(self._pull_requests.values()) == 0: return [] template = "#{number}" diff --git a/release_notes_generator/model/pull_request_record.py b/release_notes_generator/model/pull_request_record.py index 005ded8f..b8461f3a 100644 --- a/release_notes_generator/model/pull_request_record.py +++ b/release_notes_generator/model/pull_request_record.py @@ -3,6 +3,7 @@ """ import re +from functools import lru_cache from typing import Optional, Any from github.Commit import Commit @@ -25,8 +26,6 @@ def __init__(self, pull: PullRequest, skip: bool = False): super().__init__(skip=skip) self._pull_request: PullRequest = pull - self._labels = {label.name for label in self._pull_request.get_labels()} - self._commits: dict[str, Commit] = {} # properties - override Record properties @@ -106,8 +105,14 @@ def contributors(self) -> list[str]: # methods - override Record methods - def to_chapter_row(self) -> str: - super().to_chapter_row() + @lru_cache(maxsize=None) + def get_labels(self) -> set[str]: + return {label.name for label in self._pull_request.get_labels()} + + def to_chapter_row(self, add_into_chapters: bool = False) -> str: + if add_into_chapters: + self.added_into_chapters() + row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters() > 1 else "" format_values: dict[str, Any] = {} diff --git a/release_notes_generator/model/record.py b/release_notes_generator/model/record.py index 250789d5..c2d6b4c7 100644 --- a/release_notes_generator/model/record.py +++ b/release_notes_generator/model/record.py @@ -63,6 +63,9 @@ def labels(self) -> list[str]: Returns: list[str]: A list of labels associated with the record. """ + if len(self._labels) == 0: + self._labels = self.get_labels() + return list(self._labels) @property @@ -101,16 +104,22 @@ def authors(self) -> list[str]: list[str]: A list of authors associated with the record. """ - # to be overridden by subclasses and called via super() as the first line - def to_chapter_row(self) -> str: + # abstract methods + + @abstractmethod + def to_chapter_row(self, add_into_chapters: bool = False) -> str: """ Converts the record to a string row in a chapter. @return: The record as a row string. """ - self.added_into_chapters() - return "" - # abstract methods + @abstractmethod + def get_labels(self) -> set[str]: + """ + Gets the labels of the record. + Returns: + set[str]: A list of labels associated with the record. + """ @abstractmethod def get_rls_notes(self, line_marks: Optional[list[str]] = None) -> str: diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index d484082d..5a274697 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -23,8 +23,8 @@ from github import Github from github.Issue import Issue, SubIssue +from github.Label import Label from github.PullRequest import PullRequest -from github.Commit import Commit from release_notes_generator.model.commit_record import CommitRecord from release_notes_generator.model.hierarchy_issue_record import HierarchyIssueRecord @@ -58,15 +58,35 @@ def generate(self, github: Github, data: MinedData) -> dict[int | str, Record]: dict[int|str, Record]: A dictionary of records where the key is the issue or pull request number. """ - def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: + def register_pull_request(pull: PullRequest, skip_rec: bool) -> Optional["IssueRecord"]: detected_issues = extract_issue_numbers_from_body(pull) logger.debug("Detected issues - from body: %s", detected_issues) detected_issues.update(safe_call(get_issues_for_pr)(pull_number=pull.number)) logger.debug("Detected issues - final: %s", detected_issues) for parent_issue_number in detected_issues: - # create an issue record if not present for PR parent - if parent_issue_number not in records: + # try to find an issue record if not present for PR parent + if parent_issue_number in data_issue_numbers: + # find it and register + rec = records.get(parent_issue_number) + if rec is None: + # the parent issue is sub-issue of some hierarchy issue - find the hierarchy issue and register to it + for r in records.values(): + if isinstance(r, HierarchyIssueRecord): + rec = cast(HierarchyIssueRecord, r).find_issue(parent_issue_number) + if rec is not None: + break + + if rec is not None and isinstance(rec, PullRequestRecord): + continue + elif rec is not None and isinstance(rec, IssueRecord): + rec.register_pull_request(pull) + return rec + elif rec is not None and isinstance(rec, HierarchyIssueRecord): + rec.register_pull_request_in_hierarchy(parent_issue_number, pull) + return rec + + else: # parent_issue_number not in data_issue_numbers: logger.warning( "Detected PR %d linked to issue %d which is not in the list of received issues. " "Fetching ...", @@ -78,43 +98,51 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: ) if parent_issue is not None: IssueHierarchyRecordFactory.create_record_for_issue(records, parent_issue) + data_issue_numbers.append(parent_issue_number) + rec = cast(IssueRecord, records[parent_issue_number]) + rec.register_pull_request(pull) + logger.debug("Registering PR %d: %s to Issue %d", pull.number, pull.title, parent_issue_number) + return rec - if parent_issue_number in records: - cast(IssueRecord, records[parent_issue_number]).register_pull_request(pull) - logger.debug("Registering PR %d: %s to Issue %d", pull.number, pull.title, parent_issue_number) - else: + # solo PR found (no linked issue found) + if parent_issue_number not in data_issue_numbers: logger.debug( "Registering stand-alone PR %d: %s as mentioned Issue %d not found.", pull.number, pull.title, parent_issue_number, ) + return None records: dict[int | str, Record] = {} rate_limiter = GithubRateLimiter(github) safe_call = safe_call_decorator(rate_limiter) registered_issues: list[int] = [] + data_issue_numbers = [issue.number for issue in data.issues] - logger.debug("Registering issues to records...") + logger.debug("Registering hierarchy issues to records...") # create hierarchy issue records first => dict of hierarchy issues with registered sub-issues # 1. round - the most heavy ones (e.g. Epic) # 2. round - the second most heavy ones (e.g. Features) # 3. round - the rest (e.g. Bugs, Tasks, etc.) but can be just another hierarchy - depend on configuration for hierarchy_issue in ActionInputs.get_issue_type_weights(): + logger.debug("Registering hierarchy issues of type: %s", hierarchy_issue) for issue in data.issues: - issue_type = self._get_issue_type(issue) + issue_labels: list[Label] = list(issue.get_labels()) + issue_type = self._get_issue_type(issue, issue_labels) if issue_type is not None and issue_type == hierarchy_issue and issue.number not in registered_issues: sub_issues = list(issue.get_sub_issues()) if len(sub_issues) == 0: continue # not a hierarchy issue even if labeled or issue type say so - self.create_record_for_hierarchy_issue(records, issue, issue_type) + self.create_record_for_hierarchy_issue(records, issue, issue_type, issue_labels) registered_issues.append(issue.number) rec: HierarchyIssueRecord = cast(HierarchyIssueRecord, records[issue.number]) if len(sub_issues) > 0: - self._solve_sub_issues(rec, data, registered_issues, sub_issues) + self._solve_sub_issues(rec, data, data_issue_numbers, registered_issues, sub_issues) # create or register non-hierarchy issue related records + logger.debug("Registering issues to records...") for issue in data.issues: if issue.number not in registered_issues: parent_issue = issue.get_sub_issues() @@ -123,29 +151,39 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: else: IssueHierarchyRecordFactory.create_record_for_issue(records, issue) - logger.debug("Registering pull requests to records...") + logger.debug("Registering pull requests to records and commit to Pull Requests...") + registered_commits: list[str] = [] for pull in data.pull_requests: pull_labels = [label.name for label in pull.get_labels()] skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) + related_commits = [c for c in data.commits if c.sha == pull.merge_commit_sha] + registered_commits.extend(c.sha for c in related_commits) if not safe_call(get_issues_for_pr)(pull_number=pull.number) and not extract_issue_numbers_from_body(pull): - records[pull.number] = PullRequestRecord(pull, skip=skip_record) + pr_rec = PullRequestRecord(pull, skip=skip_record) + for c in related_commits: # register commits to the PR record + pr_rec.register_commit(c) + records[pull.number] = pr_rec logger.debug("Created record for PR %d: %s", pull.number, pull.title) + else: logger.debug("Registering pull number: %s, title : %s", pull.number, pull.title) - register_pull_request(pull, skip_record) + rec: IssueRecord = register_pull_request(pull, skip_record) + for c in related_commits: # register commits to the PR record + if rec is not None: + rec.register_commit(pull, c) - logger.debug("Registering commits to records...") - detected_direct_commits_count = sum( - not IssueHierarchyRecordFactory.register_commit_to_record(records, commit) for commit in data.commits - ) + logger.debug("Registering direct commits to records...") + for commit in data.commits: + if commit.sha not in registered_commits: + records[commit.sha] = CommitRecord(commit) logger.info( "Generated %d records from %d issues and %d PRs, with %d commits detected.", len(records), len(data.issues), len(data.pull_requests), - detected_direct_commits_count, + len(data.commits), ) return records @@ -153,74 +191,68 @@ def _solve_sub_issues( self, record: HierarchyIssueRecord, data: MinedData, + data_issue_numbers: list[int], registered_issues: list[int], sub_issues: list[SubIssue], ) -> None: + logger.debug("Solving sub issues for hierarchy issue record %d", record.issue.number) + for sub_issue in sub_issues: # closed in previous rls, in current one, open ones + logger.debug("Processing sub-issue %d", sub_issue.number) + if sub_issue.number in registered_issues: # already registered + logger.debug("Sub-issue %d already registered, skipping", sub_issue.number) continue - if sub_issue.number not in data.issues: # not closed in current rls or not opened == not in mined data + if sub_issue.number not in data_issue_numbers: # not closed in current rls or not opened == not in mined data + logger.debug("Sub-issue %d not registered, skipping", sub_issue.number) continue sub_sub_issues = list(sub_issue.get_sub_issues()) if len(sub_sub_issues) > 0: + logger.debug("Solving sub issues for sub-issue: %s", sub_issue.number) rec = record.register_hierarchy_issue(sub_issue) registered_issues.append(sub_issue.number) - self._solve_sub_issues(rec, data, registered_issues, sub_sub_issues) + self._solve_sub_issues(rec, data, data_issue_numbers, registered_issues, sub_sub_issues) else: + logger.debug("Solving sub issues for sub-issue: %s", sub_issue.number) record.register_issue(sub_issue) registered_issues.append(sub_issue.number) @staticmethod - def register_commit_to_record(records: dict[int | str, Record], commit: Commit) -> bool: - """ - Register a commit to a record. - - @param commit: The commit to register. - @return: True if the commit was registered to a record, False otherwise - """ - for record in records.values(): - if isinstance(record, IssueRecord): - rec_i = cast(IssueRecord, record) - for number in rec_i.get_pull_request_numbers(): - pr = rec_i.get_pull_request(number) - if pr and pr.merge_commit_sha == commit.sha: - rec_i.register_commit(pr, commit) - return True - - elif isinstance(record, PullRequestRecord): - rec_pr = cast(PullRequestRecord, record) - if rec_pr.is_commit_sha_present(commit.sha): - rec_pr.register_commit(commit) - return True - - records[commit.sha] = CommitRecord(commit=commit) - logger.debug("Created record for direct commit %s: %s", commit.sha, commit.commit.message) - return False - - @staticmethod - def create_record_for_hierarchy_issue(records: dict[int | str, Record], i: Issue, issue_type: Optional[str]) -> None: + def create_record_for_hierarchy_issue(records: dict[int | str, Record], i: Issue, issue_type: Optional[str], + issue_labels: list[Label]) -> None: """ Create a record for an issue. - @param i: Issue instance. - @return: None + Parameters: + records: The records to create the record for. + i: Issue instance. + issue_type: The type of the issue. + issue_labels: The labels of the issue. + + Returns: + None """ # check for skip labels presence and skip when detected - issue_labels = [label.name for label in i.get_labels()] - skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) + issue_labels_names = [label.name for label in issue_labels] + skip_record = any(item in issue_labels_names for item in ActionInputs.get_skip_release_notes_labels()) records[i.number] = HierarchyIssueRecord(issue=i, issue_type=issue_type, skip=skip_record) - - logger.debug("Created record for issue %d: %s", i.number, i.title) + logger.debug("Created record for hierarchy issue %d: %s (type: %s)", i.number, i.title, issue_type) @staticmethod - def _get_issue_type(issue: Issue) -> Optional[str]: + def _get_issue_type(issue: Issue, issue_labels: list[Label]) -> Optional[str]: if issue.type is not None: return issue.type.name - if issue.labels is not None: - issue_labels = [label.name for label in issue.get_labels()] - for label in issue_labels: - if label in ActionInputs.get_issue_type_weights(): - return label - # TODO - check multiple labels, not just first - select the highest weight one + + if len(issue_labels) > 0: + issue_labels_lower = [label.name.lower() for label in issue_labels] + issue_types = [issue_type.lower() for issue_type in ActionInputs.get_issue_type_weights()] + # Find all matching types and their indices in the original list + matching_indices = [ + idx for idx, t in enumerate(issue_types) if t in issue_labels_lower + ] + if matching_indices: + # Return the type from the original list with the lowest index + return ActionInputs.get_issue_type_weights()[min(matching_indices)] + return None diff --git a/release_notes_generator/utils/pull_request_utils.py b/release_notes_generator/utils/pull_request_utils.py index ce305bc8..ee2420f4 100644 --- a/release_notes_generator/utils/pull_request_utils.py +++ b/release_notes_generator/utils/pull_request_utils.py @@ -45,6 +45,9 @@ def extract_issue_numbers_from_body(pr: PullRequest) -> set[int]: # Extract the issue numbers from the matches issue_numbers = {int(match[-1]) for match in issue_matches} + if pr.number == 3645: + print(f"PR #{pr.number} - Extracted issue numbers from body: {issue_numbers}") + return issue_numbers @@ -69,4 +72,8 @@ def get_issues_for_pr(pull_number: int) -> list[int]: node["number"] for node in response.json()["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"] ] + + if pull_number == 3645: + print(f"PR #{pull_number} - Extracted issue numbers from GitHub API: {numbers}") + return numbers From a91423b28cb3c9a80702986915cf9c3a28a4813f Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 15 Sep 2025 10:24:51 +0200 Subject: [PATCH 11/33] - Remove show-only files. --- Unify-default.md | 135 ---------------------------- Unify-issue-hierarchy-v1.md | 169 ------------------------------------ Unify-issue-hierarchy-v2.md | 147 ------------------------------- Unify-issue-hierarchy-v3.md | 141 ------------------------------ 4 files changed, 592 deletions(-) delete mode 100644 Unify-default.md delete mode 100644 Unify-issue-hierarchy-v1.md delete mode 100644 Unify-issue-hierarchy-v2.md delete mode 100644 Unify-issue-hierarchy-v3.md diff --git a/Unify-default.md b/Unify-default.md deleted file mode 100644 index 2c93447b..00000000 --- a/Unify-default.md +++ /dev/null @@ -1,135 +0,0 @@ -### Breaking Changes 💥 -No entries detected. - -### New Features 🎉 -- #3186 _Improve authorization in FeedManagement context_ in #3701 - - Improves and optimizes the authorization in the feed management context. -- #3207 _Allow Unify app admins and data ops roles to run TriggerManualExecutionCommand_ in #3762 - - Added app-admins and data-ops to allowed users -- PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ - - Improves and optimizes the authorization in the feed management context. -- #3470 _FE: Ability to delete draft/testing version_ in #3663 -- #3479 _Implement model layer domain major versions merge and mergeablility functionality_ in #3633 - - Introduces functionality related to merging of domain major versions to Domain model layer -- #3481 _Implement `MergeBackDomainMajorVersionCommand` handler_ in #3666 - - Introduces `MergeBackDomainMajorVersionCommandHandler` in domain-management bounded context - -### Bugfixes 🛠 -- #3637 _Partitions fetched from the glue catalog are not imported correctly_ in #3699 - - Fix importing partitions from glue table -- #3644 _Model validation failing after removal of non-latest major version_ in #3645 -- #3683 _Owners and stewards cannot edit domain through UI if change involves PatchDomainCommand_ in #3692 - - fixed domain secondary owners and stewards not being able to edit certain domain properties through UI even though they should be authorized to do so (like description), by not performing authorization on `PatchDomainCommand` parts that didn't change but were sent in the request anyway -- #3714 _SchemaCompatibilityChecker should return incompatible when columns are being removed._ in #3733 - - Currently the SchemaCompatibilityChecker returns compatible if fields are removed but this can be seen as a breaking change to existing schema's, therefore it has been changed to return incompatible when fields are removed. -- #3726 _Output partition column is missing after editing target schema_ in #3743 - - Bug Fix: fix output partition column disappearing after editing target schema and add a dedicated Cypress scenario to prevent regressions. -- #3750 _Final DF is not cached in the Spark Job_ in #3751 - - removed cache on primary dataset DF and added one on final (post-interpretation) DF to improve dual-write performence -- #3756 _DB: Missing DB function permissions (ownership / execution)_ in #3757 - -### Closed Issues without Pull Request ⚠️ -- #3226 _`TriggerManualExecutionCommand` should return actual `executionID` not a random UUID_ in -- #3281 _Take into account Cats Effect Runtime metrics in health endpoint_ in -- #3456 _Domain management - Delete domain version_ in -- #3580 _Sending events about runs to EventBus_ in -- #3608 _Make GET /api-v2/domain-execution/queries/job-error-details/{jobID} more universal_ in -- #3609 _Adjust dependencies of original GET /api-v2/domain-execution/queries/job-error-details/{jobID} to the new one_ in -- #3617 _PWD Rotation [dev] - Pwd change_ in -- #3618 _PWD Rotation [dev] - keytabs regeneration_ in -- #3620 _Create an ADR for Event-based scheduling of domains_ in -- #3622 _Consult an ADR about event-based domain scheduling with the team for having the consensus_ in -- #3638 _Add location type into GetJobRunsResult and GetJobErrorDetailsResult_ in -- #3643 _Create UAT+PROD ldap-svc records_ in -- #3660 _Migrate hive-keytab usage to password-rotation compatible location_ in -- #3661 _Employ password/keytab rotation solution_ in -- #3662 _Create a wokflow integrating rotation step_ in -- #3665 _KarinKersten access to Curation and Consumption stuff_ in -- #3667 _Solve 'SMTP Lock Down' for Unify_ in -- #3693 _setup hive-service account params in param store to be rotated_ in -- #3700 _Migrate Unify EC2 instances to Graviton_ in -- #3702 _Implement DB function that resets the domain freshness_ in -- #3741 _Manual rotation of `svc-ursa-unify-prd` and `svc-ursa-aul-dev`_ in - -### Closed Issues without User Defined Labels ⚠️ -- #2836 _Fix ESLint config _ in #3731 -- #3300 _Add `executionID` match assertions to domain major versions flow API tests_ in #3728, #3523 - - added tests in getJobs to assert executionID returned in response is evaluated to match actual vs expected - - Timeout increased to wait few mins for eventual consistency -- #3483 _Handle `DomainMajorVersionMergedBackEvent` event in data-cataloging_ in #3696 - - Add and implement new service: `updateDomainMajorVersionLiveTables` - - Add Appropriate controller endpoint for the above - - Utilize the above in domainmajorversionmergedbackevent handler to update tables in place - - Truncate and delete merging data after - - implement tetst -- #3485 _Handle `DomainMajorVersionMergedBackEvent` in feed-management_ in #3573 - - Introduces `DomainMajorVersionMergedBackEventHandler` to handle `DomainMajorVersionMergedBackEvent` - - Adds `feed_management.update_feeds_for_merged_domain_major_versions` plpgsql function that updates feed versions for domain major versions related to merge operation -- #3555 _Update Login e2e test_ in #3604 -- 🔔 #3608 _Make GET /api-v2/domain-execution/queries/job-error-details/{jobID} more universal_ in -- 🔔 #3609 _Adjust dependencies of original GET /api-v2/domain-execution/queries/job-error-details/{jobID} to the new one_ in -- 🔔 #3617 _PWD Rotation [dev] - Pwd change_ in -- 🔔 #3618 _PWD Rotation [dev] - keytabs regeneration_ in -- #3621 _Create an ADR for event-based domain scheduling_ in #3636 -- 🔔 #3622 _Consult an ADR about event-based domain scheduling with the team for having the consensus_ in -- #3630 _Remove redundant DTOs from DE bounded ctx_ in #3631 -- 🔔 #3638 _Add location type into GetJobRunsResult and GetJobErrorDetailsResult_ in -- #3640 _Migrate LDAP account to rotatable ssm param object_ in #3670, #3641 - - Unify LDAP service account credentials are now loaded from /datassets/{aul-dev|aul-uat|unify}/service-accounts/ad-service-account-credentials. - - Unify LDAP service account credentials are now loaded from `/datassets/aul-dev/ad-service-account-credentials`. -- 🔔 #3643 _Create UAT+PROD ldap-svc records_ in -- #3646 _Update Dashboard system test_ in #3668 -- #3647 _Update Data-feed system test_ in #3673 -- 🔔 #3660 _Migrate hive-keytab usage to password-rotation compatible location_ in -- 🔔 #3661 _Employ password/keytab rotation solution_ in -- 🔔 #3662 _Create a wokflow integrating rotation step_ in -- 🔔 #3665 _KarinKersten access to Curation and Consumption stuff_ in -- 🔔 #3667 _Solve 'SMTP Lock Down' for Unify_ in -- #3678 _Remove typescript files from /assets folder_ in #3677 -- #3680 _DEV+UAT to share common non-prod service accounts space (config change)_ in #3681 - - Unify LDAP service account credentials for UAT and DEV are now loaded from `/datassets/aul-nonprod/service-accounts/ad-service-account-credentials`. -- #3690 _Implement DDL for the state management_ in #3711 -- #3691 _Implement DB function for the state management_ in #3729 - * EventBus integration: implementing state management DB functions for reacting on incoming events and changes in domain automatic triggering -- 🔔 #3693 _setup hive-service account params in param store to be rotated_ in -- 🔔 #3700 _Migrate Unify EC2 instances to Graviton_ in -- 🔔 #3702 _Implement DB function that resets the domain freshness_ in -- #3703 _Implement BE DTO & API that will represent `ProcessAvailableDatasetCommand`_ in #3713 - - added `ProcessAvailableDatasetCommand` API (without implementation) -- #3705 _Make `DataSource#location` an ADT instead of String_ in #3715 - - Introduces possibility to define location of a data source by either using Glue table or S3 access point. -- #3716 _Add and implement `GetDomainMajorVersionDefaultDataSourcesQuery`_ in #3763 - - added `GetDomainMajorVersionDefaultDataSourcesQuery` which returns all data sources used in latest `Ready` feed versions for a given domain major version -- #3719 _Add event based scheduling option to `Schedule` DTO_ in #3727 - - added Schedule.DailyWindowEventBasedSchedule option of Schedule in domain-execution API -- #3721 _Implement DB function to upsert scheduling in EBS DB tables for a domain major version_ in #3760 -- #3722 _Implement DB function to remove domain major version from EBS DB tables_ in #3759 -- #3730 _Add an ESLint rule to check for skipped/focused unit tests_ in #3731 -- #3740 _svc-ursa-unify accounts rotation support_ in #3748 - - webapp-svc-account credentials are now loaded from `/datassets/{aul-nonprod|unify}/service-accounts/spring-service-account-credentials` -- 🔔 #3741 _Manual rotation of `svc-ursa-unify-prd` and `svc-ursa-aul-dev`_ in - -### Merged PRs without Issue and User Defined Labels ⚠️ -- PR: #3639 _#3638 Add location type to runs service, DB, and UI_ - - added location type (Live/Testing) for which a run was triggered to related endpoints and UI view -- PR: #3732 _Add UI team ownership for ci-ui-checks.yml_ -- PR: #3739 _rename db file_ -- PR: #3752 _Add missing MergeBackDomainMajorVersionCommand to AuthorizationManager_ -- PR: #3753 _Fix order of arguments in DomainMajorVersionMergedBack_ -- PR: #3761 _api tests fixes_ - -### Closed PRs without Issue and User Defined Labels ⚠️ -All closed PRs are linked to issues. - -### Merged PRs Linked to 'Not Closed' Issue ⚠️ -- #3571 _Update System tests_ in #3634 - -### Direct commits ⚠️ -All direct commits are linked pull requests. - -### Others - No Topic ⚠️ -- 🔔 PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ - - Improves and optimizes the authorization in the feed management context. - -#### Full Changelog -https://github.com/absa-group/AUL/compare/v1.2.0...v1.3.0 diff --git a/Unify-issue-hierarchy-v1.md b/Unify-issue-hierarchy-v1.md deleted file mode 100644 index d8c36812..00000000 --- a/Unify-issue-hierarchy-v1.md +++ /dev/null @@ -1,169 +0,0 @@ -### Breaking Changes 💥 -No entries detected. - -### New Features 🎉 -- PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ - - Improves and optimizes the authorization in the feed management context. -- #3470 _FE: Ability to delete draft/testing version_ in #3663 -- #3479 _Implement model layer domain major versions merge and mergeablility functionality_ in #3633 - - Introduces functionality related to merging of domain major versions to Domain model layer -- #3481 _Implement `MergeBackDomainMajorVersionCommand` handler_ in #3666 - - Introduces `MergeBackDomainMajorVersionCommandHandler` in domain-management bounded context - -### Bugfixes 🛠 -- #3637 _Partitions fetched from the glue catalog are not imported correctly_ in #3699 - - Fix importing partitions from glue table -- #3644 _Model validation failing after removal of non-latest major version_ in #3645 -- #3683 _Owners and stewards cannot edit domain through UI if change involves PatchDomainCommand_ in #3692 - - fixed domain secondary owners and stewards not being able to edit certain domain properties through UI even though they should be authorized to do so (like description), by not performing authorization on `PatchDomainCommand` parts that didn't change but were sent in the request anyway -- #3714 _SchemaCompatibilityChecker should return incompatible when columns are being removed._ in #3733 - - Currently the SchemaCompatibilityChecker returns compatible if fields are removed but this can be seen as a breaking change to existing schema's, therefore it has been changed to return incompatible when fields are removed. -- #3726 _Output partition column is missing after editing target schema_ in #3743 - - Bug Fix: fix output partition column disappearing after editing target schema and add a dedicated Cypress scenario to prevent regressions. -- #3750 _Final DF is not cached in the Spark Job_ in #3751 - - removed cache on primary dataset DF and added one on final (post-interpretation) DF to improve dual-write performence -- #3756 _DB: Missing DB function permissions (ownership / execution)_ in #3757 - -### New Epics -- Feature: _Promote from Pre-prod to prod and copy from Prod to IJAT_ #3655 -- Feature: _Add SQL function: Sum_ #3656 -- Feature: _Create a function creating timestamp from date and time representing column_ #3657 -- Feature: _Rollback feature for a domain_ #3658 -- Feature: _'Save As' capability for copying existing domains_ #3659 -- Feature: _Add SQL Function: Dense_Rank_ #3671 -- Feature: _Use Glue Tables explicitly as feed data sources_ #3674 -- Epic: _Add Iceberg support (AWS-only)_ #3675 -- Feature: _Add support for Iceberg tables as sources (from AWS)_ #3679 -- Feature: _Add support for writing Iceberg tables (AWS)_ #3685 -- Feature: _Marking dataset ready for the current day and triggering runs_ #3686 -- Feature: _`runs` topic events consumption_ #3687 -- Feature: _Event-based scheduling mode selection_ #3688 -- Feature: _Exclusion of data sources from the scheduling_ #3689 -- Feature: _Technical debt in Feed management DB functions_ #3695 -- Feature: _Send only changed properties in PatchDomainCommand from the UI_ #3697 -- Feature: _Add retry mechanism to domain major versions flow API tests_ #3734 -- Feature: _Delete created domain after API and E2E tests_ #3749 -- Feature: _Domain Major Schema / Output Partitioning Change: Check compatibility of all Feed Versions, not just the latest one_ #3758 - -### Silent Live -- Epic: _Make formula builder more user-friendly_ #1188 -- Epic: _Group By + Aggregations after Join(s)_ #1668 -- Epic: _Add automation to GitHub project_ #1674 -- Epic: _Implement data controls for foundation domains_ #1857 -- Epic: _UI unit tests_ #2319 -- Epic: _Update Cypress tests_ #2847 -- Epic: _Manual once-off run for a range of dates_ #2873 -- Epic: _Allow cross-account granting & revoking READ access to domain Glue tables_ #3006 -- Feature: _FM API: initialize_new_feed_version: think about a better name potentially - API, DB function, server part_ #3141 -- Feature: _Listing AWS accounts with access to a domain's Glue table_ #3246 -- Feature: _Database changes to store and record the desired stats._ #3264 -- Feature: _Create a REST API endpoint returning the overall stats_ #3265 -- Feature: _Change the landing page to display the overall stats_ #3266 -- Feature: _Manually add Glue & Hive partitions when possible_ #3284 -- Feature: _Allow active domain major version compatible schema evolution_ #3292 -- Feature: _Country code selection for ARO_ #3297 -- Feature: _Domain Major Version made Cold - Archive data into S3 Glacier to save some S3 storage costs_ #3299 -- Feature: _Add Execution ID into Plugins, for Atum Partitioning and later Checkpoints_ #3317 -- Feature: _Feed Management: Remove redundant Domain Major Version Attribute from some DTOs and Queries_ #3373 -- Feature: _Feed management - Delete data feed version_ #3457 -- Feature: _Data feed rollback_ #3473 -- Feature: _Change domain status from COLD to ACTIVE._ #3474 -- Epic: _Improve UI testing_ #3498 -- Feature: _Revoking readonly access for domain's Glue tables to AWS account_ #3525 -- Epic: _Reduce Aquasec Findings for Unify repos_ #3539 -- Feature: _Consumption of Atum measurements via odbc/jdbc and API_ #3544 -- Feature: _Domain: add created by information on the UI_ #3553 -- Epic: _Update System tests_ #3571 -- Feature: _Extend grid view to include elapsed runtime_ #3598 -- Feature: _Access Control Mgmt. - Automated PWD Rotation_ #3599 -- Feature: _Trigger manual execution for multiple domains - batch execute_ #3600 -- Feature: _PWD Rotation [dev] - keytab replacement and unify service rotation_ #3619 - -### Closed Epics -- Feature: _Improve authorization in FeedManagement context_ #3186 - - _Release Notes_: - - Improves and optimizes the authorization in the feed management context. -- Feature: _Allow Unify app admins and data ops roles to run TriggerManualExecutionCommand_ #3207 - - _Release Notes_: - - Added app-admins and data-ops to allowed users -- Feature: _Take into account Cats Effect Runtime metrics in health endpoint_ #3281 -- Feature: _Include error message in failed runs endpoint and view_ #3426 -- Feature: _Domain management - Delete domain version_ #3456 -- Feature: _Sending events about runs to EventBus_ #3580 -- Feature: _Create an ADR for Event-based scheduling of domains_ #3620 - -### Closed Issues without Pull Request ⚠️ -- 🔔 Feature: _Take into account Cats Effect Runtime metrics in health endpoint_ #3281 -- 🔔 Feature: _Include error message in failed runs endpoint and view_ #3426 -- 🔔 Feature: _Domain management - Delete domain version_ #3456 -- 🔔 Feature: _Sending events about runs to EventBus_ #3580 -- 🔔 Feature: _Create an ADR for Event-based scheduling of domains_ #3620 - -### Closed Issues without User Defined Labels ⚠️ -- #2836 _Fix ESLint config _ in #3731 -- #3300 _Add `executionID` match assertions to domain major versions flow API tests_ in #3728, #3523 - - added tests in getJobs to assert executionID returned in response is evaluated to match actual vs expected - - Timeout increased to wait few mins for eventual consistency -- 🔔 Feature: _Include error message in failed runs endpoint and view_ #3426 -- #3483 _Handle `DomainMajorVersionMergedBackEvent` event in data-cataloging_ in #3696 - - Add and implement new service: `updateDomainMajorVersionLiveTables` - - Add Appropriate controller endpoint for the above - - Utilize the above in domainmajorversionmergedbackevent handler to update tables in place - - Truncate and delete merging data after - - implement tetst -- #3485 _Handle `DomainMajorVersionMergedBackEvent` in feed-management_ in #3573 - - Introduces `DomainMajorVersionMergedBackEventHandler` to handle `DomainMajorVersionMergedBackEvent` - - Adds `feed_management.update_feeds_for_merged_domain_major_versions` plpgsql function that updates feed versions for domain major versions related to merge operation -- #3555 _Update Login e2e test_ in #3604 -- #3621 _Create an ADR for event-based domain scheduling_ in #3636 -- #3630 _Remove redundant DTOs from DE bounded ctx_ in #3631 -- #3640 _Migrate LDAP account to rotatable ssm param object_ in #3670, #3641 - - Unify LDAP service account credentials are now loaded from /datassets/{aul-dev|aul-uat|unify}/service-accounts/ad-service-account-credentials. - - Unify LDAP service account credentials are now loaded from `/datassets/aul-dev/ad-service-account-credentials`. -- #3646 _Update Dashboard system test_ in #3668 -- #3647 _Update Data-feed system test_ in #3673 -- #3678 _Remove typescript files from /assets folder_ in #3677 -- #3680 _DEV+UAT to share common non-prod service accounts space (config change)_ in #3681 - - Unify LDAP service account credentials for UAT and DEV are now loaded from `/datassets/aul-nonprod/service-accounts/ad-service-account-credentials`. -- #3690 _Implement DDL for the state management_ in #3711 -- #3691 _Implement DB function for the state management_ in #3729 - * EventBus integration: implementing state management DB functions for reacting on incoming events and changes in domain automatic triggering -- #3703 _Implement BE DTO & API that will represent `ProcessAvailableDatasetCommand`_ in #3713 - - added `ProcessAvailableDatasetCommand` API (without implementation) -- #3705 _Make `DataSource#location` an ADT instead of String_ in #3715 - - Introduces possibility to define location of a data source by either using Glue table or S3 access point. -- #3716 _Add and implement `GetDomainMajorVersionDefaultDataSourcesQuery`_ in #3763 - - added `GetDomainMajorVersionDefaultDataSourcesQuery` which returns all data sources used in latest `Ready` feed versions for a given domain major version -- #3719 _Add event based scheduling option to `Schedule` DTO_ in #3727 - - added Schedule.DailyWindowEventBasedSchedule option of Schedule in domain-execution API -- #3721 _Implement DB function to upsert scheduling in EBS DB tables for a domain major version_ in #3760 -- #3722 _Implement DB function to remove domain major version from EBS DB tables_ in #3759 -- #3730 _Add an ESLint rule to check for skipped/focused unit tests_ in #3731 -- #3740 _svc-ursa-unify accounts rotation support_ in #3748 - - webapp-svc-account credentials are now loaded from `/datassets/{aul-nonprod|unify}/service-accounts/spring-service-account-credentials` -- #3768 _Unify: Add business value chapter to issue templates_ in #3769 - -### Merged PRs without Issue and User Defined Labels ⚠️ -- PR: #3639 _#3638 Add location type to runs service, DB, and UI_ - - added location type (Live/Testing) for which a run was triggered to related endpoints and UI view -- PR: #3732 _Add UI team ownership for ci-ui-checks.yml_ -- PR: #3739 _rename db file_ -- PR: #3752 _Add missing MergeBackDomainMajorVersionCommand to AuthorizationManager_ -- PR: #3753 _Fix order of arguments in DomainMajorVersionMergedBack_ -- PR: #3761 _api tests fixes_ - -### Closed PRs without Issue and User Defined Labels ⚠️ -All closed PRs are linked to issues. - -### Merged PRs Linked to 'Not Closed' Issue ⚠️ -- 🔔 Epic: _Update System tests_ #3571 - -### Direct commits ⚠️ -All direct commits are linked pull requests. - -### Others - No Topic ⚠️ -- 🔔 PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ - - Improves and optimizes the authorization in the feed management context. - -#### Full Changelog -https://github.com/absa-group/AUL/compare/v1.2.0...v1.3.0 diff --git a/Unify-issue-hierarchy-v2.md b/Unify-issue-hierarchy-v2.md deleted file mode 100644 index 6f8f91a0..00000000 --- a/Unify-issue-hierarchy-v2.md +++ /dev/null @@ -1,147 +0,0 @@ -### No entry 🚫 -No entries detected. - -### Breaking Changes 💥 -No entries detected. - -### New Features 🎉 -- #3186 _Improve authorization in FeedManagement context_ in #3701 - - Improves and optimizes the authorization in the feed management context. -- #3207 _Allow Unify app admins and data ops roles to run TriggerManualExecutionCommand_ in #3762 - - Added app-admins and data-ops to allowed users -- PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ - - Improves and optimizes the authorization in the feed management context. -- #3470 _FE: Ability to delete draft/testing version_ in #3663 -- #3479 _Implement model layer domain major versions merge and mergeablility functionality_ in #3633 - - Introduces functionality related to merging of domain major versions to Domain model layer -- #3481 _Implement `MergeBackDomainMajorVersionCommand` handler_ in #3666 - - Introduces `MergeBackDomainMajorVersionCommandHandler` in domain-management bounded context - -### Bugfixes 🛠 -- #3637 _Partitions fetched from the glue catalog are not imported correctly_ in #3699 - - Fix importing partitions from glue table -- #3644 _Model validation failing after removal of non-latest major version_ in #3645 -- #3683 _Owners and stewards cannot edit domain through UI if change involves PatchDomainCommand_ in #3692 - - fixed domain secondary owners and stewards not being able to edit certain domain properties through UI even though they should be authorized to do so (like description), by not performing authorization on `PatchDomainCommand` parts that didn't change but were sent in the request anyway -- #3714 _SchemaCompatibilityChecker should return incompatible when columns are being removed._ in #3733 - - Currently the SchemaCompatibilityChecker returns compatible if fields are removed but this can be seen as a breaking change to existing schema's, therefore it has been changed to return incompatible when fields are removed. -- #3726 _Output partition column is missing after editing target schema_ in #3743 - - Bug Fix: fix output partition column disappearing after editing target schema and add a dedicated Cypress scenario to prevent regressions. -- #3750 _Final DF is not cached in the Spark Job_ in #3751 - - removed cache on primary dataset DF and added one on final (post-interpretation) DF to improve dual-write performence -- #3756 _DB: Missing DB function permissions (ownership / execution)_ in #3757 - -### Infrastructure ⚙️ -- PR: #3732 _Add UI team ownership for ci-ui-checks.yml_ -- #3768 _Unify: Add business value chapter to issue templates_ in #3769 - -### Silent-live 🤫 -No entries detected. - -### Documentation 📜 -- #3621 _Create an ADR for event-based domain scheduling_ in #3636 - -### New Epics -- Feature: _Promote from Pre-prod to prod and copy from Prod to IJAT_ #3655 -- Feature: _Use Glue Tables explicitly as feed data sources_ #3674 -- Feature: _Marking dataset ready for the current day and triggering runs_ #3686 -- Feature: _`runs` topic events consumption_ #3687 -- Feature: _Event-based scheduling mode selection_ #3688 -- Feature: _Exclusion of data sources from the scheduling_ #3689 - -### Silent Live -- Epic: _Make formula builder more user-friendly_ #1188 -- Epic: _Group By + Aggregations after Join(s)_ #1668 -- Epic: _Add automation to GitHub project_ #1674 -- Epic: _Implement data controls for foundation domains_ #1857 -- Epic: _UI unit tests_ #2319 -- Epic: _Update Cypress tests_ #2847 -- Epic: _Manual once-off run for a range of dates_ #2873 -- Epic: _Allow cross-account granting & revoking READ access to domain Glue tables_ #3006 -- Feature: _Listing AWS accounts with access to a domain's Glue table_ #3246 -- Feature: _Manually add Glue & Hive partitions when possible_ #3284 -- Feature: _Allow active domain major version compatible schema evolution_ #3292 -- Feature: _Country code selection for ARO_ #3297 -- Feature: _Feed management - Delete data feed version_ #3457 -- Epic: _Improve UI testing_ #3498 -- Feature: _Revoking readonly access for domain's Glue tables to AWS account_ #3525 -- Epic: _Update System tests_ #3571 -- Feature: _PWD Rotation [dev] - keytab replacement and unify service rotation_ #3619 - -### Closed Epics -- Feature: _Include error message in failed runs endpoint and view_ #3426 -- Feature: _Domain management - Delete domain version_ #3456 -- Feature: _Sending events about runs to EventBus_ #3580 -- Feature: _Create an ADR for Event-based scheduling of domains_ #3620 - -### Closed Issues without Pull Request ⚠️ -- 🔔 Feature: _Include error message in failed runs endpoint and view_ #3426 -- 🔔 Feature: _Domain management - Delete domain version_ #3456 -- 🔔 Feature: _Sending events about runs to EventBus_ #3580 -- 🔔 Feature: _Create an ADR for Event-based scheduling of domains_ #3620 - -### Closed Issues without User Defined Labels ⚠️ -- #2836 _Fix ESLint config _ in #3731 -- #3300 _Add `executionID` match assertions to domain major versions flow API tests_ in #3728, #3523 - - added tests in getJobs to assert executionID returned in response is evaluated to match actual vs expected - - Timeout increased to wait few mins for eventual consistency -- 🔔 Feature: _Include error message in failed runs endpoint and view_ #3426 -- #3483 _Handle `DomainMajorVersionMergedBackEvent` event in data-cataloging_ in #3696 - - Add and implement new service: `updateDomainMajorVersionLiveTables` - - Add Appropriate controller endpoint for the above - - Utilize the above in domainmajorversionmergedbackevent handler to update tables in place - - Truncate and delete merging data after - - implement tetst -- #3485 _Handle `DomainMajorVersionMergedBackEvent` in feed-management_ in #3573 - - Introduces `DomainMajorVersionMergedBackEventHandler` to handle `DomainMajorVersionMergedBackEvent` - - Adds `feed_management.update_feeds_for_merged_domain_major_versions` plpgsql function that updates feed versions for domain major versions related to merge operation -- #3555 _Update Login e2e test_ in #3604 -- #3630 _Remove redundant DTOs from DE bounded ctx_ in #3631 -- #3640 _Migrate LDAP account to rotatable ssm param object_ in #3670, #3641 - - Unify LDAP service account credentials are now loaded from /datassets/{aul-dev|aul-uat|unify}/service-accounts/ad-service-account-credentials. - - Unify LDAP service account credentials are now loaded from `/datassets/aul-dev/ad-service-account-credentials`. -- #3646 _Update Dashboard system test_ in #3668 -- #3647 _Update Data-feed system test_ in #3673 -- #3678 _Remove typescript files from /assets folder_ in #3677 -- #3680 _DEV+UAT to share common non-prod service accounts space (config change)_ in #3681 - - Unify LDAP service account credentials for UAT and DEV are now loaded from `/datassets/aul-nonprod/service-accounts/ad-service-account-credentials`. -- #3690 _Implement DDL for the state management_ in #3711 -- #3691 _Implement DB function for the state management_ in #3729 - * EventBus integration: implementing state management DB functions for reacting on incoming events and changes in domain automatic triggering -- #3703 _Implement BE DTO & API that will represent `ProcessAvailableDatasetCommand`_ in #3713 - - added `ProcessAvailableDatasetCommand` API (without implementation) -- #3705 _Make `DataSource#location` an ADT instead of String_ in #3715 - - Introduces possibility to define location of a data source by either using Glue table or S3 access point. -- #3716 _Add and implement `GetDomainMajorVersionDefaultDataSourcesQuery`_ in #3763 - - added `GetDomainMajorVersionDefaultDataSourcesQuery` which returns all data sources used in latest `Ready` feed versions for a given domain major version -- #3719 _Add event based scheduling option to `Schedule` DTO_ in #3727 - - added Schedule.DailyWindowEventBasedSchedule option of Schedule in domain-execution API -- #3721 _Implement DB function to upsert scheduling in EBS DB tables for a domain major version_ in #3760 -- #3722 _Implement DB function to remove domain major version from EBS DB tables_ in #3759 -- #3730 _Add an ESLint rule to check for skipped/focused unit tests_ in #3731 -- #3740 _svc-ursa-unify accounts rotation support_ in #3748 - - webapp-svc-account credentials are now loaded from `/datassets/{aul-nonprod|unify}/service-accounts/spring-service-account-credentials` - -### Merged PRs without Issue and User Defined Labels ⚠️ -- PR: #3639 _#3638 Add location type to runs service, DB, and UI_ - - added location type (Live/Testing) for which a run was triggered to related endpoints and UI view -- PR: #3739 _rename db file_ -- PR: #3752 _Add missing MergeBackDomainMajorVersionCommand to AuthorizationManager_ -- PR: #3753 _Fix order of arguments in DomainMajorVersionMergedBack_ -- PR: #3761 _api tests fixes_ - -### Closed PRs without Issue and User Defined Labels ⚠️ -All closed PRs are linked to issues. - -### Merged PRs Linked to 'Not Closed' Issue ⚠️ -- 🔔 Epic: _Update System tests_ #3571 - -### Direct commits ⚠️ -All direct commits are linked pull requests. - -### Others - No Topic ⚠️ -- 🔔 PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ - - Improves and optimizes the authorization in the feed management context. - -#### Full Changelog -https://github.com/absa-group/AUL/compare/v1.2.0...v1.3.0 diff --git a/Unify-issue-hierarchy-v3.md b/Unify-issue-hierarchy-v3.md deleted file mode 100644 index de5d8448..00000000 --- a/Unify-issue-hierarchy-v3.md +++ /dev/null @@ -1,141 +0,0 @@ -### No entry 🚫 -No entries detected. - -### Breaking Changes 💥 -No entries detected. - -### New Features 🎉 -- PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ - - Improves and optimizes the authorization in the feed management context. - -### Bugfixes 🛠 -No entries detected. - -### Infrastructure ⚙️ -No entries detected. - -### Silent-live 🤫 -No entries detected. - -### Documentation 📜 -No entries detected. - -### New Epics -- Feature: _Promote from Pre-prod to prod and copy from Prod to IJAT_ #3655 - -### Silent Live Epics -- Epic: _Make formula builder more user-friendly_ #1188 -- Epic: _Implement some of the missing info about individual domains_ #1343 -- Epic: _Group By + Aggregations after Join(s)_ #1668 -- Epic: _Add automation to GitHub project_ #1674 -- Epic: _Implement data controls for foundation domains_ #1857 -- Epic: _UI unit tests_ #2319 -- Epic: _Update Cypress tests_ #2847 - - #3555 _Update Login e2e test_ in #3604 -- Epic: _Manual once-off run for a range of dates_ #2873 -- Epic: _Support AD security group access assignment for domain roles and consumers_ #2883 -- Epic: _Event-based scheduling MVP _ #2960 - - Feature: _Sending events about runs to EventBus_ #3580 - - #3638 _Add location type into GetJobRunsResult and GetJobErrorDetailsResult_ in - - Feature: _Create an ADR for Event-based scheduling of domains_ #3620 - - #3621 _Create an ADR for event-based domain scheduling_ in #3636 - - #3622 _Consult an ADR about event-based domain scheduling with the team for having the consensus_ in - - Feature: _Use Glue Tables explicitly as feed data sources_ #3674 - - #3705 _Make `DataSource#location` an ADT instead of String_ in #3715 - - Introduces possibility to define location of a data source by either using Glue table or S3 access point. - - Feature: _Marking dataset ready for the current day and triggering runs_ #3686 - - #3690 _Implement DDL for the state management_ in #3711 - - #3691 _Implement DB function for the state management_ in #3729 - * EventBus integration: implementing state management DB functions for reacting on incoming events and changes in domain automatic triggering - - #3702 _Implement DB function that resets the domain freshness_ in - - #3703 _Implement BE DTO & API that will represent `ProcessAvailableDatasetCommand`_ in #3713 - - added `ProcessAvailableDatasetCommand` API (without implementation) - - Feature: _Event-based scheduling mode selection_ #3688 - - #3716 _Add and implement `GetDomainMajorVersionDefaultDataSourcesQuery`_ in #3763 - - added `GetDomainMajorVersionDefaultDataSourcesQuery` which returns all data sources used in latest `Ready` feed versions for a given domain major version - - #3719 _Add event based scheduling option to `Schedule` DTO_ in #3727 - - added Schedule.DailyWindowEventBasedSchedule option of Schedule in domain-execution API - - #3721 _Implement DB function to upsert scheduling in EBS DB tables for a domain major version_ in #3760 - - #3722 _Implement DB function to remove domain major version from EBS DB tables_ in #3759 - - Feature: _`runs` topic events consumption_ #3687 - - Feature: _Exclusion of data sources from the scheduling_ #3689 -- Epic: _Allow cross-account granting & revoking READ access to domain Glue tables_ #3006 - - Feature: _Listing AWS accounts with access to a domain's Glue table_ #3246 - - Feature: _Revoking readonly access for domain's Glue tables to AWS account_ #3525 -- Epic: _Solution for externalized configs_ #3201 -- Epic: _Send execution completion events to an tenant-specified SQS queue_ #3203 -- Epic: _API 1-shot test - reach 100% coverage_ #3206 -- Epic: _Setup Glue jobs and enable running the Spark compute engine on Glue_ #3219 -- Epic: _Enable ARO Data segregation - RTSIS (Tanzania ABT requirement)_ #3251 - - Feature: _Country code selection for ARO_ #3297 -- Epic: _Display overall stats on the landing page_ #3262 -- Feature: _Manually add Glue & Hive partitions when possible_ #3284 -- Feature: _Allow active domain major version compatible schema evolution_ #3292 - - #3479 _Implement model layer domain major versions merge and mergeablility functionality_ in #3633 - - Introduces functionality related to merging of domain major versions to Domain model layer - - #3481 _Implement `MergeBackDomainMajorVersionCommand` handler_ in #3666 - - Introduces `MergeBackDomainMajorVersionCommandHandler` in domain-management bounded context - - #3483 _Handle `DomainMajorVersionMergedBackEvent` event in data-cataloging_ in #3696 - - Add and implement new service: `updateDomainMajorVersionLiveTables` - - Add Appropriate controller endpoint for the above - - Utilize the above in domainmajorversionmergedbackevent handler to update tables in place - - Truncate and delete merging data after - - implement tetst - - #3485 _Handle `DomainMajorVersionMergedBackEvent` in feed-management_ in #3573 - - Introduces `DomainMajorVersionMergedBackEventHandler` to handle `DomainMajorVersionMergedBackEvent` - - Adds `feed_management.update_feeds_for_merged_domain_major_versions` plpgsql function that updates feed versions for domain major versions related to merge operation - - #3714 _SchemaCompatibilityChecker should return incompatible when columns are being removed._ in #3733 - - Currently the SchemaCompatibilityChecker returns compatible if fields are removed but this can be seen as a breaking change to existing schema's, therefore it has been changed to return incompatible when fields are removed. -- Feature: _Feed management - Delete data feed version_ #3457 -- Epic: _Improve UI testing_ #3498 -- Feature: _PWD Rotation [dev] - keytab replacement and unify service rotation_ #3619 - - #3640 _Migrate LDAP account to rotatable ssm param object_ in #3670, #3641 - - Unify LDAP service account credentials are now loaded from /datassets/{aul-dev|aul-uat|unify}/service-accounts/ad-service-account-credentials. - - Unify LDAP service account credentials are now loaded from `/datassets/aul-dev/ad-service-account-credentials`. - - #3643 _Create UAT+PROD ldap-svc records_ in - - #3680 _DEV+UAT to share common non-prod service accounts space (config change)_ in #3681 - - Unify LDAP service account credentials for UAT and DEV are now loaded from `/datassets/aul-nonprod/service-accounts/ad-service-account-credentials`. - - #3660 _Migrate hive-keytab usage to password-rotation compatible location_ in - - #3661 _Employ password/keytab rotation solution_ in - - #3662 _Create a wokflow integrating rotation step_ in - - #3693 _setup hive-service account params in param store to be rotated_ in - - #3741 _Manual rotation of `svc-ursa-unify-prd` and `svc-ursa-aul-dev`_ in - - #3740 _svc-ursa-unify accounts rotation support_ in #3748 - - webapp-svc-account credentials are now loaded from `/datassets/{aul-nonprod|unify}/service-accounts/spring-service-account-credentials` - -### Closed Epics -- Feature: _Include error message in failed runs endpoint and view_ #3426 -- Feature: _Domain management - Delete domain version_ #3456 - - #3470 _FE: Ability to delete draft/testing version_ in #3663 - - #3644 _Model validation failing after removal of non-latest major version_ in #3645 - -### Closed Issues without Pull Request ⚠️ -- Feature: _Include error message in failed runs endpoint and view_ #3426 -- Feature: _Domain management - Delete domain version_ #3456 - - #3470 _FE: Ability to delete draft/testing version_ in #3663 - - #3644 _Model validation failing after removal of non-latest major version_ in #3645 - -### Closed Issues without User Defined Labels ⚠️ -- Feature: _Include error message in failed runs endpoint and view_ #3426 - -### Merged PRs without Issue and User Defined Labels ⚠️ -- PR: #3639 _#3638 Add location type to runs service, DB, and UI_ - - added location type (Live/Testing) for which a run was triggered to related endpoints and UI view - -### Closed PRs without Issue and User Defined Labels ⚠️ -All closed PRs are linked to issues. - -### Merged PRs Linked to 'Not Closed' Issue ⚠️ -All merged PRs are linked to Closed issues. - -### Direct commits ⚠️ -All direct commits are linked pull requests. - -### Others - No Topic ⚠️ -- PR: #3303 _#3186-improve-authorization-in-feedmanagement-context_ - - Improves and optimizes the authorization in the feed management context. -- PR: #3639 _#3638 Add location type to runs service, DB, and UI_ - - added location type (Live/Testing) for which a run was triggered to related endpoints and UI view - -#### Full Changelog -https://github.com/absa-group/AUL/compare/v1.2.0...v1.3.0 From de47c3d90bbeb755c6dd39a2e7d18dcaee2edb53 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 15 Sep 2025 20:14:40 +0200 Subject: [PATCH 12/33] - Applied changes: - Reduced inputs. Login refactored to be more generic. - One hierarchy unit test is working. - Known problem with multiple calls of labels API during rls notes generation. It is unexpected. --- README.md | 103 ++---- action.yml | 13 +- main.py | 19 +- release_notes_generator/action_inputs.py | 35 +- .../chapters/custom_chapters.py | 32 +- release_notes_generator/filter.py | 35 +- release_notes_generator/generator.py | 20 +- .../model/hierarchy_issue_record.py | 99 ++---- release_notes_generator/model/issue_record.py | 11 +- .../model/pull_request_record.py | 4 +- release_notes_generator/model/record.py | 9 +- .../model/sub_issue_record.py | 41 +++ .../record/factory/default_record_factory.py | 33 +- .../factory/issue_hierarchy_record_factory.py | 329 ++++++++---------- .../record/factory/record_factory.py | 3 +- tests/conftest.py | 26 +- .../test_release_notes_builder.py | 2 +- tests/test_action_inputs.py | 3 +- 18 files changed, 347 insertions(+), 470 deletions(-) create mode 100644 release_notes_generator/model/sub_issue_record.py diff --git a/README.md b/README.md index 5bf33809..6a8cc19e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ - [Requirements](#requirements) - [Inputs](#inputs) - [Feature controls](#feature-controls) - - [Regimes](#regimes) - [Outputs](#outputs) - [Usage Example](#usage-example) - [Features](#features) @@ -42,27 +41,26 @@ Generate Release Notes action is dedicated to enhance the quality and organizati ## Inputs -| Name | Description | Required | Default | -|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------------------------------------------| -| `GITHUB_TOKEN` | Your GitHub token for authentication. Store it as a secret and reference it in the workflow file as secrets.GITHUB_TOKEN. | Yes | | -| `tag-name` | The name of the tag for which you want to generate release notes. This should be the same as the tag name used in the release workflow. | Yes | | -| `from-tag-name` | The name of the tag from which you want to generate release notes. | No | '' | -| `chapters` | An YAML array defining chapters and corresponding labels for categorization. Each chapter should have a title and a label matching your GitHub issues and PRs. | Yes | | -| `regime` | Controls the regime of the action. Options: `default`, `issue-hierarchy`. See more about the [Regimes](#regimes). | No | `default` | -| `issue-type-weights` | Used only when `regime=issue-hierarchy`. A comma-separated list defining ordered hierarchical issue types (highest first). Listed types become hierarchy nodes (parents) in release notes. Earlier types sit higher (e.g. `Epic` above `Feature`). Unlisted issue types (e.g. `Bug`, `Task`, `Chore`) are treated as normal leaf issues: they never act as parents, gain no extra structural indentation beyond their natural placement under the nearest defined ancestor, and are rendered like standard issues with/without linked PRs. Epics may contain other Epics. If empty, all issues are rendered flat (no hierarchy). Example value: `Epic`, `Feature`. | No | `Epic, Feature` | -| `row-format-hierarchy-issue` | The format of the row for the hierarchy issue in the release notes. The format can contain placeholders for the issue `number` and `title`. The placeholders are case-sensitive. | No | `"{type}: _{title}_ {number}"` | -| `row-format-issue` | The format of the row for the issue in the release notes. The format can contain placeholders for the issue `number`, `title`, and issues `pull-requests`. The placeholders are case-sensitive. | No | `"{number} _{title}_ in {pull-requests}"` | -| `row-format-pr` | The format of the row for the PR in the release notes. The format can contain placeholders for the PR `number`, and `title`. The placeholders are case-sensitive. | No | `"{number} _{title}_"` | -| `row-format-link-pr` | If defined `true`, the PR row will begin with a `"PR: "` string. Otherwise, no prefix will be added. | No | true | -| `duplicity-scope` | Set to `custom` to allow duplicity issue lines to be shown only in custom chapters. Options: `custom`, `service`, `both`, `none`. | No | `both` | -| `duplicity-icon` | The icon used to indicate duplicity issue lines in the release notes. Icon will be placed at the beginning of the line. | No | `🔔` | -| `published-at` | Set to true to enable the use of the `published-at` timestamp as the reference point for searching closed issues and PRs, instead of the `created-at` date of the latest release. If first release, repository creation date is used. | No | false | -| `skip-release-notes-labels` | List labels used for detection if issues or pull requests are ignored in the Release Notes generation process. Example: `skip-release-notes, question`. | No | `skip-release-notes` | -| `verbose` | Set to true to enable verbose logging for detailed output during the action's execution. | No | false | -| `release-notes-title` | The title of the release notes section in the PR description. | No | `[Rr]elease [Nn]otes:` | -| `coderabbit-support-active` | Enable CodeRabbit support. If true, the action will use CodeRabbit to generate release notes. | No | false | -| `coderabbit-release-notes-title` | The title of the CodeRabbit summary in the PR body. Value supports regex. | No | `Summary by CodeRabbit` | -| `coderabbit-summary-ignore-groups` | List of "group names" to be ignored by release notes detection logic. Example: `Documentation, Tests, Chores, Bug Fixes`. | No | '' | +| Name | Description | Required | Default | +|------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------------------------------------------| +| `GITHUB_TOKEN` | Your GitHub token for authentication. Store it as a secret and reference it in the workflow file as secrets.GITHUB_TOKEN. | Yes | | +| `tag-name` | The name of the tag for which you want to generate release notes. This should be the same as the tag name used in the release workflow. | Yes | | +| `from-tag-name` | The name of the tag from which you want to generate release notes. | No | '' | +| `chapters` | An YAML array defining chapters and corresponding labels for categorization. Each chapter should have a title and a label matching your GitHub issues and PRs. | Yes | | +| `hierarchy` | Set to true to enable issue hierarchy handling. When enabled, the action will organize issues based on their hierarchical relationships (e.g., Epics and their child issues). This is useful for projects that use issue types to represent different levels of work. | No | false | +| `row-format-hierarchy-issue` | The format of the row for the hierarchy issue in the release notes. The format can contain placeholders for the issue `number` and `title`. The placeholders are case-sensitive. | No | `"{type}: _{title}_ {number}"` | +| `row-format-issue` | The format of the row for the issue in the release notes. The format can contain placeholders for the issue `number`, `title`, and issues `pull-requests`. The placeholders are case-sensitive. | No | `"{number} _{title}_ in {pull-requests}"` | +| `row-format-pr` | The format of the row for the PR in the release notes. The format can contain placeholders for the PR `number`, and `title`. The placeholders are case-sensitive. | No | `"{number} _{title}_"` | +| `row-format-link-pr` | If defined `true`, the PR row will begin with a `"PR: "` string. Otherwise, no prefix will be added. | No | true | +| `duplicity-scope` | Set to `custom` to allow duplicity issue lines to be shown only in custom chapters. Options: `custom`, `service`, `both`, `none`. | No | `both` | +| `duplicity-icon` | The icon used to indicate duplicity issue lines in the release notes. Icon will be placed at the beginning of the line. | No | `🔔` | +| `published-at` | Set to true to enable the use of the `published-at` timestamp as the reference point for searching closed issues and PRs, instead of the `created-at` date of the latest release. If first release, repository creation date is used. | No | false | +| `skip-release-notes-labels` | List labels used for detection if issues or pull requests are ignored in the Release Notes generation process. Example: `skip-release-notes, question`. | No | `skip-release-notes` | +| `verbose` | Set to true to enable verbose logging for detailed output during the action's execution. | No | false | +| `release-notes-title` | The title of the release notes section in the PR description. | No | `[Rr]elease [Nn]otes:` | +| `coderabbit-support-active` | Enable CodeRabbit support. If true, the action will use CodeRabbit to generate release notes. | No | false | +| `coderabbit-release-notes-title` | The title of the CodeRabbit summary in the PR body. Value supports regex. | No | `Summary by CodeRabbit` | +| `coderabbit-summary-ignore-groups` | List of "group names" to be ignored by release notes detection logic. Example: `Documentation, Tests, Chores, Bug Fixes`. | No | '' | > **Notes** > - `skip-release-notes-labels` @@ -83,67 +81,6 @@ Generate Release Notes action is dedicated to enhance the quality and organizati > - `warnings` > - **Disabling this feature will hide service chapter showing direct commits!** These cannot be visible in custom chapters as they do not have labels! -### Regimes - -### Default regime - -The basic regime for this action. - -- **Data management** - - The issue type is not used. It can lead to placing Epic and other issues without linked PR into service chapters. If you need to use issue type use another regime. -- **Release notes** - - Organized by custom chapters defined by user using labels. - - Used these `types of rows`: - - Issue with/without linked Pull Request(s). - - Pull Request without linked Issue. - - Direct commits (without Issue and Pull Request). - -#### Row Examples -```markdown -TODO -``` - -### Issue Hierarchy regime - -The regime is designed to handle issues with hierarchical relationships, such as Epics and their associated child issues. It ensures that release notes are organized in a way that reflects these relationships. - -- **Data management** - - The issue type is used to manage the hierarchy of issues. -- **Release notes** - - Organized by custom chapters defined by user using labels. - - The rows are build using the highest issue in the hierarchy. The hierarchy weight is defined by user using `issue-type-weights` input. - - Used these `types of rows`: - - **Epic** - - Epic without Features - - Epic with Features - - Hint: Epic can have more Epics - - **Features** - - Feature without Epic with other types of issues - - Feature without Epic without any other types of issues - - **Any other types of issues `*`** - - `*` without Feature without PR - - `*` without Feature with PR - - **No issues** - - PR without Issue with 1+ commit - - Direct commit - -#### Row Examples -```markdown -- #120 Data Platform Revamp # Epic type - Feature #134 Incremental Extraction # Feature type - Issue #147 Split mining logic (links: #148) # Any other type of issue `*` - PR #155 new dataclass MinedData # Pull Request - Bug #162 Off‑by‑one in batch window # Can be Bug type of issue (depends on team setup) - Feature #150 UI Polish - Task #171 Add skeleton loader - Bug #176 Dark mode overflow - -Epic #200 Observability - Epic #205 Tracing Core # Example of Epic in Epic - Feature #210 OpenTelemetry Export - Issue #214 Validate span enrichment -``` - ## Outputs The output of the action is a markdown string containing the release notes for the specified tag. This string can be used in subsequent steps to publish the release notes to a file, create a GitHub release, or send notifications. diff --git a/action.yml b/action.yml index 4d4be714..8e3d3545 100644 --- a/action.yml +++ b/action.yml @@ -32,14 +32,10 @@ inputs: description: 'The tag name of the previous release to use as a start reference point for the current release notes.' required: false default: '' - regime: - description: 'Regime of the release notes generation. Options: default.' + hierarchy: + description: 'Use hierarchy of issues and pull requests.' required: false - default: 'default' - issue-type-weights: - description: 'A comma-separated list defining the order of issue types by their weight' - required: false - default: 'Epic, Feature' + default: 'false' duplicity-icon: description: 'Icon to be used for duplicity warning. Icon is placed before the record line.' required: false @@ -145,8 +141,7 @@ runs: INPUT_TAG_NAME: ${{ inputs.tag-name }} INPUT_CHAPTERS: ${{ inputs.chapters }} INPUT_FROM_TAG_NAME: ${{ inputs.from-tag-name }} - INPUT_REGIME: ${{ inputs.regime }} - INPUT_ISSUE_TYPE_WEIGHTS: ${{ inputs.issue-type-weights }} + INPUT_HIERARCHY: ${{ inputs.hierarchy }} INPUT_DUPLICITY_SCOPE: ${{ inputs.duplicity-scope }} INPUT_DUPLICITY_ICON: ${{ inputs.duplicity-icon }} INPUT_WARNINGS: ${{ inputs.warnings }} diff --git a/main.py b/main.py index 0203b74d..e7c67725 100644 --- a/main.py +++ b/main.py @@ -29,25 +29,12 @@ from release_notes_generator.generator import ReleaseNotesGenerator from release_notes_generator.chapters.custom_chapters import CustomChapters from release_notes_generator.action_inputs import ActionInputs -from release_notes_generator.model.chapter import Chapter from release_notes_generator.utils.gh_action import set_action_output from release_notes_generator.utils.logging_config import setup_logging warnings.filterwarnings("ignore", category=InsecureRequestWarning) -def prepare_custom_chapters() -> CustomChapters: - custom_chapters = CustomChapters(print_empty_chapters=ActionInputs.get_print_empty_chapters()).from_yaml_array( - ActionInputs.get_chapters() - ) - if ActionInputs.get_regime() == ActionInputs.REGIME_ISSUE_HIERARCHY: - custom_chapters.chapters[f"New {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title=f"New {ActionInputs.get_issue_type_first_level()}s") - custom_chapters.chapters[f"Silent Live {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title=f"Silent Live {ActionInputs.get_issue_type_first_level()}s") - custom_chapters.chapters[f"Closed {ActionInputs.get_issue_type_first_level()}s"] = Chapter(title=f"Closed {ActionInputs.get_issue_type_first_level()}s") - - return custom_chapters - - def run() -> None: """ The main function to run the Release Notes Generator. @@ -63,7 +50,11 @@ def run() -> None: ActionInputs.validate_inputs() - generator = ReleaseNotesGenerator(py_github, prepare_custom_chapters()) + custom_chapters = CustomChapters(print_empty_chapters=ActionInputs.get_print_empty_chapters()).from_yaml_array( + ActionInputs.get_chapters() + ) + + generator = ReleaseNotesGenerator(py_github, custom_chapters) rls_notes = generator.generate() logger.debug("Generated release notes: \n%s", rls_notes) diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index ef6b8455..548fdbda 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -64,9 +64,6 @@ class ActionInputs: A class representing the inputs provided to the GH action. """ - REGIME_DEFAULT = "default" - REGIME_ISSUE_HIERARCHY = "issue-hierarchy" - ROW_TYPE_ISSUE = "Issue" ROW_TYPE_PR = "PR" ROW_TYPE_HIERARCHY_ISSUE = "HierarchyIssue" @@ -170,29 +167,11 @@ def get_chapters() -> list[dict[str, str]]: return chapters @staticmethod - def get_regime() -> str: - """ - Get the regime parameter value from the action inputs. - """ - return get_action_input("regime", "default") # type: ignore[return-value] # default defined - - @staticmethod - def get_issue_type_weights() -> list[str]: - """ - Get the issue type weights from the action inputs. - """ - user_input = get_action_input("issue-type-weights", "Epic, Feature") - return [item.strip() for item in user_input.split(",")] if user_input else [] - - @staticmethod - def get_issue_type_first_level() -> str: + def get_hierarchy() -> bool: """ - Get the issue type first level from the action inputs. + Check if the hierarchy release notes structure is enabled. """ - types = ActionInputs.get_issue_type_weights() - if len(types) == 0: - return "Epic" - return types[0] + return get_action_input("hierarchy", "false").lower() == "true" @staticmethod def get_duplicity_scope() -> DuplicityScopeEnum: @@ -415,10 +394,8 @@ def validate_inputs() -> None: if not isinstance(duplicity_icon, str) or not duplicity_icon.strip() or len(duplicity_icon) != 1: errors.append("Duplicity icon must be a non-empty string and have a length of 1.") - regime = ActionInputs.get_regime() - ActionInputs.validate_input(regime, str, "Regime must be a string.", errors) - if regime not in [ActionInputs.REGIME_DEFAULT, ActionInputs.REGIME_ISSUE_HIERARCHY]: - errors.append(f"Regime '{regime}' is not supported.") + hierarchy = ActionInputs.get_hierarchy() + ActionInputs.validate_input(hierarchy, bool, "Verbose logging must be a boolean.", errors) warnings = ActionInputs.get_warnings() ActionInputs.validate_input(warnings, bool, "Warnings must be a boolean.", errors) @@ -476,7 +453,7 @@ def validate_inputs() -> None: logger.debug("Tag name: %s", tag_name) logger.debug("From tag name: %s", from_tag_name) logger.debug("Chapters: %s", chapters) - logger.debug("Regime: %s", regime) + logger.debug("Hierarchy: %s", hierarchy) logger.debug("Published at: %s", published_at) logger.debug("Skip release notes labels: %s", ActionInputs.get_skip_release_notes_labels()) logger.debug("Verbose logging: %s", verbose) diff --git a/release_notes_generator/chapters/custom_chapters.py b/release_notes_generator/chapters/custom_chapters.py index 31a041eb..dc1b50e1 100644 --- a/release_notes_generator/chapters/custom_chapters.py +++ b/release_notes_generator/chapters/custom_chapters.py @@ -28,6 +28,7 @@ from release_notes_generator.model.hierarchy_issue_record import HierarchyIssueRecord from release_notes_generator.model.issue_record import IssueRecord from release_notes_generator.model.record import Record +from release_notes_generator.model.sub_issue_record import SubIssueRecord from release_notes_generator.utils.enums import DuplicityScopeEnum logger = logging.getLogger(__name__) @@ -45,7 +46,6 @@ def populate(self, records: dict[int | str, Record]) -> None: @param records: A dictionary of records where the key is an integer and the value is a Record object. @return: None """ - hierarchy_active = ActionInputs.get_regime() == ActionInputs.REGIME_ISSUE_HIERARCHY for record_id, record in records.items(): # iterate all records # check if the record should be skipped if records[record_id].skip: @@ -62,40 +62,16 @@ def populate(self, records: dict[int | str, Record]) -> None: ): continue - if ( - hierarchy_active - and isinstance(records[record_id], HierarchyIssueRecord) - and not records[record_id].is_present_in_chapters - ): - self._populate_hierarchy_issue(cast(HierarchyIssueRecord, records[record_id])) - continue + pulls_count = 1 + if isinstance(records[record_id], (HierarchyIssueRecord | IssueRecord | SubIssueRecord)): + pulls_count = cast(IssueRecord, records[record_id]).pull_requests_count() for record_label in records[record_id].labels: # iterate all labels of the record (issue, or 1st PR) - pulls_count = 1 - if isinstance(records[record_id], IssueRecord): - pulls_count = cast(IssueRecord, records[record_id]).pull_requests_count() - if record_label in ch.labels and pulls_count > 0: if not records[record_id].is_present_in_chapters: ch.add_row(record_id, records[record_id].to_chapter_row(True)) self.populated_record_numbers_list.append(record_id) - def _populate_hierarchy_issue(self, record: HierarchyIssueRecord) -> None: - # detect Closed "Epic" - if record.is_closed and not record.is_present_in_chapters: - ch = self.chapters[f"Closed {ActionInputs.get_issue_type_first_level()}s"] - - # detect New root "Epic" - elif record.is_open and record.issue.created_at > self.since: - ch = self.chapters[f"New {ActionInputs.get_issue_type_first_level()}s"] - - # detect Silent living "Epic" - else: - ch = self.chapters[f"Silent Live {ActionInputs.get_issue_type_first_level()}s"] - - ch.add_row(record.record_id, record.to_chapter_row(True)) - self.populated_record_numbers_list.append(record.record_id) - def from_yaml_array(self, chapters: list[dict[str, str]]) -> "CustomChapters": """ Populates the custom chapters from a JSON string. diff --git a/release_notes_generator/filter.py b/release_notes_generator/filter.py index c88ed185..f1754a2c 100644 --- a/release_notes_generator/filter.py +++ b/release_notes_generator/filter.py @@ -112,25 +112,27 @@ def filter(self, data: MinedData) -> MinedData: def _filter_issues(self, data: MinedData) -> list: """ - Filter issues based on the selected regime. + Filter issues based on the selected filtering type - default or hierarchy. @param data: The mined data containing issues. @return: The filtered list of issues. """ - # Currently, only the default regime is implemented. - if ActionInputs.get_regime() == ActionInputs.REGIME_ISSUE_HIERARCHY: + if ActionInputs.get_hierarchy(): + logger.debug("Used hierarchy issue filtering logic.") return self._filter_issues_issue_hierarchy(data) - - logger.debug("Used default issue filtering regime.") + logger.debug("Used default issue filtering logic.") return self._filter_issues_default(data) def _filter_issues_default(self, data: MinedData) -> list: """ Default filtering for issues: filter out closed issues before the release date. - @param data: The mined data containing issues. - @return: The filtered list of issues. + Parameters: + data (MinedData): The mined data containing issues and release information. + + Returns: + list: The filtered list of issues. """ return [ issue @@ -140,21 +142,20 @@ def _filter_issues_default(self, data: MinedData) -> list: def _filter_issues_issue_hierarchy(self, data: MinedData) -> list: """ - Filtering for issues in the 'issue-hierarchy' regime: - - filter out closed issues before the release date - - keep open issues - - keep issues with types defined in `issue-type-weights` + Hierarchy filtering for issues: filter out closed issues before the release date, + but always include issues of certain types (e.g., "Epic", "Story"). - @param data: The mined data containing issues. - @return: The filtered list of issues. + Parameters: + data (MinedData): The mined data containing issues and release information. + + Returns: + list: The filtered list of issues. """ - issue_types = ActionInputs.get_issue_type_weights() return list( filter( lambda issue: ( - (issue.closed_at is not None and issue.closed_at >= data.since) - or (issue.state == "open") - or (issue.type is not None and issue.type.name in issue_types) + (issue.closed_at is not None and issue.closed_at >= data.since) # closed after the release + or (issue.state == "open") # still open ), data.issues, ) diff --git a/release_notes_generator/generator.py b/release_notes_generator/generator.py index c7f76e1d..716cc5cd 100644 --- a/release_notes_generator/generator.py +++ b/release_notes_generator/generator.py @@ -93,9 +93,8 @@ def generate(self) -> Optional[str]: assert data_filtered_by_release.repository is not None, "Repository must not be None" - # get record factory instance in dependency on active regime - rls_notes_records: dict[int | str, Record] = self._get_record_factory().generate( - github=self._github_instance, data=data_filtered_by_release + rls_notes_records: dict[int | str, Record] = self._get_record_factory(github=self._github_instance).generate( + data=data_filtered_by_release ) return ReleaseNotesBuilder( @@ -104,17 +103,16 @@ def generate(self) -> Optional[str]: changelog_url=changelog_url, ).build() - def _get_record_factory(self) -> RecordFactory: + def _get_record_factory(self, github: Github) -> RecordFactory: """ Determines and returns the appropriate RecordFactory instance based on the action inputs. Returns: RecordFactory: An instance of either IssueHierarchyRecordFactory or RecordFactory. """ - match ActionInputs.get_regime(): - case ActionInputs.REGIME_ISSUE_HIERARCHY: - logger.info("Using IssueHierarchyRecordFactory based on action inputs.") - return IssueHierarchyRecordFactory() - case _: - logger.info("Using default RecordFactory based on action inputs.") - return DefaultRecordFactory() + if ActionInputs.get_hierarchy(): + logger.info("Using IssueHierarchyRecordFactory based on action inputs.") + return IssueHierarchyRecordFactory(github) + + logger.info("Using default RecordFactory based on action inputs.") + return DefaultRecordFactory(github) diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index 28283bae..4b419b40 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -9,6 +9,7 @@ from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.issue_record import IssueRecord +from release_notes_generator.model.sub_issue_record import SubIssueRecord logger = logging.getLogger(__name__) @@ -19,18 +20,43 @@ class HierarchyIssueRecord(IssueRecord): Inherits from IssueRecord and provides additional functionality specific to issues. """ - def __init__(self, issue: Issue, issue_type: Optional[str] = None, skip: bool = False, level: int = 0): - super().__init__(issue, issue_type, skip=skip) + def __init__(self, issue: Issue, issue_labels: Optional[list[str]] = None, skip: bool = False): + super().__init__(issue, issue_labels, skip) - self.type: Optional[str] = None - self._level: int = level - self._sub_issues: dict[int, IssueRecord] = {} # sub-issues - no more sub-issues - self._sub_hierarchy_issues: dict[int, HierarchyIssueRecord] = {} # sub-hierarchy issues - have sub-issues + self._level: int = 0 + self._sub_issues: dict[int, SubIssueRecord] = {} + self._sub_hierarchy_issues: dict[int, HierarchyIssueRecord] = {} - @lru_cache(maxsize=None) - def get_labels(self) -> set[str]: - labels = set() - labels.update(self._issue.get_labels()) + @property + def level(self) -> int: + return self._level + + @level.setter + def level(self, value: int) -> None: + self._level = value + + @property + def sub_issues(self): + return self._sub_issues + + @property + def sub_hierarchy_issues(self): + return self._sub_hierarchy_issues + + def pull_requests_count(self) -> int: + count = super().pull_requests_count() + + for sub_issue in self._sub_issues.values(): + count += sub_issue.pull_requests_count() + + for sub_hierarchy_issue in self._sub_hierarchy_issues.values(): + count += sub_hierarchy_issue.pull_requests_count() + + return count + + def get_labels(self) -> list[str]: + labels: set[str] = set() + labels.update(label.name for label in self._issue.get_labels()) for sub_issue in self._sub_issues.values(): labels.update(sub_issue.labels) @@ -39,9 +65,9 @@ def get_labels(self) -> set[str]: labels.update(sub_hierarchy_issue.labels) for pull in self._pull_requests.values(): - labels.update(pull.get_labels()) + labels.update(label.name for label in pull.get_labels()) - return labels + return list(labels) # methods - override ancestor methods def to_chapter_row(self, add_into_chapters: bool = False) -> str: @@ -99,52 +125,3 @@ def to_chapter_row(self, add_into_chapters: bool = False) -> str: # No data loss - in service chapter there will be all detail not presented here return row - - def register_hierarchy_issue(self, issue: Issue) -> "HierarchyIssueRecord": - """ - Registers a sub-hierarchy issue. - - Parameters: - issue: The sub-hierarchy issue to register. - Returns: - The registered sub-hierarchy issue record. - """ - sub_rec = HierarchyIssueRecord(issue=issue, issue_type=issue.type.name, level=self._level + 1) - self._sub_hierarchy_issues[issue.number] = sub_rec - logger.debug("Registered sub-hierarchy issue '%d' to parent issue '%d'", issue.number, self.issue.number) - return sub_rec - - def register_issue(self, issue: Issue) -> IssueRecord: - """ - Registers a sub-issue. - - Parameters: - issue: The sub-issue to register. - Returns: - The registered sub-issue record. - """ - sub_rec = IssueRecord(issue=issue) - self._sub_issues[issue.number] = sub_rec - logger.debug("Registered sub-issue '%d' to parent issue '%d'", issue.number, self.issue.number) - return sub_rec - - def register_pull_request_in_hierarchy(self, issue_number: int, pull: PullRequest) -> None: - if issue_number in self._sub_issues.keys(): - self._sub_issues[issue_number].register_pull_request(pull) - return - - if issue_number in self._sub_hierarchy_issues.keys(): - self._sub_hierarchy_issues[issue_number].register_pull_request(pull) - return - - def find_issue(self, issue_number: int) -> Optional["IssueRecord"]: - if issue_number in self._sub_issues.keys(): - return self._sub_issues[issue_number] - elif issue_number in self._sub_hierarchy_issues.keys(): - return self._sub_hierarchy_issues[issue_number] - else: - for rec in self._sub_hierarchy_issues.values(): - found = rec.find_issue(issue_number) - if found is not None: - return found - return None diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py index f6ad165f..84129bdc 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -26,14 +26,16 @@ class IssueRecord(Record): ISSUE_STATE_OPEN = "open" ISSUE_STATE_ALL = "all" - def __init__(self, issue: Issue, issue_type: Optional[str] = None, skip: bool = False): + def __init__(self, issue: Issue, issue_labels: Optional[list[str]] = None, skip: bool = False): super().__init__(skip=skip) self._issue: Issue = issue - self._issue_type: Optional[str] = issue_type + self._labels = issue_labels if issue_labels is not None else [] if issue is not None and issue.type is not None: self._issue_type = issue.type.name + else: + self._issue_type = None self._pull_requests: dict[int, PullRequest] = {} self._commits: dict[int, dict[str, Commit]] = {} @@ -79,9 +81,8 @@ def issue_type(self) -> Optional[str]: # methods - override Record methods - @lru_cache(maxsize=None) - def get_labels(self) -> set[str]: - return {label.name for label in self._issue.get_labels()} + def get_labels(self) -> list[str]: + return [label.name for label in self._issue.get_labels()] def find_issue(self, issue_number: int) -> Optional["IssueRecord"]: if self._issue.number == issue_number: diff --git a/release_notes_generator/model/pull_request_record.py b/release_notes_generator/model/pull_request_record.py index b8461f3a..3af497ea 100644 --- a/release_notes_generator/model/pull_request_record.py +++ b/release_notes_generator/model/pull_request_record.py @@ -22,8 +22,8 @@ class PullRequestRecord(Record): PR_STATE_CLOSED = "closed" PR_STATE_OPEN = "open" - def __init__(self, pull: PullRequest, skip: bool = False): - super().__init__(skip=skip) + def __init__(self, pull: PullRequest, labels: Optional[list[str]] = None, skip: bool = False): + super().__init__(labels, skip) self._pull_request: PullRequest = pull self._commits: dict[str, Commit] = {} diff --git a/release_notes_generator/model/record.py b/release_notes_generator/model/record.py index c2d6b4c7..3d239197 100644 --- a/release_notes_generator/model/record.py +++ b/release_notes_generator/model/record.py @@ -33,12 +33,11 @@ class Record(metaclass=ABCMeta): RELEASE_NOTE_LINE_MARKS: list[str] = ["-", "*", "+"] - # def __init__(self, issue: Optional[Issue] = None, skip: bool = False): - def __init__(self, skip: bool = False): + def __init__(self, labels: Optional[list[str]] = None, skip: bool = False): self._present_in_chapters = 0 self._skip = skip self._is_release_note_detected: Optional[bool] = None - self._labels: set[str] = set() + self._labels = labels if labels is not None else [] self._rls_notes: Optional[str] = None # single annotation here # properties @@ -66,7 +65,7 @@ def labels(self) -> list[str]: if len(self._labels) == 0: self._labels = self.get_labels() - return list(self._labels) + return self._labels @property @abstractmethod @@ -114,7 +113,7 @@ def to_chapter_row(self, add_into_chapters: bool = False) -> str: """ @abstractmethod - def get_labels(self) -> set[str]: + def get_labels(self) -> list[str]: """ Gets the labels of the record. Returns: diff --git a/release_notes_generator/model/sub_issue_record.py b/release_notes_generator/model/sub_issue_record.py new file mode 100644 index 00000000..e0386ed7 --- /dev/null +++ b/release_notes_generator/model/sub_issue_record.py @@ -0,0 +1,41 @@ +""" +A module that defines the IssueRecord class, which represents an issue record in the release notes. +""" +import logging +from typing import Optional, cast + +from github.Commit import Commit +from github.Issue import SubIssue +from github.PullRequest import PullRequest + +from release_notes_generator.model.issue_record import IssueRecord + +logger = logging.getLogger(__name__) + + +class SubIssueRecord(IssueRecord): + """ + A class used to represent an issue record in the release notes. + Inherits from Record and provides additional functionality specific to issues. + """ + + def __init__(self, sub_issue: SubIssue, issue_labels: Optional[list[str]] = None, skip: bool = False): + super().__init__(sub_issue, issue_labels, skip) + + self._labels = issue_labels if issue_labels is not None else [] + + self._pull_requests: dict[int, PullRequest] = {} + self._commits: dict[int, dict[str, Commit]] = {} + + # properties - override IssueRecord properties + + @property + def issue(self) -> SubIssue: + """ + Gets the issue associated with the record. + Returns: The issue associated with the record. + """ + return cast(SubIssue, self._issue) + + # properties - specific to IssueRecord + diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index a9c290fa..7ca46cef 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -19,7 +19,7 @@ """ import logging -from typing import cast +from typing import cast, Optional from github import Github from github.Issue import Issue @@ -45,12 +45,15 @@ class DefaultRecordFactory(RecordFactory): """ A class used to generate records for release notes. """ + def __init__(self, github: Github) -> None: + self._github: Github = github + rate_limiter = GithubRateLimiter(github) + self._safe_call = safe_call_decorator(rate_limiter) - def generate(self, github: Github, data: MinedData) -> dict[int | str, Record]: + def generate(self, data: MinedData) -> dict[int | str, Record]: """ Generate records for release notes. Parameters: - github (GitHub): The GitHub instance to generate records for. data (MinedData): The MinedData instance containing repository, issues, pull requests, and commits. Returns: dict[int|str, Record]: A dictionary of records where the key is the issue or pull request number. @@ -59,7 +62,7 @@ def generate(self, github: Github, data: MinedData) -> dict[int | str, Record]: def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: detected_issues = extract_issue_numbers_from_body(pull) logger.debug("Detected issues - from body: %s", detected_issues) - detected_issues.update(safe_call(get_issues_for_pr)(pull_number=pull.number)) + detected_issues.update(safe_call(get_issues_for_pr)(pull_number=pull.number)) # TODO - safe call is now inside logger.debug("Detected issues - final: %s", detected_issues) for parent_issue_number in detected_issues: @@ -75,7 +78,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: safe_call(data.repository.get_issue)(parent_issue_number) if data.repository else None ) if parent_issue is not None: - DefaultRecordFactory.create_record_for_issue(records, parent_issue) + DefaultRecordFactory._create_record_for_issue(records, parent_issue) if parent_issue_number in records: cast(IssueRecord, records[parent_issue_number]).register_pull_request(pull) @@ -89,12 +92,12 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: ) records: dict[int | str, Record] = {} - rate_limiter = GithubRateLimiter(github) + rate_limiter = GithubRateLimiter(self._github) safe_call = safe_call_decorator(rate_limiter) logger.debug("Registering issues to records...") for issue in data.issues: - DefaultRecordFactory.create_record_for_issue(records, issue) + DefaultRecordFactory._create_record_for_issue(records, issue) logger.debug("Registering pull requests to records...") for pull in data.pull_requests: @@ -150,16 +153,20 @@ def register_commit_to_record(records: dict[int | str, Record], commit: Commit) return False @staticmethod - def create_record_for_issue(records: dict[int | str, Record], i: Issue) -> None: + def _create_record_for_issue(records: dict[int | str, Record], i: Issue, issue_labels: Optional[list[str]] = None) -> None: """ Create a record for an issue. - @param i: Issue instance. - @return: None + Parameters: + records (dict[int|str, Record]): The dictionary of records to add the issue record to. + i (Issue): The issue to create a record for. + issue_labels (Optional[list[str]]): Optional set of labels for the issue. If not provided, labels will be fetched from the issue. + Returns: + None """ # check for skip labels presence and skip when detected - issue_labels = [label.name for label in i.get_labels()] + if issue_labels is None: + issue_labels = {label.name for label in i.get_labels()} skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) records[i.number] = IssueRecord(issue=i, skip=skip_record) - - logger.debug("Created record for issue %d: %s", i.number, i.title) + logger.debug("Created record for non hierarchy issue %d: %s", i.number, i.title) diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 5a274697..b1072c4b 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -33,10 +33,9 @@ from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.pull_request_record import PullRequestRecord from release_notes_generator.model.record import Record +from release_notes_generator.model.sub_issue_record import SubIssueRecord from release_notes_generator.record.factory.default_record_factory import DefaultRecordFactory -from release_notes_generator.utils.decorators import safe_call_decorator -from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter from release_notes_generator.utils.pull_request_utils import get_issues_for_pr, extract_issue_numbers_from_body logger = logging.getLogger(__name__) @@ -47,212 +46,188 @@ class IssueHierarchyRecordFactory(DefaultRecordFactory): """ A class used to generate records for release notes. """ + def __init__(self, github: Github) -> None: + super().__init__(github) - def generate(self, github: Github, data: MinedData) -> dict[int | str, Record]: + self.__records: dict[int | str, Record] = {} + self.__registered_issues: list[int] = [] + self.__sub_issue_parents: dict[int, int] = {} + self.__registered_commits: list[str] = [] + + def generate(self, data: MinedData) -> dict[int | str, Record]: """ Generate records for release notes. Parameters: - github (GitHub): The GitHub instance to generate records for. data (MinedData): The MinedData instance containing repository, issues, pull requests, and commits. Returns: dict[int|str, Record]: A dictionary of records where the key is the issue or pull request number. """ - - def register_pull_request(pull: PullRequest, skip_rec: bool) -> Optional["IssueRecord"]: - detected_issues = extract_issue_numbers_from_body(pull) - logger.debug("Detected issues - from body: %s", detected_issues) - detected_issues.update(safe_call(get_issues_for_pr)(pull_number=pull.number)) - logger.debug("Detected issues - final: %s", detected_issues) - - for parent_issue_number in detected_issues: - # try to find an issue record if not present for PR parent - if parent_issue_number in data_issue_numbers: - # find it and register - rec = records.get(parent_issue_number) - if rec is None: - # the parent issue is sub-issue of some hierarchy issue - find the hierarchy issue and register to it - for r in records.values(): - if isinstance(r, HierarchyIssueRecord): - rec = cast(HierarchyIssueRecord, r).find_issue(parent_issue_number) - if rec is not None: - break - - if rec is not None and isinstance(rec, PullRequestRecord): - continue - elif rec is not None and isinstance(rec, IssueRecord): - rec.register_pull_request(pull) - return rec - elif rec is not None and isinstance(rec, HierarchyIssueRecord): - rec.register_pull_request_in_hierarchy(parent_issue_number, pull) - return rec - - else: # parent_issue_number not in data_issue_numbers: - logger.warning( - "Detected PR %d linked to issue %d which is not in the list of received issues. " - "Fetching ...", - pull.number, - parent_issue_number, - ) - parent_issue = ( - safe_call(data.repository.get_issue)(parent_issue_number) if data.repository else None - ) - if parent_issue is not None: - IssueHierarchyRecordFactory.create_record_for_issue(records, parent_issue) - data_issue_numbers.append(parent_issue_number) - rec = cast(IssueRecord, records[parent_issue_number]) - rec.register_pull_request(pull) - logger.debug("Registering PR %d: %s to Issue %d", pull.number, pull.title, parent_issue_number) - return rec - - # solo PR found (no linked issue found) - if parent_issue_number not in data_issue_numbers: - logger.debug( - "Registering stand-alone PR %d: %s as mentioned Issue %d not found.", - pull.number, - pull.title, - parent_issue_number, - ) - return None - - records: dict[int | str, Record] = {} - rate_limiter = GithubRateLimiter(github) - safe_call = safe_call_decorator(rate_limiter) - registered_issues: list[int] = [] - data_issue_numbers = [issue.number for issue in data.issues] - - logger.debug("Registering hierarchy issues to records...") - # create hierarchy issue records first => dict of hierarchy issues with registered sub-issues - # 1. round - the most heavy ones (e.g. Epic) - # 2. round - the second most heavy ones (e.g. Features) - # 3. round - the rest (e.g. Bugs, Tasks, etc.) but can be just another hierarchy - depend on configuration - for hierarchy_issue in ActionInputs.get_issue_type_weights(): - logger.debug("Registering hierarchy issues of type: %s", hierarchy_issue) - for issue in data.issues: - issue_labels: list[Label] = list(issue.get_labels()) - issue_type = self._get_issue_type(issue, issue_labels) - if issue_type is not None and issue_type == hierarchy_issue and issue.number not in registered_issues: - sub_issues = list(issue.get_sub_issues()) - if len(sub_issues) == 0: - continue # not a hierarchy issue even if labeled or issue type say so - - self.create_record_for_hierarchy_issue(records, issue, issue_type, issue_labels) - registered_issues.append(issue.number) - rec: HierarchyIssueRecord = cast(HierarchyIssueRecord, records[issue.number]) - if len(sub_issues) > 0: - self._solve_sub_issues(rec, data, data_issue_numbers, registered_issues, sub_issues) - - # create or register non-hierarchy issue related records - logger.debug("Registering issues to records...") + logger.debug("Creation of records started...") for issue in data.issues: - if issue.number not in registered_issues: - parent_issue = issue.get_sub_issues() - if parent_issue is not None: - pass # TODO - find parent and register to it - else: - IssueHierarchyRecordFactory.create_record_for_issue(records, issue) + if issue.number in self.__registered_issues: + continue - logger.debug("Registering pull requests to records and commit to Pull Requests...") - registered_commits: list[str] = [] - for pull in data.pull_requests: - pull_labels = [label.name for label in pull.get_labels()] - skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) - related_commits = [c for c in data.commits if c.sha == pull.merge_commit_sha] - registered_commits.extend(c.sha for c in related_commits) - - if not safe_call(get_issues_for_pr)(pull_number=pull.number) and not extract_issue_numbers_from_body(pull): - pr_rec = PullRequestRecord(pull, skip=skip_record) - for c in related_commits: # register commits to the PR record - pr_rec.register_commit(c) - records[pull.number] = pr_rec - logger.debug("Created record for PR %d: %s", pull.number, pull.title) + self._create_issue_record_using_sub_issues_existence(issue) - else: - logger.debug("Registering pull number: %s, title : %s", pull.number, pull.title) - rec: IssueRecord = register_pull_request(pull, skip_record) - for c in related_commits: # register commits to the PR record - if rec is not None: - rec.register_commit(pull, c) + # dev note: Each issue is now in records dict by its issue number - all on same level - no hierarchy + # This is useful for population by PRs and commits + + logger.debug("Registering Commits to Pull Requests and Pull Requests to Issues...") + for pull in data.pull_requests: + self._register_pull_and_its_commits_to_issue(pull, data, self.__records, self.__registered_commits) logger.debug("Registering direct commits to records...") for commit in data.commits: - if commit.sha not in registered_commits: - records[commit.sha] = CommitRecord(commit) + if commit.sha not in self.__registered_commits: + self.__records[commit.sha] = CommitRecord(commit) + + # dev note: now we have all PRs and commits registered to issues or as stand-alone records + # let build hierarchy + logger.debug("Building issues hierarchy...") + self._re_register_hierarchy_issues() + self.order_hierarchy_levels() logger.info( "Generated %d records from %d issues and %d PRs, with %d commits detected.", - len(records), + len(self.__records), len(data.issues), len(data.pull_requests), len(data.commits), ) - return records - - def _solve_sub_issues( - self, - record: HierarchyIssueRecord, - data: MinedData, - data_issue_numbers: list[int], - registered_issues: list[int], - sub_issues: list[SubIssue], - ) -> None: - logger.debug("Solving sub issues for hierarchy issue record %d", record.issue.number) - - for sub_issue in sub_issues: # closed in previous rls, in current one, open ones - logger.debug("Processing sub-issue %d", sub_issue.number) - - if sub_issue.number in registered_issues: # already registered - logger.debug("Sub-issue %d already registered, skipping", sub_issue.number) - continue - if sub_issue.number not in data_issue_numbers: # not closed in current rls or not opened == not in mined data - logger.debug("Sub-issue %d not registered, skipping", sub_issue.number) - continue + return self.__records + + def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: MinedData, + records: dict[int | str, Record], registered_commits: list[str]) -> None: + pull_labels = [label.name for label in pull.get_labels()] + skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) + related_commits = [c for c in data.commits if c.sha == pull.merge_commit_sha] + registered_commits.extend(c.sha for c in related_commits) + + pull_issues = self._safe_call(get_issues_for_pr)(pull_number=pull.number) + pull_issues.update(extract_issue_numbers_from_body(pull)) + if len(pull_issues) > 0: + for issue_number in pull_issues: + if issue_number not in records.keys(): + logger.warning( + "Detected PR %d linked to issue %d which is not in the list of received issues. " + "Fetching ...", + pull.number, + issue_number, + ) + parent_issue = ( + self._safe_call(data.repository.get_issue)(issue_number) if data.repository else None + ) + if parent_issue is not None: + self._create_issue_record_using_sub_issues_existence(parent_issue) - sub_sub_issues = list(sub_issue.get_sub_issues()) - if len(sub_sub_issues) > 0: - logger.debug("Solving sub issues for sub-issue: %s", sub_issue.number) - rec = record.register_hierarchy_issue(sub_issue) - registered_issues.append(sub_issue.number) - self._solve_sub_issues(rec, data, data_issue_numbers, registered_issues, sub_sub_issues) - else: - logger.debug("Solving sub issues for sub-issue: %s", sub_issue.number) - record.register_issue(sub_issue) - registered_issues.append(sub_issue.number) + if issue_number in records.keys() and isinstance(records[issue_number], (SubIssueRecord, HierarchyIssueRecord, IssueRecord)): + rec = cast(IssueRecord, records[issue_number]) + rec.register_pull_request(pull) + logger.debug("Registering pull number: %s, title : %s", pull.number, pull.title) - @staticmethod - def create_record_for_hierarchy_issue(records: dict[int | str, Record], i: Issue, issue_type: Optional[str], - issue_labels: list[Label]) -> None: + for c in related_commits: # register commits to the PR record + rec.register_commit(pull, c) + logger.debug("Registering commit %s to PR %d", c.sha, pull.number) + + pr_rec = PullRequestRecord(pull, pull_labels, skip_record) + for c in related_commits: # register commits to the PR record + pr_rec.register_commit(c) + records[pull.number] = pr_rec + logger.debug("Created record for PR %d: %s", pull.number, pull.title) + + def _create_issue_record_using_sub_issues_existence(self, issue: Issue) -> None: + # use presence of sub-issues as a hint for hierarchy issue or non hierarchy issue + sub_issues = list(issue.get_sub_issues()) + if len(sub_issues) == 0: + self._create_record_for_issue(issue) + else: + self._create_record_for_hierarchy_issue(issue) + for si in sub_issues: + self._create_record_for_sub_issue(si) + # register sub-issue and its parent for later hierarchy building + self.__sub_issue_parents[si.number] = issue.number # Note: GitHub now allows only 1 parent + + def _create_record_for_hierarchy_issue(self, i: Issue, issue_labels: Optional[list[str]] = None) -> None: """ - Create a record for an issue. + Create a hierarchy issue record and register sub-issues. Parameters: - records: The records to create the record for. - i: Issue instance. - issue_type: The type of the issue. + i: The issue to create the record for. issue_labels: The labels of the issue. Returns: None """ # check for skip labels presence and skip when detected - issue_labels_names = [label.name for label in issue_labels] - skip_record = any(item in issue_labels_names for item in ActionInputs.get_skip_release_notes_labels()) - records[i.number] = HierarchyIssueRecord(issue=i, issue_type=issue_type, skip=skip_record) - logger.debug("Created record for hierarchy issue %d: %s (type: %s)", i.number, i.title, issue_type) + if issue_labels is None: + issue_labels: list[str] = self._get_issue_labels_mix_with_type(i) + skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) + + self.__records[i.number] = HierarchyIssueRecord(issue=i, skip=skip_record) + logger.debug("Created record for hierarchy issue %d: %s", i.number, i.title) + + def _get_issue_labels_mix_with_type(self, issue: Issue) -> list[str]: + labels: list[str] = [label.name for label in issue.get_labels()] - @staticmethod - def _get_issue_type(issue: Issue, issue_labels: list[Label]) -> Optional[str]: if issue.type is not None: - return issue.type.name - - if len(issue_labels) > 0: - issue_labels_lower = [label.name.lower() for label in issue_labels] - issue_types = [issue_type.lower() for issue_type in ActionInputs.get_issue_type_weights()] - # Find all matching types and their indices in the original list - matching_indices = [ - idx for idx, t in enumerate(issue_types) if t in issue_labels_lower - ] - if matching_indices: - # Return the type from the original list with the lowest index - return ActionInputs.get_issue_type_weights()[min(matching_indices)] - - return None + issue_type = issue.type.name.lower() + if issue_type not in labels: + labels.append(issue_type) + + return labels + + def _create_record_for_issue(self, issue: Issue, issue_labels: Optional[list[Label]] = None) -> None: + if issue_labels is None: + issue_labels: list[str] = self._get_issue_labels_mix_with_type(issue) + + super()._create_record_for_issue(self.__records, issue, issue_labels) + self.__registered_issues.append(issue.number) + + def _create_record_for_sub_issue(self, sub_issue: SubIssue, issue_labels: Optional[list[Label]] = None) -> None: + if issue_labels is None: + issue_labels: list[str] = self._get_issue_labels_mix_with_type(sub_issue) + + skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) + logger.debug("Created record for sub issue %d: %s", sub_issue.number, sub_issue.title) + self.__registered_issues.append(sub_issue.number) + self.__records[sub_issue.number] = SubIssueRecord(sub_issue, issue_labels, skip_record) + + def _re_register_hierarchy_issues(self): + sub_issues_numbers = list(self.__sub_issue_parents.keys()) + + for sub_issue_number in sub_issues_numbers: + # remove issue(sub_issue_number) from current records and add it to parent + # as sub-issue or sub-hierarchy-issue + # but do it only for issue where parent issue number is not in _sub_issue_parents keys + # Why? We building hierarchy from bottom. Access in records is very easy. + parent_issue_nr: int = self.__sub_issue_parents[sub_issue_number] + if parent_issue_nr in self.__sub_issue_parents.keys(): + continue + else: + parent_rec = cast(HierarchyIssueRecord, self.__sub_issue_parents[parent_issue_nr]) + sub_rec = self.__records[sub_issue_number] + + if isinstance(sub_rec, SubIssueRecord): + parent_rec.sub_issues.append(sub_rec) # add to parent as SubIssueRecord + self.__records.pop(sub_issue_number) # remove from main records as it is sub-one + self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one + elif isinstance(sub_rec, HierarchyIssueRecord): + parent_rec.sub_issues.append(sub_rec) # add to parent as 'Sub' HierarchyIssueRecord + self.__records.pop(sub_issue_number) # remove from main records as it is sub-one + self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one + else: + logger.error("Detected IssueRecord in position of SubIssueRecord - skipping it") + # Dev note: IssueRecord is expected to be stand-alone - not sub-issue + + if len(self.__sub_issue_parents.items()) > 0: + self._re_register_hierarchy_issues() + + def order_hierarchy_levels(self, level: int = 0): + # we have now all hierarchy issues in records - but levels are not set + # we need to set levels for proper rendering + # This have to be done from up to down + top_hierarchy_records = [rec for rec in self.__records.values() if isinstance(rec, HierarchyIssueRecord)] + for rec in top_hierarchy_records: + rec.level = level + for sub_hierarchy_record in rec.sub_hierarchy_issues.values(): + sub_hierarchy_record.order_hierarchy_levels(level=level + 1) diff --git a/release_notes_generator/record/factory/record_factory.py b/release_notes_generator/record/factory/record_factory.py index 3a90ec24..615a3cdb 100644 --- a/release_notes_generator/record/factory/record_factory.py +++ b/release_notes_generator/record/factory/record_factory.py @@ -33,11 +33,10 @@ class RecordFactory(metaclass=abc.ABCMeta): """ @abc.abstractmethod - def generate(self, github: Github, data: MinedData) -> dict[int | str, Record]: + def generate(self, data: MinedData) -> dict[int | str, Record]: """ Generate records for release notes. Parameters: - github (GitHub): The GitHub instance to generate records for. data (MinedData): The MinedData instance containing repository, issues, pull requests, and commits. Returns: dict[int|str, Record]: A dictionary of records where the key is the issue or pull request number. diff --git a/tests/conftest.py b/tests/conftest.py index 419c8c55..c627ccb0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,7 @@ from release_notes_generator.chapters.service_chapters import ServiceChapters from release_notes_generator.model.chapter import Chapter from release_notes_generator.chapters.custom_chapters import CustomChapters +from release_notes_generator.model.sub_issue_record import SubIssueRecord from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter @@ -58,11 +59,9 @@ def custom_chapters(): def custom_chapters_not_print_empty_chapters(): chapters = CustomChapters() chapters.chapters = { + "Epics": Chapter("Epics", ["epic"]), "Chapter 1": Chapter("Chapter 1 🛠", ["bug", "enhancement"]), "Chapter 2": Chapter("Chapter 2 🎉", ["feature"]), - "New Epics": Chapter("New Epics", []), - "Silent Live Epics": Chapter("Silent Live Epics", []), - "Closed Epics": Chapter("Closed Epics", []), } chapters.print_empty_chapters = False return chapters @@ -249,7 +248,7 @@ def mock_open_hierarchy_issue_feature(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" label2 = mocker.Mock(spec=MockLabel) - label2.name = "label2" + label2.name = "label3" issue.get_labels.return_value = [label1, label2] return issue @@ -561,13 +560,18 @@ def record_with_issue_closed_two_pulls(request): @pytest.fixture def record_with_hierarchy_issues(request): rec_epic_issue = HierarchyIssueRecord(issue=request.getfixturevalue("mock_open_hierarchy_issue_epic")) # nr:200 - rec_feature_issue = rec_epic_issue.register_hierarchy_issue(request.getfixturevalue("mock_open_hierarchy_issue_feature")) # nr:201 + rec_epic_issue._labels = ["epic"] # override labels to have epic label + rec_feature_issue = HierarchyIssueRecord(request.getfixturevalue("mock_open_hierarchy_issue_feature")) # nr:201 + rec_feature_issue.level = 1 + rec_epic_issue.sub_hierarchy_issues[rec_feature_issue.issue.number] = rec_feature_issue + + rec_task_issue = SubIssueRecord(request.getfixturevalue("mock_closed_issue_type_task")) # nr:202 + rec_feature_issue.sub_issues[rec_task_issue.issue.number] = rec_task_issue - issue_task = request.getfixturevalue("mock_closed_issue_type_task") # nr:202 - rec_task_issue = rec_feature_issue.register_hierarchy_issue(issue_task) # add sub_issue - sub_issue_no_type = request.getfixturevalue("mock_closed_issue_type_none") # nr:204 - rec_sub_issue_no_type = rec_task_issue.register_issue(sub_issue_no_type) + rec_sub_issue_no_type = SubIssueRecord(request.getfixturevalue("mock_closed_issue_type_none")) # nr:204 + rec_feature_issue.sub_issues[rec_sub_issue_no_type.issue.number] = rec_sub_issue_no_type + # add pr to sub_issue sub_issue_merged_pr = request.getfixturevalue("mock_pull_merged_with_rls_notes_102") # nr:205 sub_issue_merged_pr.number = 205 # simulate PR closing sub-issue nr:204 @@ -575,8 +579,8 @@ def record_with_hierarchy_issues(request): sub_issue_merged_pr.title = "Sub issue 204 closed by merged PR" rec_sub_issue_no_type.register_pull_request(sub_issue_merged_pr) - issue_bug = request.getfixturevalue("mock_closed_issue_type_bug") # nr:203 - rec_bug_issue = rec_feature_issue.register_issue(issue_bug) + rec_bug_issue = SubIssueRecord(request.getfixturevalue("mock_closed_issue_type_bug")) # nr:203 + rec_feature_issue.sub_issues[rec_bug_issue.issue.number] = rec_bug_issue # not description keyword used - registration simulate API way (relation) rec_task_issue.register_pull_request(request.getfixturevalue("mock_pull_closed_with_rls_notes_101")) diff --git a/tests/release_notes/test_release_notes_builder.py b/tests/release_notes/test_release_notes_builder.py index e7368941..26421da5 100644 --- a/tests/release_notes/test_release_notes_builder.py +++ b/tests/release_notes/test_release_notes_builder.py @@ -974,7 +974,7 @@ def test_build_hierarchy_issue_with_one_custom_label( rec_3 = record_with_pr_only rec_4 = record_with_direct_commit mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) - mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_regime", return_value=ActionInputs.REGIME_ISSUE_HIERARCHY) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=True) mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") builder = ReleaseNotesBuilder( diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index 8eb2373c..4ea0d675 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -25,7 +25,7 @@ "get_tag_name": "tag_name", "get_from_tag_name": "from_tag_name", "get_chapters": [{"title": "Title", "label": "Label"}], - "get_regime": "default", + "get_hierarchy": "false", "get_duplicity_scope": "custom", "get_duplicity_icon": "🔁", "get_warnings": True, @@ -56,7 +56,6 @@ ("get_release_notes_title", "", "Release Notes title must be a non-empty string and have non-zero length."), ("get_coderabbit_release_notes_title", "", "CodeRabbit Release Notes title must be a non-empty string and have non-zero length."), ("get_coderabbit_summary_ignore_groups", [""], "CodeRabbit summary ignore groups must be a non-empty string and have non-zero length."), - ("get_regime", "not_supported", "Regime 'not_supported' is not supported."), ] From 62a5e0ec1b94c17e14bc4edf955472a864822493 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 15 Sep 2025 21:35:20 +0200 Subject: [PATCH 13/33] - Fixed origin unit tests. - Applied black. --- release_notes_generator/action_inputs.py | 8 ++- release_notes_generator/filter.py | 10 +--- .../model/commit_record.py | 4 +- .../model/hierarchy_issue_record.py | 5 +- release_notes_generator/model/issue_record.py | 3 +- .../model/pull_request_record.py | 2 +- release_notes_generator/model/record.py | 9 ++- .../model/sub_issue_record.py | 2 +- .../record/factory/default_record_factory.py | 9 ++- .../factory/issue_hierarchy_record_factory.py | 28 +++++----- tests/conftest.py | 4 +- .../release_notes/model/test_commit_record.py | 55 ++++++++----------- tests/release_notes/model/test_record.py | 10 +++- tests/release_notes/test_record_factory.py | 14 ++--- tests/test_action_inputs.py | 2 +- 15 files changed, 85 insertions(+), 80 deletions(-) diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index 548fdbda..fd0d5bf4 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -48,7 +48,9 @@ CODERABBIT_RELEASE_NOTES_TITLE, CODERABBIT_RELEASE_NOTE_TITLE_DEFAULT, CODERABBIT_SUMMARY_IGNORE_GROUPS, - ROW_FORMAT_HIERARCHY_ISSUE, SUPPORTED_ROW_FORMAT_KEYS_ISSUE, SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST, + ROW_FORMAT_HIERARCHY_ISSUE, + SUPPORTED_ROW_FORMAT_KEYS_ISSUE, + SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST, SUPPORTED_ROW_FORMAT_KEYS_HIERARCHY_ISSUE, ) from release_notes_generator.utils.enums import DuplicityScopeEnum @@ -394,8 +396,8 @@ def validate_inputs() -> None: if not isinstance(duplicity_icon, str) or not duplicity_icon.strip() or len(duplicity_icon) != 1: errors.append("Duplicity icon must be a non-empty string and have a length of 1.") - hierarchy = ActionInputs.get_hierarchy() - ActionInputs.validate_input(hierarchy, bool, "Verbose logging must be a boolean.", errors) + hierarchy: bool = ActionInputs.get_hierarchy() + ActionInputs.validate_input(hierarchy, bool, "Hierarchy must be a boolean.", errors) warnings = ActionInputs.get_warnings() ActionInputs.validate_input(warnings, bool, "Warnings must be a boolean.", errors) diff --git a/release_notes_generator/filter.py b/release_notes_generator/filter.py index f1754a2c..33ddf3c0 100644 --- a/release_notes_generator/filter.py +++ b/release_notes_generator/filter.py @@ -134,11 +134,7 @@ def _filter_issues_default(self, data: MinedData) -> list: Returns: list: The filtered list of issues. """ - return [ - issue - for issue in data.issues - if (issue.closed_at is None) or (issue.closed_at >= data.since) - ] + return [issue for issue in data.issues if (issue.closed_at is None) or (issue.closed_at >= data.since)] def _filter_issues_issue_hierarchy(self, data: MinedData) -> list: """ @@ -154,8 +150,8 @@ def _filter_issues_issue_hierarchy(self, data: MinedData) -> list: return list( filter( lambda issue: ( - (issue.closed_at is not None and issue.closed_at >= data.since) # closed after the release - or (issue.state == "open") # still open + (issue.closed_at is not None and issue.closed_at >= data.since) # closed after the release + or (issue.state == "open") # still open ), data.issues, ) diff --git a/release_notes_generator/model/commit_record.py b/release_notes_generator/model/commit_record.py index dcc4e74c..dbb11f85 100644 --- a/release_notes_generator/model/commit_record.py +++ b/release_notes_generator/model/commit_record.py @@ -51,14 +51,14 @@ def commit(self) -> Commit: # methods - override Record methods - def to_chapter_row(self, add_into_chapters: bool = False) -> str: + def to_chapter_row(self, add_into_chapters: bool = True) -> str: if add_into_chapters: self.added_into_chapters() row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters() > 1 else "" # collecting values for formatting commit_message = self._commit.commit.message.replace("\n", " ") - row = f"{row_prefix}Commit: {self._commit.sha[:7]} - {commit_message}" + row = f"{row_prefix}Commit: {self._commit.sha[:7]}... - {commit_message}" if self.contains_release_notes(): row = f"{row}\n{self.get_rls_notes()}" diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index 4b419b40..b364aeee 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -1,6 +1,7 @@ """ A module that defines the IssueRecord class, which represents an issue record in the release notes. """ + import logging from functools import lru_cache from typing import Optional, Any @@ -70,7 +71,7 @@ def get_labels(self) -> list[str]: return list(labels) # methods - override ancestor methods - def to_chapter_row(self, add_into_chapters: bool = False) -> str: + def to_chapter_row(self, add_into_chapters: bool = True) -> str: if add_into_chapters: self.added_into_chapters() row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters() > 1 else "" @@ -114,7 +115,7 @@ def to_chapter_row(self, add_into_chapters: bool = False) -> str: sub_indent = " " * (self._level + 1) for sub_issue in self._sub_issues.values(): if sub_issue.is_open: - continue # only closed issues are reported in release notes + continue # only closed issues are reported in release notes sub_issue_block = "- " + sub_issue.to_chapter_row() ind_child_block = "\n".join( diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py index 84129bdc..4028be29 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -1,6 +1,7 @@ """ A module that defines the IssueRecord class, which represents an issue record in the release notes. """ + import logging import re from functools import lru_cache @@ -90,7 +91,7 @@ def find_issue(self, issue_number: int) -> Optional["IssueRecord"]: else: return None - def to_chapter_row(self, add_into_chapters: bool = False) -> str: + def to_chapter_row(self, add_into_chapters: bool = True) -> str: if add_into_chapters: self.added_into_chapters() row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters() > 1 else "" diff --git a/release_notes_generator/model/pull_request_record.py b/release_notes_generator/model/pull_request_record.py index 3af497ea..ca78af00 100644 --- a/release_notes_generator/model/pull_request_record.py +++ b/release_notes_generator/model/pull_request_record.py @@ -109,7 +109,7 @@ def contributors(self) -> list[str]: def get_labels(self) -> set[str]: return {label.name for label in self._pull_request.get_labels()} - def to_chapter_row(self, add_into_chapters: bool = False) -> str: + def to_chapter_row(self, add_into_chapters: bool = True) -> str: if add_into_chapters: self.added_into_chapters() diff --git a/release_notes_generator/model/record.py b/release_notes_generator/model/record.py index 3d239197..0625d88a 100644 --- a/release_notes_generator/model/record.py +++ b/release_notes_generator/model/record.py @@ -106,10 +106,15 @@ def authors(self) -> list[str]: # abstract methods @abstractmethod - def to_chapter_row(self, add_into_chapters: bool = False) -> str: + def to_chapter_row(self, add_into_chapters: bool = True) -> str: """ Converts the record to a string row in a chapter. - @return: The record as a row string. + + Parameters: + add_into_chapters (bool): Whether to increment the chapter count for this record. + + Returns: + str: The string representation of the record in a chapter row. """ @abstractmethod diff --git a/release_notes_generator/model/sub_issue_record.py b/release_notes_generator/model/sub_issue_record.py index e0386ed7..b888edeb 100644 --- a/release_notes_generator/model/sub_issue_record.py +++ b/release_notes_generator/model/sub_issue_record.py @@ -1,6 +1,7 @@ """ A module that defines the IssueRecord class, which represents an issue record in the release notes. """ + import logging from typing import Optional, cast @@ -38,4 +39,3 @@ def issue(self) -> SubIssue: return cast(SubIssue, self._issue) # properties - specific to IssueRecord - diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index 7ca46cef..27a686c8 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -45,6 +45,7 @@ class DefaultRecordFactory(RecordFactory): """ A class used to generate records for release notes. """ + def __init__(self, github: Github) -> None: self._github: Github = github rate_limiter = GithubRateLimiter(github) @@ -62,7 +63,9 @@ def generate(self, data: MinedData) -> dict[int | str, Record]: def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: detected_issues = extract_issue_numbers_from_body(pull) logger.debug("Detected issues - from body: %s", detected_issues) - detected_issues.update(safe_call(get_issues_for_pr)(pull_number=pull.number)) # TODO - safe call is now inside + detected_issues.update( + safe_call(get_issues_for_pr)(pull_number=pull.number) + ) # TODO - safe call is now inside logger.debug("Detected issues - final: %s", detected_issues) for parent_issue_number in detected_issues: @@ -153,7 +156,9 @@ def register_commit_to_record(records: dict[int | str, Record], commit: Commit) return False @staticmethod - def _create_record_for_issue(records: dict[int | str, Record], i: Issue, issue_labels: Optional[list[str]] = None) -> None: + def _create_record_for_issue( + records: dict[int | str, Record], i: Issue, issue_labels: Optional[list[str]] = None + ) -> None: """ Create a record for an issue. diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index b1072c4b..6683ba1c 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -46,6 +46,7 @@ class IssueHierarchyRecordFactory(DefaultRecordFactory): """ A class used to generate records for release notes. """ + def __init__(self, github: Github) -> None: super().__init__(github) @@ -96,8 +97,9 @@ def generate(self, data: MinedData) -> dict[int | str, Record]: ) return self.__records - def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: MinedData, - records: dict[int | str, Record], registered_commits: list[str]) -> None: + def _register_pull_and_its_commits_to_issue( + self, pull: PullRequest, data: MinedData, records: dict[int | str, Record], registered_commits: list[str] + ) -> None: pull_labels = [label.name for label in pull.get_labels()] skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) related_commits = [c for c in data.commits if c.sha == pull.merge_commit_sha] @@ -114,23 +116,23 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined pull.number, issue_number, ) - parent_issue = ( - self._safe_call(data.repository.get_issue)(issue_number) if data.repository else None - ) + parent_issue = self._safe_call(data.repository.get_issue)(issue_number) if data.repository else None if parent_issue is not None: self._create_issue_record_using_sub_issues_existence(parent_issue) - if issue_number in records.keys() and isinstance(records[issue_number], (SubIssueRecord, HierarchyIssueRecord, IssueRecord)): + if issue_number in records.keys() and isinstance( + records[issue_number], (SubIssueRecord, HierarchyIssueRecord, IssueRecord) + ): rec = cast(IssueRecord, records[issue_number]) rec.register_pull_request(pull) logger.debug("Registering pull number: %s, title : %s", pull.number, pull.title) - for c in related_commits: # register commits to the PR record + for c in related_commits: # register commits to the PR record rec.register_commit(pull, c) logger.debug("Registering commit %s to PR %d", c.sha, pull.number) pr_rec = PullRequestRecord(pull, pull_labels, skip_record) - for c in related_commits: # register commits to the PR record + for c in related_commits: # register commits to the PR record pr_rec.register_commit(c) records[pull.number] = pr_rec logger.debug("Created record for PR %d: %s", pull.number, pull.title) @@ -145,7 +147,7 @@ def _create_issue_record_using_sub_issues_existence(self, issue: Issue) -> None: for si in sub_issues: self._create_record_for_sub_issue(si) # register sub-issue and its parent for later hierarchy building - self.__sub_issue_parents[si.number] = issue.number # Note: GitHub now allows only 1 parent + self.__sub_issue_parents[si.number] = issue.number # Note: GitHub now allows only 1 parent def _create_record_for_hierarchy_issue(self, i: Issue, issue_labels: Optional[list[str]] = None) -> None: """ @@ -208,12 +210,12 @@ def _re_register_hierarchy_issues(self): sub_rec = self.__records[sub_issue_number] if isinstance(sub_rec, SubIssueRecord): - parent_rec.sub_issues.append(sub_rec) # add to parent as SubIssueRecord - self.__records.pop(sub_issue_number) # remove from main records as it is sub-one + parent_rec.sub_issues.append(sub_rec) # add to parent as SubIssueRecord + self.__records.pop(sub_issue_number) # remove from main records as it is sub-one self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one elif isinstance(sub_rec, HierarchyIssueRecord): - parent_rec.sub_issues.append(sub_rec) # add to parent as 'Sub' HierarchyIssueRecord - self.__records.pop(sub_issue_number) # remove from main records as it is sub-one + parent_rec.sub_issues.append(sub_rec) # add to parent as 'Sub' HierarchyIssueRecord + self.__records.pop(sub_issue_number) # remove from main records as it is sub-one self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one else: logger.error("Detected IssueRecord in position of SubIssueRecord - skipping it") diff --git a/tests/conftest.py b/tests/conftest.py index c627ccb0..bc5b8a88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -507,9 +507,9 @@ def mock_pull_no_rls_notes(mocker): @pytest.fixture def mock_commit(mocker): commit = mocker.Mock() - commit.author = "author" + commit.author.login = "author" commit.sha = "merge_commit_sha" - commit.message = "Fixed bug" + commit.commit.message = "Fixed bug" return commit # Fixtures for Record(s) diff --git a/tests/release_notes/model/test_commit_record.py b/tests/release_notes/model/test_commit_record.py index 41c6d084..d960f2ca 100644 --- a/tests/release_notes/model/test_commit_record.py +++ b/tests/release_notes/model/test_commit_record.py @@ -1,76 +1,65 @@ import types from release_notes_generator.model.commit_record import CommitRecord +from tests.conftest import mock_commit + class DummyInnerCommit: def __init__(self, sha: str, message: str): self.sha = sha self.message = message -class DummyCommit: - def __init__(self, sha: str, message: str, author_login: str | None = None): - self.sha = sha - self.commit = DummyInnerCommit(sha, message) - self.author = types.SimpleNamespace(login=author_login) if author_login else None - -def test_basic_properties(): - commit = DummyCommit("abcdef1234567890", "Fix: something") - rec = CommitRecord(commit) - assert rec.record_id == "abcdef1234567890" +def test_basic_properties(mock_commit): + rec = CommitRecord(mock_commit) + assert rec.record_id == "merge_commit_sha" assert rec.is_closed is True assert rec.is_open is False - assert rec.labels == [] assert rec.skip is False -def test_authors_with_author(): - commit = DummyCommit("abcdef1", "Message", author_login="alice") - rec = CommitRecord(commit) - assert rec.authors == ["alice"] +def test_authors_with_author(mock_commit): + rec = CommitRecord(mock_commit) + assert rec.authors == ["author"] -def test_authors_no_author(): - commit = DummyCommit("abcdef1", "Message", author_login=None) - rec = CommitRecord(commit) +def test_authors_no_author(mock_commit): + mock_commit.author = None + rec = CommitRecord(mock_commit) assert rec.authors == [] -def test_to_chapter_row_single_occurrence(monkeypatch): +def test_to_chapter_row_single_occurrence(monkeypatch, mock_commit): monkeypatch.setattr( "release_notes_generator.model.commit_record.ActionInputs.get_duplicity_icon", lambda: "🔁", ) - commit = DummyCommit("1234567890abcd", "First line\nSecond line") - rec = CommitRecord(commit) + rec = CommitRecord(mock_commit) row = rec.to_chapter_row() # Newline in message replaced by space, no prefix on first addition - assert row.startswith("Commit: 1234567 - First line Second line") + assert row.startswith("Commit: merge_c... - Fixed bug") assert "🔁" not in row -def test_to_chapter_row_duplicate_with_icon(monkeypatch): +def test_to_chapter_row_duplicate_with_icon(monkeypatch, mock_commit): monkeypatch.setattr( "release_notes_generator.model.commit_record.ActionInputs.get_duplicity_icon", lambda: "[D]", ) - commit = DummyCommit("feedbeefcafebab0", "Some change") - rec = CommitRecord(commit) - first = rec.to_chapter_row() - second = rec.to_chapter_row() + rec = CommitRecord(mock_commit) + first = rec.to_chapter_row(True) + second = rec.to_chapter_row(True) assert not first.startswith("[D] ") assert second.startswith("[D] ") assert rec.present_in_chapters() == 2 -def test_to_chapter_row_with_release_notes_injected(monkeypatch): +def test_to_chapter_row_with_release_notes_injected(monkeypatch, mock_commit): # Force contains_release_notes to True and provide fake release notes monkeypatch.setattr( CommitRecord, "contains_release_notes", lambda self: True, ) - commit = DummyCommit("aa11bb22cc33", "Add feature") - rec = CommitRecord(commit) + rec = CommitRecord(mock_commit) monkeypatch.setattr(rec, "get_rls_notes", lambda line_marks=None: "Extra release notes.") row = rec.to_chapter_row() assert "\nExtra release notes." in row -def test_get_rls_notes_empty(): - commit = DummyCommit("deadbeef1234", "Refactor") - rec = CommitRecord(commit) +def test_get_rls_notes_empty(mock_commit): + rec = CommitRecord(mock_commit) assert rec.get_rls_notes() == "" diff --git a/tests/release_notes/model/test_record.py b/tests/release_notes/model/test_record.py index 4d0bc07a..ddac8a3e 100644 --- a/tests/release_notes/model/test_record.py +++ b/tests/release_notes/model/test_record.py @@ -13,12 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. # +from typing import Optional from release_notes_generator.model.record import Record class DummyRecord(Record): - def __init__(self, skip=False, labels=None, authors=None, closed=True, record_id=1, rls_notes="notes"): - super().__init__(skip) + def __init__(self, skip=False, labels=None, authors=None, closed=True, record_id=1, rls_notes: Optional[str]="notes"): + super().__init__(labels, skip) self._labels = labels or ["bug", "feature"] self._authors = authors or ["alice", "bob"] self._closed = closed @@ -45,12 +46,15 @@ def labels(self): def authors(self): return self._authors - def to_chapter_row(self): + def to_chapter_row(self, add_into_chapters: bool = True): return f"Row for {self._record_id}" def get_rls_notes(self, line_marks=None): return self._rls_notes + def get_labels(self) -> list[str]: + return self._labels + def test_is_present_in_chapters(): rec = DummyRecord() assert not rec.is_present_in_chapters diff --git a/tests/release_notes/test_record_factory.py b/tests/release_notes/test_record_factory.py index 9c8ebdeb..51a7fcdb 100644 --- a/tests/release_notes/test_record_factory.py +++ b/tests/release_notes/test_record_factory.py @@ -188,7 +188,7 @@ def test_generate_with_issues_and_pulls_and_commits(mocker, mock_repo): data.commits = [commit1, commit2, commit3] data.repository = mock_repo - records = DefaultRecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory(mock_github_client).generate(data) # Check if records for issues and PRs were created assert len(records) == 3 @@ -228,7 +228,7 @@ def test_generate_with_issues_and_pulls_and_commits_with_skip_labels(mocker, moc data.pull_requests = [pr1, pr2] data.commits = [commit1, commit2, commit3] - records = DefaultRecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory(mock_github_client).generate(data) # Check if records for issues and PRs were created assert len(records) == 3 @@ -278,7 +278,7 @@ def test_generate_with_no_commits(mocker, mock_repo): data.commits = [] # No commits data.repository = mock_repo mocker.patch("release_notes_generator.record.factory.default_record_factory.get_issues_for_pr", return_value=[2]) - records = DefaultRecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory(mock_github_client).generate(data) assert 2 == len(records) @@ -313,7 +313,7 @@ def test_generate_with_no_commits_with_wrong_issue_number_in_pull_body_mention(m data.commits = [] # No commits data.repository = mock_repo mocker.patch("release_notes_generator.record.factory.default_record_factory.get_issues_for_pr", return_value=[2]) - records = DefaultRecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory(mock_github_client).generate(data) assert 2 == len(records) @@ -351,7 +351,7 @@ def test_generate_with_no_issues(mocker, request): data.repository = request.getfixturevalue("mock_repo") data.issues = [] # No issues - records = DefaultRecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory(mock_github_client).generate(data) # Verify the record creation assert 2 == len(records) @@ -393,7 +393,7 @@ def test_generate_with_no_issues_skip_labels(mocker, request): data.repository = request.getfixturevalue("mock_repo") data.issues = [] # No issues - records = DefaultRecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory(mock_github_client).generate(data) # Verify the record creation assert 2 == len(records) @@ -423,7 +423,7 @@ def test_generate_with_no_pulls(mocker, mock_repo): data.repository = mock_repo data.pull_requests = [] # No pull requests data.commits = [] # No commits - records = DefaultRecordFactory().generate(mock_github_client, data) + records = DefaultRecordFactory(mock_github_client).generate(data) # Verify the record creation assert 2 == len(records) diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index 4ea0d675..beae123f 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -25,7 +25,7 @@ "get_tag_name": "tag_name", "get_from_tag_name": "from_tag_name", "get_chapters": [{"title": "Title", "label": "Label"}], - "get_hierarchy": "false", + "get_hierarchy": False, "get_duplicity_scope": "custom", "get_duplicity_icon": "🔁", "get_warnings": True, From 8c2976d70896d2844100627915c35dc4b22e9d94 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 15 Sep 2025 21:45:30 +0200 Subject: [PATCH 14/33] - Partial fix of mypy alerts. --- release_notes_generator/action_inputs.py | 5 ++++- release_notes_generator/model/commit_record.py | 4 ++-- release_notes_generator/model/pull_request_record.py | 5 ++--- .../record/factory/default_record_factory.py | 2 +- .../record/factory/issue_hierarchy_record_factory.py | 2 +- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index fd0d5bf4..3a573848 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -173,7 +173,10 @@ def get_hierarchy() -> bool: """ Check if the hierarchy release notes structure is enabled. """ - return get_action_input("hierarchy", "false").lower() == "true" + val = get_action_input("hierarchy", "false") + if val is None: + return False + return val.lower() == "true" @staticmethod def get_duplicity_scope() -> DuplicityScopeEnum: diff --git a/release_notes_generator/model/commit_record.py b/release_notes_generator/model/commit_record.py index dbb11f85..0dd4949c 100644 --- a/release_notes_generator/model/commit_record.py +++ b/release_notes_generator/model/commit_record.py @@ -69,7 +69,7 @@ def get_rls_notes(self, line_marks: Optional[list[str]] = None) -> str: # Hint: direct commits does not support release notes return "" - def get_labels(self) -> set[str]: - return set() + def get_labels(self) -> list[str]: + return [] # methods - specific to CommitRecord diff --git a/release_notes_generator/model/pull_request_record.py b/release_notes_generator/model/pull_request_record.py index ca78af00..3ca0b8ff 100644 --- a/release_notes_generator/model/pull_request_record.py +++ b/release_notes_generator/model/pull_request_record.py @@ -105,9 +105,8 @@ def contributors(self) -> list[str]: # methods - override Record methods - @lru_cache(maxsize=None) - def get_labels(self) -> set[str]: - return {label.name for label in self._pull_request.get_labels()} + def get_labels(self) -> list[str]: + return [label.name for label in self._pull_request.get_labels()] def to_chapter_row(self, add_into_chapters: bool = True) -> str: if add_into_chapters: diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index 27a686c8..b01dd9c7 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -171,7 +171,7 @@ def _create_record_for_issue( """ # check for skip labels presence and skip when detected if issue_labels is None: - issue_labels = {label.name for label in i.get_labels()} + issue_labels = [label.name for label in i.get_labels()] skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) records[i.number] = IssueRecord(issue=i, skip=skip_record) logger.debug("Created record for non hierarchy issue %d: %s", i.number, i.title) diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 6683ba1c..9210d14f 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -162,7 +162,7 @@ def _create_record_for_hierarchy_issue(self, i: Issue, issue_labels: Optional[li """ # check for skip labels presence and skip when detected if issue_labels is None: - issue_labels: list[str] = self._get_issue_labels_mix_with_type(i) + issue_labels = self._get_issue_labels_mix_with_type(i) skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) self.__records[i.number] = HierarchyIssueRecord(issue=i, skip=skip_record) From c38193dcee2bf64efdca4c754e090b941218a71a Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 17 Sep 2025 09:00:02 +0200 Subject: [PATCH 15/33] - Fixed all check alerts on local. --- .../model/hierarchy_issue_record.py | 17 ++++++- release_notes_generator/model/issue_record.py | 10 +++- .../model/pull_request_record.py | 1 - .../record/factory/default_record_factory.py | 46 ++++++++--------- .../factory/issue_hierarchy_record_factory.py | 51 ++++++++++--------- .../record/factory/record_factory.py | 1 - 6 files changed, 72 insertions(+), 54 deletions(-) diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index b364aeee..e658a65a 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -3,10 +3,8 @@ """ import logging -from functools import lru_cache from typing import Optional, Any from github.Issue import Issue -from github.PullRequest import PullRequest from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.issue_record import IssueRecord @@ -30,18 +28,33 @@ def __init__(self, issue: Issue, issue_labels: Optional[list[str]] = None, skip: @property def level(self) -> int: + """ + The level of the hierarchy issue. + """ return self._level @level.setter def level(self, value: int) -> None: + """ + Sets the level of the hierarchy issue. + + Parameters: + value (int): The level of the hierarchy issue. + """ self._level = value @property def sub_issues(self): + """ + The list of sub-issues for the hierarchy issue. + """ return self._sub_issues @property def sub_hierarchy_issues(self): + """ + The list of sub-hierarchy issues for the hierarchy issue. + """ return self._sub_hierarchy_issues def pull_requests_count(self) -> int: diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py index 4028be29..9d734b48 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -4,7 +4,6 @@ import logging import re -from functools import lru_cache from typing import Optional, Any from github.Commit import Commit @@ -86,6 +85,15 @@ def get_labels(self) -> list[str]: return [label.name for label in self._issue.get_labels()] def find_issue(self, issue_number: int) -> Optional["IssueRecord"]: + """ + Finds an issue record by its number. + + Parameters: + issue_number (int): The number of the issue. + + Returns: + IssueRecord: The issue record with that number. + """ if self._issue.number == issue_number: return self else: diff --git a/release_notes_generator/model/pull_request_record.py b/release_notes_generator/model/pull_request_record.py index 3ca0b8ff..02c6b667 100644 --- a/release_notes_generator/model/pull_request_record.py +++ b/release_notes_generator/model/pull_request_record.py @@ -3,7 +3,6 @@ """ import re -from functools import lru_cache from typing import Optional, Any from github.Commit import Commit diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index b01dd9c7..c3e894a5 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -51,6 +51,8 @@ def __init__(self, github: Github) -> None: rate_limiter = GithubRateLimiter(github) self._safe_call = safe_call_decorator(rate_limiter) + self.__records: dict[int | str, Record] = {} + def generate(self, data: MinedData) -> dict[int | str, Record]: """ Generate records for release notes. @@ -70,7 +72,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: for parent_issue_number in detected_issues: # create an issue record if not present for PR parent - if parent_issue_number not in records: + if parent_issue_number not in self.__records: logger.warning( "Detected PR %d linked to issue %d which is not in the list of received issues. " "Fetching ...", @@ -81,10 +83,10 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: safe_call(data.repository.get_issue)(parent_issue_number) if data.repository else None ) if parent_issue is not None: - DefaultRecordFactory._create_record_for_issue(records, parent_issue) + self._create_record_for_issue(parent_issue) - if parent_issue_number in records: - cast(IssueRecord, records[parent_issue_number]).register_pull_request(pull) + if parent_issue_number in self.__records: + cast(IssueRecord, self.__records[parent_issue_number]).register_pull_request(pull) logger.debug("Registering PR %d: %s to Issue %d", pull.number, pull.title, parent_issue_number) else: logger.debug( @@ -94,13 +96,12 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: parent_issue_number, ) - records: dict[int | str, Record] = {} rate_limiter = GithubRateLimiter(self._github) safe_call = safe_call_decorator(rate_limiter) logger.debug("Registering issues to records...") for issue in data.issues: - DefaultRecordFactory._create_record_for_issue(records, issue) + self._create_record_for_issue(issue) logger.debug("Registering pull requests to records...") for pull in data.pull_requests: @@ -108,35 +109,32 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) if not safe_call(get_issues_for_pr)(pull_number=pull.number) and not extract_issue_numbers_from_body(pull): - records[pull.number] = PullRequestRecord(pull, skip=skip_record) + self.__records[pull.number] = PullRequestRecord(pull, skip=skip_record) logger.debug("Created record for PR %d: %s", pull.number, pull.title) else: logger.debug("Registering pull number: %s, title : %s", pull.number, pull.title) register_pull_request(pull, skip_record) logger.debug("Registering commits to records...") - detected_direct_commits_count = sum( - not DefaultRecordFactory.register_commit_to_record(records, commit) for commit in data.commits - ) + detected_direct_commits_count = sum(not self.register_commit_to_record(commit) for commit in data.commits) logger.info( "Generated %d records from %d issues and %d PRs, with %d commits detected.", - len(records), + len(self.__records), len(data.issues), len(data.pull_requests), detected_direct_commits_count, ) - return records + return self.__records - @staticmethod - def register_commit_to_record(records: dict[int | str, Record], commit: Commit) -> bool: + def register_commit_to_record(self, commit: Commit) -> bool: """ Register a commit to a record. @param commit: The commit to register. @return: True if the commit was registered to a record, False otherwise """ - for record in records.values(): + for record in self.__records.values(): if isinstance(record, IssueRecord): rec_i = cast(IssueRecord, record) for number in rec_i.get_pull_request_numbers(): @@ -151,27 +149,25 @@ def register_commit_to_record(records: dict[int | str, Record], commit: Commit) rec_pr.register_commit(commit) return True - records[commit.sha] = CommitRecord(commit=commit) + self.__records[commit.sha] = CommitRecord(commit=commit) logger.debug("Created record for direct commit %s: %s", commit.sha, commit.commit.message) return False - @staticmethod - def _create_record_for_issue( - records: dict[int | str, Record], i: Issue, issue_labels: Optional[list[str]] = None - ) -> None: + def _create_record_for_issue(self, issue: Issue, issue_labels: Optional[list[str]] = None) -> None: """ Create a record for an issue. Parameters: records (dict[int|str, Record]): The dictionary of records to add the issue record to. - i (Issue): The issue to create a record for. - issue_labels (Optional[list[str]]): Optional set of labels for the issue. If not provided, labels will be fetched from the issue. + issue (Issue): The issue to create a record for. + issue_labels (Optional[list[str]]): Optional set of labels for the issue. If not provided, labels will be + fetched from the issue. Returns: None """ # check for skip labels presence and skip when detected if issue_labels is None: - issue_labels = [label.name for label in i.get_labels()] + issue_labels = [label.name for label in issue.get_labels()] skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) - records[i.number] = IssueRecord(issue=i, skip=skip_record) - logger.debug("Created record for non hierarchy issue %d: %s", i.number, i.title) + self.__records[issue.number] = IssueRecord(issue=issue, skip=skip_record) + logger.debug("Created record for non hierarchy issue %d: %s", issue.number, issue.title) diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 9210d14f..d8b18f32 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -23,7 +23,6 @@ from github import Github from github.Issue import Issue, SubIssue -from github.Label import Label from github.PullRequest import PullRequest from release_notes_generator.model.commit_record import CommitRecord @@ -41,7 +40,6 @@ logger = logging.getLogger(__name__) -# TODO - code review - check if it beneficial to inherit from DefaultRecordFactory class IssueHierarchyRecordFactory(DefaultRecordFactory): """ A class used to generate records for release notes. @@ -50,7 +48,6 @@ class IssueHierarchyRecordFactory(DefaultRecordFactory): def __init__(self, github: Github) -> None: super().__init__(github) - self.__records: dict[int | str, Record] = {} self.__registered_issues: list[int] = [] self.__sub_issue_parents: dict[int, int] = {} self.__registered_commits: list[str] = [] @@ -178,16 +175,16 @@ def _get_issue_labels_mix_with_type(self, issue: Issue) -> list[str]: return labels - def _create_record_for_issue(self, issue: Issue, issue_labels: Optional[list[Label]] = None) -> None: + def _create_record_for_issue(self, issue: Issue, issue_labels: Optional[list[str]] = None) -> None: if issue_labels is None: - issue_labels: list[str] = self._get_issue_labels_mix_with_type(issue) + issue_labels = self._get_issue_labels_mix_with_type(issue) - super()._create_record_for_issue(self.__records, issue, issue_labels) + super()._create_record_for_issue(issue, issue_labels) self.__registered_issues.append(issue.number) - def _create_record_for_sub_issue(self, sub_issue: SubIssue, issue_labels: Optional[list[Label]] = None) -> None: + def _create_record_for_sub_issue(self, sub_issue: SubIssue, issue_labels: Optional[list[str]] = None) -> None: if issue_labels is None: - issue_labels: list[str] = self._get_issue_labels_mix_with_type(sub_issue) + issue_labels = self._get_issue_labels_mix_with_type(sub_issue) skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) logger.debug("Created record for sub issue %d: %s", sub_issue.number, sub_issue.title) @@ -203,28 +200,34 @@ def _re_register_hierarchy_issues(self): # but do it only for issue where parent issue number is not in _sub_issue_parents keys # Why? We building hierarchy from bottom. Access in records is very easy. parent_issue_nr: int = self.__sub_issue_parents[sub_issue_number] - if parent_issue_nr in self.__sub_issue_parents.keys(): + if parent_issue_nr in self.__sub_issue_parents: continue + + parent_rec = cast(HierarchyIssueRecord, self.__sub_issue_parents[parent_issue_nr]) + sub_rec = self.__records[sub_issue_number] + + if isinstance(sub_rec, SubIssueRecord): + parent_rec.sub_issues.append(sub_rec) # add to parent as SubIssueRecord + self.__records.pop(sub_issue_number) # remove from main records as it is sub-one + self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one + elif isinstance(sub_rec, HierarchyIssueRecord): + parent_rec.sub_issues.append(sub_rec) # add to parent as 'Sub' HierarchyIssueRecord + self.__records.pop(sub_issue_number) # remove from main records as it is sub-one + self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one else: - parent_rec = cast(HierarchyIssueRecord, self.__sub_issue_parents[parent_issue_nr]) - sub_rec = self.__records[sub_issue_number] - - if isinstance(sub_rec, SubIssueRecord): - parent_rec.sub_issues.append(sub_rec) # add to parent as SubIssueRecord - self.__records.pop(sub_issue_number) # remove from main records as it is sub-one - self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one - elif isinstance(sub_rec, HierarchyIssueRecord): - parent_rec.sub_issues.append(sub_rec) # add to parent as 'Sub' HierarchyIssueRecord - self.__records.pop(sub_issue_number) # remove from main records as it is sub-one - self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one - else: - logger.error("Detected IssueRecord in position of SubIssueRecord - skipping it") - # Dev note: IssueRecord is expected to be stand-alone - not sub-issue + logger.error("Detected IssueRecord in position of SubIssueRecord - skipping it") + # Dev note: IssueRecord is expected to be stand-alone - not sub-issue if len(self.__sub_issue_parents.items()) > 0: self._re_register_hierarchy_issues() - def order_hierarchy_levels(self, level: int = 0): + def order_hierarchy_levels(self, level: int = 0) -> None: + """ + Order hierarchy levels for proper rendering. + + Parameters: + level (int): The current level in the hierarchy. Default is 0. + """ # we have now all hierarchy issues in records - but levels are not set # we need to set levels for proper rendering # This have to be done from up to down diff --git a/release_notes_generator/record/factory/record_factory.py b/release_notes_generator/record/factory/record_factory.py index 615a3cdb..698fe86f 100644 --- a/release_notes_generator/record/factory/record_factory.py +++ b/release_notes_generator/record/factory/record_factory.py @@ -20,7 +20,6 @@ import abc import logging -from github import Github from release_notes_generator.model.mined_data import MinedData from release_notes_generator.model.record import Record From 94f1ce2d7606951542fcf8c7821598dac0c66927 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 17 Sep 2025 12:03:00 +0200 Subject: [PATCH 16/33] - Relocated test files to follow latest source code structure. --- tests/release_notes/builder/__init__.py | 0 .../test_release_notes_builder.py | 110 +++++++++++++----- tests/release_notes/chapters/__init__.py | 0 .../{model => chapters}/test_base_chapters.py | 0 .../{model => chapters}/test_chapter.py | 0 .../test_custom_chapters.py | 0 .../test_service_chapters.py | 0 tests/release_notes/record/__init__.py | 0 .../release_notes/record/factory/__init__.py | 0 .../factory}/test_record_factory.py | 0 10 files changed, 81 insertions(+), 29 deletions(-) create mode 100644 tests/release_notes/builder/__init__.py rename tests/release_notes/{ => builder}/test_release_notes_builder.py (86%) create mode 100644 tests/release_notes/chapters/__init__.py rename tests/release_notes/{model => chapters}/test_base_chapters.py (100%) rename tests/release_notes/{model => chapters}/test_chapter.py (100%) rename tests/release_notes/{model => chapters}/test_custom_chapters.py (100%) rename tests/release_notes/{model => chapters}/test_service_chapters.py (100%) create mode 100644 tests/release_notes/record/__init__.py create mode 100644 tests/release_notes/record/factory/__init__.py rename tests/release_notes/{ => record/factory}/test_record_factory.py (100%) diff --git a/tests/release_notes/builder/__init__.py b/tests/release_notes/builder/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/release_notes/test_release_notes_builder.py b/tests/release_notes/builder/test_release_notes_builder.py similarity index 86% rename from tests/release_notes/test_release_notes_builder.py rename to tests/release_notes/builder/test_release_notes_builder.py index 26421da5..57b62ab2 100644 --- a/tests/release_notes/test_release_notes_builder.py +++ b/tests/release_notes/builder/test_release_notes_builder.py @@ -13,7 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from release_notes_generator.action_inputs import ActionInputs +import pytest + from release_notes_generator.builder.builder import ReleaseNotesBuilder from release_notes_generator.chapters.custom_chapters import CustomChapters @@ -68,13 +69,11 @@ - Without linked Issue """ - # pylint: disable=too-few-public-methods class MockLabel: def __init__(self, name): self.name = name - DEFAULT_CHANGELOG_URL = "http://example.com/changelog" default_chapters = [ {"title": "Breaking Changes 💥", "label": "breaking-change"}, @@ -333,7 +332,9 @@ def __init__(self, name): # build -def test_build_no_data(): +@pytest.mark.parametrize("hierarchy_value", [True, False]) +def test_build_no_data(mocker, hierarchy_value): + mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_hierarchy", return_value=hierarchy_value) custom_chapters = CustomChapters() custom_chapters.from_yaml_array(default_chapters) @@ -349,7 +350,9 @@ def test_build_no_data(): assert expected_release_notes == actual_release_notes -def test_build_no_data_no_warnings(mocker): +@pytest.mark.parametrize("hierarchy_value", [True, False]) +def test_build_no_data_no_warnings(mocker, hierarchy_value): + mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_hierarchy", return_value=hierarchy_value) custom_chapters = CustomChapters() custom_chapters.from_yaml_array(default_chapters) mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_warnings", return_value=False) @@ -366,7 +369,9 @@ def test_build_no_data_no_warnings(mocker): assert expected_release_notes == actual_release_notes -def test_build_no_data_no_warnings_no_empty_chapters(mocker): +@pytest.mark.parametrize("hierarchy_value", [True, False]) +def test_build_no_data_no_warnings_no_empty_chapters(mocker, hierarchy_value): + mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_hierarchy", return_value=hierarchy_value) custom_chapters_no_empty_chapters = CustomChapters() custom_chapters_no_empty_chapters.from_yaml_array(default_chapters) custom_chapters_no_empty_chapters.print_empty_chapters = False @@ -385,7 +390,9 @@ def test_build_no_data_no_warnings_no_empty_chapters(mocker): assert expected_release_notes == actual_release_notes -def test_build_no_data_no_empty_chapters(mocker): +@pytest.mark.parametrize("hierarchy_value", [True, False]) +def test_build_no_data_no_empty_chapters(mocker, hierarchy_value): + mocker.patch("release_notes_generator.action_inputs.ActionInputs.get_hierarchy", return_value=hierarchy_value) custom_chapters_no_empty_chapters = CustomChapters() custom_chapters_no_empty_chapters.from_yaml_array(default_chapters) custom_chapters_no_empty_chapters.print_empty_chapters = False @@ -558,11 +565,13 @@ def test_build_no_data_no_empty_chapters(mocker): # - covered in 'test_build_merged_pr_service_chapter_without_issue_and_user_labels' +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_closed_issue_with_one_custom_label( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_two_pulls, mocker + custom_chapters_not_print_empty_chapters, record_with_issue_closed_two_pulls, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_ONE_LABEL rec = record_with_issue_closed_two_pulls + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) builder = ReleaseNotesBuilder( @@ -576,14 +585,16 @@ def test_build_closed_issue_with_one_custom_label( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_closed_issue_with_more_custom_labels_duplicity_reduction_on( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_two_pulls, mocker + custom_chapters_not_print_empty_chapters, record_with_issue_closed_two_pulls, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_MORE_LABELS_DUPLICITY_REDUCTION_ON rec = record_with_issue_closed_two_pulls rec.issue.labels.append(MockLabel("enhancement")) rec.issue.title = "I1+bug-enhancement" mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -596,12 +607,14 @@ def test_build_closed_issue_with_more_custom_labels_duplicity_reduction_on( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_closed_issue_service_chapter_without_pull_request_and_user_defined_label( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_no_pull, mocker + custom_chapters_not_print_empty_chapters, record_with_issue_closed_no_pull, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_ISSUE_NO_PR_NO_USER_LABELS rec = record_with_issue_closed_no_pull mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -614,12 +627,14 @@ def test_build_closed_issue_service_chapter_without_pull_request_and_user_define assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_merged_pr_service_chapter_without_issue_and_user_labels( - custom_chapters_not_print_empty_chapters, pull_request_record_merged, mocker + custom_chapters_not_print_empty_chapters, pull_request_record_merged, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_MERGED_PR_NO_ISSUE_NO_USER_LABELS rec = pull_request_record_merged mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -632,12 +647,14 @@ def test_build_merged_pr_service_chapter_without_issue_and_user_labels( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_closed_pr_service_chapter_without_issue_and_user_labels( - custom_chapters_not_print_empty_chapters, pull_request_record_closed, mocker + custom_chapters_not_print_empty_chapters, pull_request_record_closed, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_NO_USER_LABELS rec = pull_request_record_closed mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -650,12 +667,14 @@ def test_build_closed_pr_service_chapter_without_issue_and_user_labels( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_open_issue_with_merged_pr_service_chapter_linked_to_not_closed_issue( - custom_chapters_not_print_empty_chapters, record_with_issue_open_two_pulls_closed, mocker + custom_chapters_not_print_empty_chapters, record_with_issue_open_two_pulls_closed, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_OPEN_ISSUE_AND_MERGED_PR_NO_USER_LABELS rec = record_with_issue_open_two_pulls_closed mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -668,10 +687,12 @@ def test_build_open_issue_with_merged_pr_service_chapter_linked_to_not_closed_is assert expected_release_notes == actual_release_notes -def test_build_open_issue(custom_chapters_not_print_empty_chapters, record_with_issue_open_no_pull, mocker): +@pytest.mark.parametrize("hierarchy_value", [True, False]) +def test_build_open_issue(custom_chapters_not_print_empty_chapters, record_with_issue_open_no_pull, mocker, hierarchy_value): expected_release_notes = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS rec = record_with_issue_open_no_pull mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -684,10 +705,12 @@ def test_build_open_issue(custom_chapters_not_print_empty_chapters, record_with_ assert expected_release_notes == actual_release_notes -def test_build_closed_issue(custom_chapters_not_print_empty_chapters, record_with_issue_closed_no_pull, mocker): +@pytest.mark.parametrize("hierarchy_value", [True, False]) +def test_build_closed_issue(custom_chapters_not_print_empty_chapters, record_with_issue_closed_no_pull, mocker, hierarchy_value): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_ISSUE_NO_PR_NO_USER_LABELS rec = record_with_issue_closed_no_pull mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -702,11 +725,13 @@ def test_build_closed_issue(custom_chapters_not_print_empty_chapters, record_wit assert expected_release_notes == actual_release_notes -def test_build_reopened_issue(custom_chapters_not_print_empty_chapters, record_with_issue_open_no_pull, mocker): +@pytest.mark.parametrize("hierarchy_value", [True, False]) +def test_build_reopened_issue(custom_chapters_not_print_empty_chapters, record_with_issue_open_no_pull, mocker, hierarchy_value): expected_release_notes = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS rec = record_with_issue_open_no_pull rec.issue.state_reason = "reopened" mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -719,13 +744,15 @@ def test_build_reopened_issue(custom_chapters_not_print_empty_chapters, record_w assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_closed_not_planned_issue( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_no_pull, mocker + custom_chapters_not_print_empty_chapters, record_with_issue_closed_no_pull, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_ISSUE_NO_PR_NO_USER_LABELS rec = record_with_issue_closed_no_pull rec.issue.state_reason = "not_planned" mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -738,13 +765,15 @@ def test_build_closed_not_planned_issue( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_closed_issue_with_user_labels_no_prs( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_no_pull, mocker + custom_chapters_not_print_empty_chapters, record_with_issue_closed_no_pull, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_CLOSED_ISSUE_NO_PR_WITH_USER_LABELS rec = record_with_issue_closed_no_pull rec._labels = {"bug", "breaking-changes"} mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -757,14 +786,16 @@ def test_build_closed_issue_with_user_labels_no_prs( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_closed_issue_with_prs_without_user_label( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_two_pulls, mocker + custom_chapters_not_print_empty_chapters, record_with_issue_closed_two_pulls, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_PR_WITHOUT_USER_LABELS rec = record_with_issue_closed_two_pulls rec._labels = {"label1", "label2"} rec.issue.title = "I1" mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -777,12 +808,14 @@ def test_build_closed_issue_with_prs_without_user_label( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_open_pr_without_issue( - custom_chapters_not_print_empty_chapters, pull_request_record_open, mocker + custom_chapters_not_print_empty_chapters, pull_request_record_open, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_OPEN_PR_WITHOUT_ISSUE rec = pull_request_record_open mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -795,12 +828,14 @@ def test_build_open_pr_without_issue( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_merged_pr_without_issue_ready_for_review( - custom_chapters_not_print_empty_chapters, pull_request_record_merged, mocker + custom_chapters_not_print_empty_chapters, pull_request_record_merged, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_MERGED_PR_NO_ISSUE_NO_USER_LABELS rec = pull_request_record_merged mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -813,12 +848,14 @@ def test_build_merged_pr_without_issue_ready_for_review( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_closed_pr_without_issue_ready_for_review( - custom_chapters_not_print_empty_chapters, pull_request_record_closed, mocker + custom_chapters_not_print_empty_chapters, pull_request_record_closed, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_NO_USER_LABELS rec = pull_request_record_closed mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -831,13 +868,15 @@ def test_build_closed_pr_without_issue_ready_for_review( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_closed_pr_without_issue_non_draft( - custom_chapters_not_print_empty_chapters, pull_request_record_closed, mocker + custom_chapters_not_print_empty_chapters, pull_request_record_closed, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_NO_USER_LABELS rec = pull_request_record_closed rec.pull_request.draft = False mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -852,13 +891,15 @@ def test_build_closed_pr_without_issue_non_draft( # TODO - research situation when PR is not merged and is in draft state +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_merged_pr_without_issue_with_more_user_labels_duplicity_reduction_on( - custom_chapters_not_print_empty_chapters, pull_request_record_merged, mocker + custom_chapters_not_print_empty_chapters, pull_request_record_merged, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_MERGED_PR_WITH_USER_LABELS_DUPLICITY_REDUCTION_ON rec = pull_request_record_merged rec._labels = {"bug", "enhancement"} mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -871,12 +912,14 @@ def test_merged_pr_without_issue_with_more_user_labels_duplicity_reduction_on( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_merged_pr_with_open_init_issue_mention( - custom_chapters_not_print_empty_chapters, record_with_two_issue_open_two_pulls_closed, mocker + custom_chapters_not_print_empty_chapters, record_with_two_issue_open_two_pulls_closed, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_MERGED_PRS_WITH_OPEN_ISSUES records = record_with_two_issue_open_two_pulls_closed mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records=records, @@ -889,12 +932,14 @@ def test_merged_pr_with_open_init_issue_mention( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_merged_pr_with_closed_issue_mention_without_user_labels( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_one_pull, mocker + custom_chapters_not_print_empty_chapters, record_with_issue_closed_one_pull, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_MERGED_PRS_WITHOUT_USER_LABELS rec = record_with_issue_closed_one_pull mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -907,12 +952,14 @@ def test_merged_pr_with_closed_issue_mention_without_user_labels( assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_merged_pr_with_closed_issue_mention_with_user_labels( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_one_pull_merged, mocker + custom_chapters_not_print_empty_chapters, record_with_issue_closed_one_pull_merged, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_MERGED_PRS_WITH_USER_LABELS rec = record_with_issue_closed_one_pull_merged mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -924,12 +971,15 @@ def test_merged_pr_with_closed_issue_mention_with_user_labels( assert expected_release_notes == actual_release_notes + +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_merged_pr_with_closed_issue_mention_with_user_labels_with_skip_label_on_issue( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_one_pull_merged_skip, mocker + custom_chapters_not_print_empty_chapters, record_with_issue_closed_one_pull_merged_skip, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_MERGED_PRS_WITH_USER_LABELS_WITH_SKIP_LABEL rec = record_with_issue_closed_one_pull_merged_skip mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, @@ -942,12 +992,14 @@ def test_merged_pr_with_closed_issue_mention_with_user_labels_with_skip_label_on assert expected_release_notes == actual_release_notes +@pytest.mark.parametrize("hierarchy_value", [True, False]) def test_build_closed_pr_service_chapter_without_issue_with_skip_label_on_pr( - custom_chapters_not_print_empty_chapters, pull_request_record_closed_with_skip_label, mocker + custom_chapters_not_print_empty_chapters, pull_request_record_closed_with_skip_label, mocker, hierarchy_value ): expected_release_notes = RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_SKIP_USER_LABELS rec = pull_request_record_closed_with_skip_label mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=hierarchy_value) builder = ReleaseNotesBuilder( records={rec.record_id: rec}, diff --git a/tests/release_notes/chapters/__init__.py b/tests/release_notes/chapters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/release_notes/model/test_base_chapters.py b/tests/release_notes/chapters/test_base_chapters.py similarity index 100% rename from tests/release_notes/model/test_base_chapters.py rename to tests/release_notes/chapters/test_base_chapters.py diff --git a/tests/release_notes/model/test_chapter.py b/tests/release_notes/chapters/test_chapter.py similarity index 100% rename from tests/release_notes/model/test_chapter.py rename to tests/release_notes/chapters/test_chapter.py diff --git a/tests/release_notes/model/test_custom_chapters.py b/tests/release_notes/chapters/test_custom_chapters.py similarity index 100% rename from tests/release_notes/model/test_custom_chapters.py rename to tests/release_notes/chapters/test_custom_chapters.py diff --git a/tests/release_notes/model/test_service_chapters.py b/tests/release_notes/chapters/test_service_chapters.py similarity index 100% rename from tests/release_notes/model/test_service_chapters.py rename to tests/release_notes/chapters/test_service_chapters.py diff --git a/tests/release_notes/record/__init__.py b/tests/release_notes/record/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/release_notes/record/factory/__init__.py b/tests/release_notes/record/factory/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/release_notes/test_record_factory.py b/tests/release_notes/record/factory/test_record_factory.py similarity index 100% rename from tests/release_notes/test_record_factory.py rename to tests/release_notes/record/factory/test_record_factory.py From 6808247e71fdcff4137d187283e1f8ae84736a8a Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Thu, 18 Sep 2025 21:05:18 +0200 Subject: [PATCH 17/33] - Finished 1st unit test for hierarchy issue factory - without labels and issue types. --- .../record/factory/default_record_factory.py | 20 +-- .../factory/issue_hierarchy_record_factory.py | 54 ++++--- .../utils/pull_request_utils.py | 6 +- tests/conftest.py | 150 +++++++++++++++++- ...tory.py => test_default_record_factory.py} | 0 .../test_issue_hierarchy_record_factory.py | 131 +++++++++++++++ 6 files changed, 320 insertions(+), 41 deletions(-) rename tests/release_notes/record/factory/{test_record_factory.py => test_default_record_factory.py} (100%) create mode 100644 tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index c3e894a5..502b220a 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -51,7 +51,7 @@ def __init__(self, github: Github) -> None: rate_limiter = GithubRateLimiter(github) self._safe_call = safe_call_decorator(rate_limiter) - self.__records: dict[int | str, Record] = {} + self._records: dict[int | str, Record] = {} def generate(self, data: MinedData) -> dict[int | str, Record]: """ @@ -72,7 +72,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: for parent_issue_number in detected_issues: # create an issue record if not present for PR parent - if parent_issue_number not in self.__records: + if parent_issue_number not in self._records: logger.warning( "Detected PR %d linked to issue %d which is not in the list of received issues. " "Fetching ...", @@ -85,8 +85,8 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: if parent_issue is not None: self._create_record_for_issue(parent_issue) - if parent_issue_number in self.__records: - cast(IssueRecord, self.__records[parent_issue_number]).register_pull_request(pull) + if parent_issue_number in self._records: + cast(IssueRecord, self._records[parent_issue_number]).register_pull_request(pull) logger.debug("Registering PR %d: %s to Issue %d", pull.number, pull.title, parent_issue_number) else: logger.debug( @@ -109,7 +109,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) if not safe_call(get_issues_for_pr)(pull_number=pull.number) and not extract_issue_numbers_from_body(pull): - self.__records[pull.number] = PullRequestRecord(pull, skip=skip_record) + self._records[pull.number] = PullRequestRecord(pull, skip=skip_record) logger.debug("Created record for PR %d: %s", pull.number, pull.title) else: logger.debug("Registering pull number: %s, title : %s", pull.number, pull.title) @@ -120,12 +120,12 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: logger.info( "Generated %d records from %d issues and %d PRs, with %d commits detected.", - len(self.__records), + len(self._records), len(data.issues), len(data.pull_requests), detected_direct_commits_count, ) - return self.__records + return self._records def register_commit_to_record(self, commit: Commit) -> bool: """ @@ -134,7 +134,7 @@ def register_commit_to_record(self, commit: Commit) -> bool: @param commit: The commit to register. @return: True if the commit was registered to a record, False otherwise """ - for record in self.__records.values(): + for record in self._records.values(): if isinstance(record, IssueRecord): rec_i = cast(IssueRecord, record) for number in rec_i.get_pull_request_numbers(): @@ -149,7 +149,7 @@ def register_commit_to_record(self, commit: Commit) -> bool: rec_pr.register_commit(commit) return True - self.__records[commit.sha] = CommitRecord(commit=commit) + self._records[commit.sha] = CommitRecord(commit=commit) logger.debug("Created record for direct commit %s: %s", commit.sha, commit.commit.message) return False @@ -169,5 +169,5 @@ def _create_record_for_issue(self, issue: Issue, issue_labels: Optional[list[str if issue_labels is None: issue_labels = [label.name for label in issue.get_labels()] skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) - self.__records[issue.number] = IssueRecord(issue=issue, skip=skip_record) + self._records[issue.number] = IssueRecord(issue=issue, skip=skip_record) logger.debug("Created record for non hierarchy issue %d: %s", issue.number, issue.title) diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index d8b18f32..6a8c682c 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -72,12 +72,12 @@ def generate(self, data: MinedData) -> dict[int | str, Record]: logger.debug("Registering Commits to Pull Requests and Pull Requests to Issues...") for pull in data.pull_requests: - self._register_pull_and_its_commits_to_issue(pull, data, self.__records, self.__registered_commits) + self._register_pull_and_its_commits_to_issue(pull, data) logger.debug("Registering direct commits to records...") for commit in data.commits: if commit.sha not in self.__registered_commits: - self.__records[commit.sha] = CommitRecord(commit) + self._records[commit.sha] = CommitRecord(commit) # dev note: now we have all PRs and commits registered to issues or as stand-alone records # let build hierarchy @@ -87,26 +87,25 @@ def generate(self, data: MinedData) -> dict[int | str, Record]: logger.info( "Generated %d records from %d issues and %d PRs, with %d commits detected.", - len(self.__records), + len(self._records), len(data.issues), len(data.pull_requests), len(data.commits), ) - return self.__records + return self._records - def _register_pull_and_its_commits_to_issue( - self, pull: PullRequest, data: MinedData, records: dict[int | str, Record], registered_commits: list[str] - ) -> None: + def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: MinedData) -> None: pull_labels = [label.name for label in pull.get_labels()] skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) related_commits = [c for c in data.commits if c.sha == pull.merge_commit_sha] - registered_commits.extend(c.sha for c in related_commits) + self.__registered_commits.extend(c.sha for c in related_commits) - pull_issues = self._safe_call(get_issues_for_pr)(pull_number=pull.number) - pull_issues.update(extract_issue_numbers_from_body(pull)) + pull_issues_set: set[int] = self._safe_call(get_issues_for_pr)(pull_number=pull.number) + pull_issues_set.update(extract_issue_numbers_from_body(pull)) + pull_issues: list[int] = list(pull_issues_set) if len(pull_issues) > 0: for issue_number in pull_issues: - if issue_number not in records.keys(): + if issue_number not in self._records.keys(): logger.warning( "Detected PR %d linked to issue %d which is not in the list of received issues. " "Fetching ...", @@ -116,11 +115,12 @@ def _register_pull_and_its_commits_to_issue( parent_issue = self._safe_call(data.repository.get_issue)(issue_number) if data.repository else None if parent_issue is not None: self._create_issue_record_using_sub_issues_existence(parent_issue) + return - if issue_number in records.keys() and isinstance( - records[issue_number], (SubIssueRecord, HierarchyIssueRecord, IssueRecord) + if issue_number in self._records.keys() and isinstance( + self._records[issue_number], (SubIssueRecord, HierarchyIssueRecord, IssueRecord) ): - rec = cast(IssueRecord, records[issue_number]) + rec = cast(IssueRecord, self._records[issue_number]) rec.register_pull_request(pull) logger.debug("Registering pull number: %s, title : %s", pull.number, pull.title) @@ -128,10 +128,12 @@ def _register_pull_and_its_commits_to_issue( rec.register_commit(pull, c) logger.debug("Registering commit %s to PR %d", c.sha, pull.number) + return + pr_rec = PullRequestRecord(pull, pull_labels, skip_record) for c in related_commits: # register commits to the PR record pr_rec.register_commit(c) - records[pull.number] = pr_rec + self._records[pull.number] = pr_rec logger.debug("Created record for PR %d: %s", pull.number, pull.title) def _create_issue_record_using_sub_issues_existence(self, issue: Issue) -> None: @@ -162,7 +164,7 @@ def _create_record_for_hierarchy_issue(self, i: Issue, issue_labels: Optional[li issue_labels = self._get_issue_labels_mix_with_type(i) skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) - self.__records[i.number] = HierarchyIssueRecord(issue=i, skip=skip_record) + self._records[i.number] = HierarchyIssueRecord(issue=i, skip=skip_record) logger.debug("Created record for hierarchy issue %d: %s", i.number, i.title) def _get_issue_labels_mix_with_type(self, issue: Issue) -> list[str]: @@ -189,7 +191,7 @@ def _create_record_for_sub_issue(self, sub_issue: SubIssue, issue_labels: Option skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) logger.debug("Created record for sub issue %d: %s", sub_issue.number, sub_issue.title) self.__registered_issues.append(sub_issue.number) - self.__records[sub_issue.number] = SubIssueRecord(sub_issue, issue_labels, skip_record) + self._records[sub_issue.number] = SubIssueRecord(sub_issue, issue_labels, skip_record) def _re_register_hierarchy_issues(self): sub_issues_numbers = list(self.__sub_issue_parents.keys()) @@ -203,17 +205,17 @@ def _re_register_hierarchy_issues(self): if parent_issue_nr in self.__sub_issue_parents: continue - parent_rec = cast(HierarchyIssueRecord, self.__sub_issue_parents[parent_issue_nr]) - sub_rec = self.__records[sub_issue_number] + parent_rec = cast(HierarchyIssueRecord, self._records[parent_issue_nr]) + sub_rec = self._records[sub_issue_number] if isinstance(sub_rec, SubIssueRecord): - parent_rec.sub_issues.append(sub_rec) # add to parent as SubIssueRecord - self.__records.pop(sub_issue_number) # remove from main records as it is sub-one - self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one + parent_rec.sub_issues[sub_issue_number] = sub_rec # add to parent as SubIssueRecord + self._records.pop(sub_issue_number) # remove from main records as it is sub-one + self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one elif isinstance(sub_rec, HierarchyIssueRecord): - parent_rec.sub_issues.append(sub_rec) # add to parent as 'Sub' HierarchyIssueRecord - self.__records.pop(sub_issue_number) # remove from main records as it is sub-one - self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one + parent_rec.sub_issues[sub_issue_number] = sub_rec # add to parent as 'Sub' HierarchyIssueRecord + self._records.pop(sub_issue_number) # remove from main records as it is sub-one + self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one else: logger.error("Detected IssueRecord in position of SubIssueRecord - skipping it") # Dev note: IssueRecord is expected to be stand-alone - not sub-issue @@ -231,7 +233,7 @@ def order_hierarchy_levels(self, level: int = 0) -> None: # we have now all hierarchy issues in records - but levels are not set # we need to set levels for proper rendering # This have to be done from up to down - top_hierarchy_records = [rec for rec in self.__records.values() if isinstance(rec, HierarchyIssueRecord)] + top_hierarchy_records = [rec for rec in self._records.values() if isinstance(rec, HierarchyIssueRecord)] for rec in top_hierarchy_records: rec.level = level for sub_hierarchy_record in rec.sub_hierarchy_issues.values(): diff --git a/release_notes_generator/utils/pull_request_utils.py b/release_notes_generator/utils/pull_request_utils.py index ee2420f4..81801338 100644 --- a/release_notes_generator/utils/pull_request_utils.py +++ b/release_notes_generator/utils/pull_request_utils.py @@ -52,7 +52,7 @@ def extract_issue_numbers_from_body(pr: PullRequest) -> set[int]: @lru_cache(maxsize=None) -def get_issues_for_pr(pull_number: int) -> list[int]: +def get_issues_for_pr(pull_number: int) -> set[int]: """Update the placeholder values and formate the graphQL query""" github_api_url = "https://api.github.com/graphql" query = ISSUES_FOR_PRS.format( @@ -68,10 +68,10 @@ def get_issues_for_pr(pull_number: int) -> list[int]: response = requests.post(github_api_url, json={"query": query}, headers=headers, verify=False, timeout=10) response.raise_for_status() # Raise an error for HTTP issues - numbers = [ + numbers = { node["number"] for node in response.json()["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"] - ] + } if pull_number == 3645: print(f"PR #{pull_number} - Extracted issue numbers from GitHub API: {numbers}") diff --git a/tests/conftest.py b/tests/conftest.py index bc5b8a88..534c336c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ from datetime import datetime, timedelta +import copy import pytest from github import Github, IssueType @@ -28,6 +29,7 @@ from release_notes_generator.model.commit_record import CommitRecord from release_notes_generator.model.hierarchy_issue_record import HierarchyIssueRecord from release_notes_generator.model.issue_record import IssueRecord +from release_notes_generator.model.mined_data import MinedData from release_notes_generator.model.pull_request_record import PullRequestRecord from release_notes_generator.chapters.service_chapters import ServiceChapters from release_notes_generator.model.chapter import Chapter @@ -165,6 +167,7 @@ def mock_issue_closed(mocker): issue.title = "Fix the bug" issue.number = 121 issue.body = "Some issue body text" + issue.get_sub_issues.return_value = [] label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" @@ -208,6 +211,56 @@ def mock_issue_closed_i1_bug_and_skip(mocker): return issue +@pytest.fixture +def mock_open_sub_issue(mocker): + issue = mocker.Mock(spec=Issue) + issue.state = IssueRecord.ISSUE_STATE_OPEN + issue.number = 400 + issue.title = "SI400 open" + issue.state_reason = None + issue.body = "I400 open/nRelease Notes:\n- Hierarchy level release note" + issue.type = None + issue.created_at = datetime.now() + + issue.get_labels.return_value = [] + issue.get_sub_issues.return_value = [] + + return (issue) + + +@pytest.fixture +def mock_closed_sub_issue(mocker): + issue = mocker.Mock(spec=Issue) + issue.state = IssueRecord.ISSUE_STATE_CLOSED + issue.number = 450 + issue.title = "SI450 closed" + issue.state_reason = None + issue.body = "I450 closed/nRelease Notes:\n- Hierarchy level release note" + issue.type = None + issue.created_at = datetime.now() + + issue.get_labels.return_value = [] + issue.get_sub_issues.return_value = [] + + return issue + + +@pytest.fixture +def mock_open_hierarchy_issue(mocker): + issue = mocker.Mock(spec=Issue) + issue.state = IssueRecord.ISSUE_STATE_OPEN + issue.number = 300 + issue.title = "HI300 open" + issue.state_reason = None + issue.body = "I300 open/nRelease Notes:\n- Hierarchy level release note" + issue.type = None + issue.created_at = datetime.now() + + issue.get_labels.return_value = [] + issue.get_sub_issues.return_value = [] + + return issue + @pytest.fixture def mock_open_hierarchy_issue_epic(mocker): issue_type = mocker.Mock(spec=IssueType) @@ -451,8 +504,8 @@ def mock_pull_merged(mocker): pull = mocker.Mock(spec=PullRequest) pull.state = PullRequestRecord.PR_STATE_CLOSED pull.body = "Release Notes:\n- Fixed bug\n- Improved performance\n" - pull.url = "http://example.com/pull/123" - pull.number = 123 + pull.url = "http://example.com/pull/124" + pull.number = 124 pull.merge_commit_sha = "merge_commit_sha" pull.title = "Fixed bug" pull.created_at = datetime.now() @@ -512,6 +565,99 @@ def mock_commit(mocker): commit.commit.message = "Fixed bug" return commit + +@pytest.fixture +def mined_data_isolated_record_types_no_labels_no_type_defined( + mock_issue_closed, mock_pull_closed, mock_pull_merged, mock_commit, + mock_open_hierarchy_issue, mock_open_sub_issue, mock_closed_sub_issue +): + # - single issue record (closed) + # - single hierarchy issue record - two sub-issues without PRs + # - single hierarchy issue record - two sub-issues with PRs - no commits + # - single hierarchy issue record - two sub-issues with PRs - with commits + # - single pull request record (closed, merged) + # - single direct commit record + data = MinedData() + + # single issue record (closed) + solo_closed_issue = copy.deepcopy(mock_issue_closed) + solo_closed_issue.get_labels.return_value = [] + + # single hierarchy issue record - two sub-issues without PRs + hi_two_sub_issues_no_prs = copy.deepcopy(mock_open_hierarchy_issue) + hi_two_sub_issues_no_prs.number = 301 + hi_two_sub_issues_no_prs.title = "HI301 open" + hi_two_sub_issues_no_prs.body = "I301 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_1 = copy.deepcopy(mock_open_sub_issue) + sub_issue_2 = copy.deepcopy(mock_closed_sub_issue) + hi_two_sub_issues_no_prs.get_sub_issues.return_value = [sub_issue_1, sub_issue_2] + + # single hierarchy issue record - two sub-issues with PRs - no commits + hi_two_sub_issues_with_prs = copy.deepcopy(mock_open_hierarchy_issue) + hi_two_sub_issues_with_prs.number = 302 + hi_two_sub_issues_with_prs.title = "HI302 open" + hi_two_sub_issues_with_prs.body = "I302 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_3 = copy.deepcopy(mock_open_sub_issue) + sub_issue_3.number = 401 + sub_issue_3.title = "SI401 open" + sub_issue_3.body = "I401 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_4 = copy.deepcopy(mock_closed_sub_issue) + sub_issue_4.number = 451 + sub_issue_4.title = "SI451 open" + sub_issue_4.body = "I451 open/nRelease Notes:\n- Hierarchy level release note" + mock_pr_closed_2 = copy.deepcopy(mock_pull_closed) + mock_pr_closed_2.url = "http://example.com/pull/150" + mock_pr_closed_2.number = 150 + mock_pr_closed_2.merge_commit_sha = "merge_commit_sha_150" + mock_pr_closed_2.get_labels.return_value = [] + mock_pr_closed_2.body += "\nCloses #451" + hi_two_sub_issues_with_prs.get_sub_issues.return_value = [sub_issue_3, sub_issue_4] + + # single hierarchy issue record - two sub-issues with PRs - with commits + hi_two_sub_issues_with_prs_with_commit = copy.deepcopy(mock_open_hierarchy_issue) + hi_two_sub_issues_with_prs_with_commit.number = 303 + hi_two_sub_issues_with_prs_with_commit.title = "HI303 open" + hi_two_sub_issues_with_prs_with_commit.body = "I303 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_5 = copy.deepcopy(mock_open_sub_issue) + sub_issue_5.number = 402 + sub_issue_5.title = "SI402 open" + sub_issue_5.body = "I402 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_6 = copy.deepcopy(mock_closed_sub_issue) + sub_issue_6.number = 452 + sub_issue_6.title = "SI452 open" + sub_issue_6.body = "I452 open/nRelease Notes:\n- Hierarchy level release note" + mock_pr_closed_3 = copy.deepcopy(mock_pull_closed) + mock_pr_closed_3.url = "http://example.com/pull/151" + mock_pr_closed_3.number = 151 + mock_pr_closed_3.merge_commit_sha = "merge_commit_sha_151" + mock_pr_closed_3.get_labels.return_value = [] + mock_pr_closed_3.body += "\nCloses #452" + mock_commit_1 = copy.deepcopy(mock_commit) + mock_commit_1.sha = "merge_commit_sha_151" + mock_commit_1.message = "Fixed bug in PR 151" + hi_two_sub_issues_with_prs_with_commit.get_sub_issues.return_value = [sub_issue_5, sub_issue_6] + + # single pull request record (closed, merged) + mock_pr_closed_1 = copy.deepcopy(mock_pull_closed) + mock_pr_closed_1.get_labels.return_value = [] + mock_pr_merged_1 = copy.deepcopy(mock_pull_merged) + mock_pr_merged_1.get_labels.return_value = [] + + # single direct commit record + mock_commit_2 = copy.deepcopy(mock_commit) + mock_commit_2.sha = "merge_commit_sha_direct" + mock_commit_2.message = "Direct commit example" + + data.issues = [solo_closed_issue, + hi_two_sub_issues_no_prs, sub_issue_1, sub_issue_2, + hi_two_sub_issues_with_prs, sub_issue_3, sub_issue_4, + hi_two_sub_issues_with_prs_with_commit, sub_issue_5, sub_issue_6] + data.pull_requests = [mock_pr_closed_1, mock_pr_merged_1, mock_pr_closed_2, mock_pr_closed_3] + data.commits = [mock_commit_1, mock_commit_2] + + return data + + # Fixtures for Record(s) @pytest.fixture def record_with_issue_open_no_pull(request): diff --git a/tests/release_notes/record/factory/test_record_factory.py b/tests/release_notes/record/factory/test_default_record_factory.py similarity index 100% rename from tests/release_notes/record/factory/test_record_factory.py rename to tests/release_notes/record/factory/test_default_record_factory.py diff --git a/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py b/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py new file mode 100644 index 00000000..ff61a1d4 --- /dev/null +++ b/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py @@ -0,0 +1,131 @@ +# +# 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 time +from typing import cast + +from github import Github +from mypy.semanal_classprop import calculate_class_abstract_status + +from release_notes_generator.model.commit_record import CommitRecord +from release_notes_generator.model.hierarchy_issue_record import HierarchyIssueRecord +from release_notes_generator.model.issue_record import IssueRecord +from release_notes_generator.model.mined_data import MinedData +from release_notes_generator.model.pull_request_record import PullRequestRecord +from release_notes_generator.record.factory.issue_hierarchy_record_factory import IssueHierarchyRecordFactory + +def mock_safe_call_decorator(_rate_limiter): + def wrapper(fn): + if fn.__name__ == "get_issues_for_pr": + return mock_get_issues_for_pr + return fn + return wrapper + +def mock_get_issues_for_pr(pull_number: int) -> set[int]: + # if pull_number == 150: + # return [451] + return set() + + +# generate + +def test_generate_no_input_data(mocker): + mock_github_client = mocker.Mock(spec=Github) + factory = IssueHierarchyRecordFactory(github=mock_github_client) + data = MinedData() + + result = factory.generate(data) + + assert 0 == len(result.values()) + + +def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_data_isolated_record_types_no_labels_no_type_defined): + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = IssueHierarchyRecordFactory(github=mock_github_client) + + result = factory.generate(mined_data_isolated_record_types_no_labels_no_type_defined) + + assert 7 == len(result) + assert {121, 301, 302, 303, 123, 124, "merge_commit_sha_direct"}.issubset(result.keys()) + + assert isinstance(result[121], IssueRecord) + assert isinstance(result[301], HierarchyIssueRecord) + assert isinstance(result[302], HierarchyIssueRecord) + assert isinstance(result[303], HierarchyIssueRecord) + assert isinstance(result[123], PullRequestRecord) + assert isinstance(result[124], PullRequestRecord) + assert isinstance(result["merge_commit_sha_direct"], CommitRecord) + + rec_i = cast(IssueRecord, result[121]) + assert 0 == rec_i.pull_requests_count() + + rec_hi_1 = cast(HierarchyIssueRecord, result[301]) + assert 0 == rec_hi_1.pull_requests_count() + assert 0 == len(rec_hi_1.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_1.sub_issues.values()) + assert 0 == rec_hi_1.sub_issues[450].pull_requests_count() + + rec_hi_2 = cast(HierarchyIssueRecord, result[302]) + assert 1 == rec_hi_2.pull_requests_count() + assert 0 == len(rec_hi_2.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_2.sub_issues.values()) + assert 1 == rec_hi_2.sub_issues[451].pull_requests_count() + + rec_hi_3 = cast(HierarchyIssueRecord, result[303]) + assert 1 == rec_hi_3.pull_requests_count() + assert 0 == len(rec_hi_3.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_3.sub_issues.values()) + assert 1 == rec_hi_3.sub_issues[452].pull_requests_count() + assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").message + + +# def test_generate_isolated_record_types_with_labels_no_type_defined() +# - single issue record +# - single hierarchy issue record - no sub-issues +# - single hierarchy issue record - two sub-issues without PRs +# - single hierarchy issue record - two sub-issues with PRs - no commits +# - single hierarchy issue record - two sub-issues with PRs - with commits +# - single pull request record +# - single direct commit record + + +# def test_generate_isolated_record_types_no_labels_with_type_defined() +# - single issue record +# - single hierarchy issue record - no sub-issues +# - single hierarchy issue record - two sub-issues without PRs +# - single hierarchy issue record - two sub-issues with PRs - no commits +# - single hierarchy issue record - two sub-issues with PRs - with commits +# - single pull request record +# - single direct commit record + + +# def test_generate_isolated_record_types_with_labels_with_type_defined() +# - single issue record +# - single hierarchy issue record - no sub-issues +# - single hierarchy issue record - two sub-issues without PRs +# - single hierarchy issue record - two sub-issues with PRs - no commits +# - single hierarchy issue record - two sub-issues with PRs - with commits +# - single pull request record +# - single direct commit record + + +# def test_generate_records_with_deep_hierarchy_nesting() From 2ce546687ebf407e71db77f2e5dc3ede28f38cf3 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 22 Sep 2025 11:59:41 +0200 Subject: [PATCH 18/33] - Expanded 1st unit test for hierarchy issue factory. --- .../model/hierarchy_issue_record.py | 5 +++ .../model/sub_issue_record.py | 4 +- .../factory/issue_hierarchy_record_factory.py | 42 +++++++++++------- tests/conftest.py | 44 ++++++++++++++++--- .../test_issue_hierarchy_record_factory.py | 41 ++++++++++------- 5 files changed, 98 insertions(+), 38 deletions(-) diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index e658a65a..40db1859 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -139,3 +139,8 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str: # No data loss - in service chapter there will be all detail not presented here return row + + def order_hierarchy_levels(self, level: int = 0) -> None: + self._level = level + for sub_hierarchy_record in self.sub_hierarchy_issues.values(): + sub_hierarchy_record.order_hierarchy_levels(level=level + 1) diff --git a/release_notes_generator/model/sub_issue_record.py b/release_notes_generator/model/sub_issue_record.py index b888edeb..fe1b99ac 100644 --- a/release_notes_generator/model/sub_issue_record.py +++ b/release_notes_generator/model/sub_issue_record.py @@ -6,7 +6,7 @@ from typing import Optional, cast from github.Commit import Commit -from github.Issue import SubIssue +from github.Issue import SubIssue, Issue from github.PullRequest import PullRequest from release_notes_generator.model.issue_record import IssueRecord @@ -20,7 +20,7 @@ class SubIssueRecord(IssueRecord): Inherits from Record and provides additional functionality specific to issues. """ - def __init__(self, sub_issue: SubIssue, issue_labels: Optional[list[str]] = None, skip: bool = False): + def __init__(self, sub_issue: SubIssue | Issue, issue_labels: Optional[list[str]] = None, skip: bool = False): super().__init__(sub_issue, issue_labels, skip) self._labels = issue_labels if issue_labels is not None else [] diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 6a8c682c..a248ba19 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -61,12 +61,20 @@ def generate(self, data: MinedData) -> dict[int | str, Record]: dict[int|str, Record]: A dictionary of records where the key is the issue or pull request number. """ logger.debug("Creation of records started...") + # First register all issues with sub-issues for issue in data.issues: if issue.number in self.__registered_issues: continue self._create_issue_record_using_sub_issues_existence(issue) + # Now register all issues without sub-issues + for issue in data.issues: + if issue.number in self.__registered_issues: + continue + + self._create_issue_record_using_sub_issues_not_existence(issue) + # dev note: Each issue is now in records dict by its issue number - all on same level - no hierarchy # This is useful for population by PRs and commits @@ -139,15 +147,20 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined def _create_issue_record_using_sub_issues_existence(self, issue: Issue) -> None: # use presence of sub-issues as a hint for hierarchy issue or non hierarchy issue sub_issues = list(issue.get_sub_issues()) - if len(sub_issues) == 0: - self._create_record_for_issue(issue) - else: + + if len(sub_issues) > 0: self._create_record_for_hierarchy_issue(issue) for si in sub_issues: - self._create_record_for_sub_issue(si) # register sub-issue and its parent for later hierarchy building self.__sub_issue_parents[si.number] = issue.number # Note: GitHub now allows only 1 parent + def _create_issue_record_using_sub_issues_not_existence(self, issue: Issue) -> None: + # Expected to run after all issue with sub-issues are registered + if issue.number in self.__sub_issue_parents.keys(): + self._create_record_for_sub_issue(issue) + else: + self._create_record_for_issue(issue) + def _create_record_for_hierarchy_issue(self, i: Issue, issue_labels: Optional[list[str]] = None) -> None: """ Create a hierarchy issue record and register sub-issues. @@ -165,6 +178,7 @@ def _create_record_for_hierarchy_issue(self, i: Issue, issue_labels: Optional[li skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) self._records[i.number] = HierarchyIssueRecord(issue=i, skip=skip_record) + self.__registered_issues.append(i.number) logger.debug("Created record for hierarchy issue %d: %s", i.number, i.title) def _get_issue_labels_mix_with_type(self, issue: Issue) -> list[str]: @@ -184,14 +198,14 @@ def _create_record_for_issue(self, issue: Issue, issue_labels: Optional[list[str super()._create_record_for_issue(issue, issue_labels) self.__registered_issues.append(issue.number) - def _create_record_for_sub_issue(self, sub_issue: SubIssue, issue_labels: Optional[list[str]] = None) -> None: + def _create_record_for_sub_issue(self, issue: Issue, issue_labels: Optional[list[str]] = None) -> None: if issue_labels is None: - issue_labels = self._get_issue_labels_mix_with_type(sub_issue) + issue_labels = self._get_issue_labels_mix_with_type(issue) skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) - logger.debug("Created record for sub issue %d: %s", sub_issue.number, sub_issue.title) - self.__registered_issues.append(sub_issue.number) - self._records[sub_issue.number] = SubIssueRecord(sub_issue, issue_labels, skip_record) + logger.debug("Created record for sub issue %d: %s", issue.number, issue.title) + self.__registered_issues.append(issue.number) + self._records[issue.number] = SubIssueRecord(issue, issue_labels, skip_record) def _re_register_hierarchy_issues(self): sub_issues_numbers = list(self.__sub_issue_parents.keys()) @@ -201,10 +215,10 @@ def _re_register_hierarchy_issues(self): # as sub-issue or sub-hierarchy-issue # but do it only for issue where parent issue number is not in _sub_issue_parents keys # Why? We building hierarchy from bottom. Access in records is very easy. - parent_issue_nr: int = self.__sub_issue_parents[sub_issue_number] - if parent_issue_nr in self.__sub_issue_parents: + if sub_issue_number in self.__sub_issue_parents.values(): continue + parent_issue_nr: int = self.__sub_issue_parents[sub_issue_number] parent_rec = cast(HierarchyIssueRecord, self._records[parent_issue_nr]) sub_rec = self._records[sub_issue_number] @@ -213,7 +227,7 @@ def _re_register_hierarchy_issues(self): self._records.pop(sub_issue_number) # remove from main records as it is sub-one self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one elif isinstance(sub_rec, HierarchyIssueRecord): - parent_rec.sub_issues[sub_issue_number] = sub_rec # add to parent as 'Sub' HierarchyIssueRecord + parent_rec.sub_hierarchy_issues[sub_issue_number] = sub_rec # add to parent as 'Sub' HierarchyIssueRecord self._records.pop(sub_issue_number) # remove from main records as it is sub-one self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one else: @@ -235,6 +249,4 @@ def order_hierarchy_levels(self, level: int = 0) -> None: # This have to be done from up to down top_hierarchy_records = [rec for rec in self._records.values() if isinstance(rec, HierarchyIssueRecord)] for rec in top_hierarchy_records: - rec.level = level - for sub_hierarchy_record in rec.sub_hierarchy_issues.values(): - sub_hierarchy_record.order_hierarchy_levels(level=level + 1) + rec.order_hierarchy_levels(level=level + 1) diff --git a/tests/conftest.py b/tests/conftest.py index 534c336c..f2ae6643 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -575,6 +575,7 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( # - single hierarchy issue record - two sub-issues without PRs # - single hierarchy issue record - two sub-issues with PRs - no commits # - single hierarchy issue record - two sub-issues with PRs - with commits + # - single hierarchy issue record - one sub hierarchy issues - two sub-issues with PRs - with commits # - single pull request record (closed, merged) # - single direct commit record data = MinedData() @@ -637,6 +638,35 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( mock_commit_1.message = "Fixed bug in PR 151" hi_two_sub_issues_with_prs_with_commit.get_sub_issues.return_value = [sub_issue_5, sub_issue_6] + # single hierarchy issue record - one sub hierarchy issues - two sub-issues with PRs - with commits + hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit = copy.deepcopy(mock_open_hierarchy_issue) + hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit.number = 304 + hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit.title = "HI304 open" + hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit.body = "I304 open/nRelease Notes:\n- Hierarchy level release note" + sub_hierarchy_issue = copy.deepcopy(mock_open_hierarchy_issue) + sub_hierarchy_issue.number = 350 + sub_hierarchy_issue.title = "HI350 open" + sub_hierarchy_issue.body = "I350 open/nRelease Notes:\n- Sub-hierarchy level release note" + sub_issue_7 = copy.deepcopy(mock_open_sub_issue) + sub_issue_7.number = 403 + sub_issue_7.title = "SI403 open" + sub_issue_7.body = "I403 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_8 = copy.deepcopy(mock_closed_sub_issue) + sub_issue_8.number = 453 + sub_issue_8.title = "SI453 open" + sub_issue_8.body = "I453 open/nRelease Notes:\n- Hierarchy level release note" + mock_pr_closed_4 = copy.deepcopy(mock_pull_closed) + mock_pr_closed_4.url = "http://example.com/pull/152" + mock_pr_closed_4.number = 152 + mock_pr_closed_4.merge_commit_sha = "merge_commit_sha_152" + mock_pr_closed_4.get_labels.return_value = [] + mock_pr_closed_4.body += "\nCloses #453" + mock_commit_2 = copy.deepcopy(mock_commit) + mock_commit_2.sha = "merge_commit_sha_152" + mock_commit_2.message = "Fixed bug in PR 152" + sub_hierarchy_issue.get_sub_issues.return_value = [sub_issue_7, sub_issue_8] + hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit.get_sub_issues.return_value = [sub_hierarchy_issue] + # single pull request record (closed, merged) mock_pr_closed_1 = copy.deepcopy(mock_pull_closed) mock_pr_closed_1.get_labels.return_value = [] @@ -644,16 +674,18 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( mock_pr_merged_1.get_labels.return_value = [] # single direct commit record - mock_commit_2 = copy.deepcopy(mock_commit) - mock_commit_2.sha = "merge_commit_sha_direct" - mock_commit_2.message = "Direct commit example" + mock_commit_3 = copy.deepcopy(mock_commit) + mock_commit_3.sha = "merge_commit_sha_direct" + mock_commit_3.message = "Direct commit example" data.issues = [solo_closed_issue, hi_two_sub_issues_no_prs, sub_issue_1, sub_issue_2, hi_two_sub_issues_with_prs, sub_issue_3, sub_issue_4, - hi_two_sub_issues_with_prs_with_commit, sub_issue_5, sub_issue_6] - data.pull_requests = [mock_pr_closed_1, mock_pr_merged_1, mock_pr_closed_2, mock_pr_closed_3] - data.commits = [mock_commit_1, mock_commit_2] + hi_two_sub_issues_with_prs_with_commit, sub_issue_5, sub_issue_6, + hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit, sub_hierarchy_issue, sub_issue_7, + sub_issue_8] + data.pull_requests = [mock_pr_closed_1, mock_pr_merged_1, mock_pr_closed_2, mock_pr_closed_3, mock_pr_closed_4] + data.commits = [mock_commit_1, mock_commit_2, mock_commit_3] return data diff --git a/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py b/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py index ff61a1d4..181ac412 100644 --- a/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py +++ b/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py @@ -50,7 +50,13 @@ def test_generate_no_input_data(mocker): assert 0 == len(result.values()) - +# - single issue record (closed) +# - single hierarchy issue record - two sub-issues without PRs +# - single hierarchy issue record - two sub-issues with PRs - no commits +# - single hierarchy issue record - two sub-issues with PRs - with commits +# - single hierarchy issue record - one sub hierarchy issues - two sub-issues with PRs - with commits +# - single pull request record (closed, merged) +# - single direct commit record def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_data_isolated_record_types_no_labels_no_type_defined): mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) mock_github_client = mocker.Mock(spec=Github) @@ -64,13 +70,14 @@ def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_ result = factory.generate(mined_data_isolated_record_types_no_labels_no_type_defined) - assert 7 == len(result) - assert {121, 301, 302, 303, 123, 124, "merge_commit_sha_direct"}.issubset(result.keys()) + assert 8 == len(result) + assert {121, 301, 302, 303, 304, 123, 124, "merge_commit_sha_direct"}.issubset(result.keys()) assert isinstance(result[121], IssueRecord) assert isinstance(result[301], HierarchyIssueRecord) assert isinstance(result[302], HierarchyIssueRecord) assert isinstance(result[303], HierarchyIssueRecord) + assert isinstance(result[304], HierarchyIssueRecord) assert isinstance(result[123], PullRequestRecord) assert isinstance(result[124], PullRequestRecord) assert isinstance(result["merge_commit_sha_direct"], CommitRecord) @@ -97,35 +104,39 @@ def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_ assert 1 == rec_hi_3.sub_issues[452].pull_requests_count() assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").message + rec_hi_4 = cast(HierarchyIssueRecord, result[304]) + assert 1 == rec_hi_4.pull_requests_count() + assert 1 == len(rec_hi_4.sub_hierarchy_issues.values()) + assert 0 == len(rec_hi_4.sub_issues.values()) + assert 1 == rec_hi_4.pull_requests_count() + assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").message + # def test_generate_isolated_record_types_with_labels_no_type_defined() -# - single issue record -# - single hierarchy issue record - no sub-issues +# - single issue record (closed) # - single hierarchy issue record - two sub-issues without PRs # - single hierarchy issue record - two sub-issues with PRs - no commits # - single hierarchy issue record - two sub-issues with PRs - with commits -# - single pull request record +# - single hierarchy issue record - one sub hierarchy issues - two sub-issues with PRs - with commits +# - single pull request record (closed, merged) # - single direct commit record # def test_generate_isolated_record_types_no_labels_with_type_defined() -# - single issue record -# - single hierarchy issue record - no sub-issues +# - single issue record (closed) # - single hierarchy issue record - two sub-issues without PRs # - single hierarchy issue record - two sub-issues with PRs - no commits # - single hierarchy issue record - two sub-issues with PRs - with commits -# - single pull request record +# - single hierarchy issue record - one sub hierarchy issues - two sub-issues with PRs - with commits +# - single pull request record (closed, merged) # - single direct commit record # def test_generate_isolated_record_types_with_labels_with_type_defined() -# - single issue record -# - single hierarchy issue record - no sub-issues +# - single issue record (closed) # - single hierarchy issue record - two sub-issues without PRs # - single hierarchy issue record - two sub-issues with PRs - no commits # - single hierarchy issue record - two sub-issues with PRs - with commits -# - single pull request record +# - single hierarchy issue record - one sub hierarchy issues - two sub-issues with PRs - with commits +# - single pull request record (closed, merged) # - single direct commit record - - -# def test_generate_records_with_deep_hierarchy_nesting() From 85c743f6a29f702c3462e45ae4d1393a3e68f702 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 22 Sep 2025 12:20:13 +0200 Subject: [PATCH 19/33] - Fix of failing unit tests influenced latest code changes. --- .../builder/test_release_notes_builder.py | 6 +++--- tests/utils/test_pull_reuqest_utils.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/release_notes/builder/test_release_notes_builder.py b/tests/release_notes/builder/test_release_notes_builder.py index 57b62ab2..e70867b1 100644 --- a/tests/release_notes/builder/test_release_notes_builder.py +++ b/tests/release_notes/builder/test_release_notes_builder.py @@ -208,7 +208,7 @@ def __init__(self, name): # pylint: disable=line-too-long RELEASE_NOTES_DATA_SERVICE_CHAPTERS_MERGED_PR_NO_ISSUE_NO_USER_LABELS = """### Merged PRs without Issue and User Defined Labels ⚠️ -- PR: #123 _Fixed bug_ +- PR: #124 _Fixed bug_ - Fixed bug - Improved performance @@ -283,7 +283,7 @@ def __init__(self, name): """ RELEASE_NOTES_DATA_MERGED_PR_WITH_USER_LABELS_DUPLICITY_REDUCTION_ON = """### Chapter 1 🛠 -- PR: #123 _Fixed bug_ +- PR: #124 _Fixed bug_ - Fixed bug - Improved performance @@ -317,7 +317,7 @@ def __init__(self, name): RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_MERGED_PRS_WITH_USER_LABELS_WITH_SKIP_LABEL = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_MERGED_PRS_WITH_USER_LABELS = """### Chapter 1 🛠 -- #122 _I1+bug_ in #123 +- #122 _I1+bug_ in #124 - Fixed bug - Improved performance + More nice code diff --git a/tests/utils/test_pull_reuqest_utils.py b/tests/utils/test_pull_reuqest_utils.py index 135a7883..c183d54b 100644 --- a/tests/utils/test_pull_reuqest_utils.py +++ b/tests/utils/test_pull_reuqest_utils.py @@ -125,7 +125,7 @@ def fake_post(url, json=None, headers=None, verify=None, timeout=None): monkeypatch.setattr(pru.requests, "post", fake_post) result = pru.get_issues_for_pr(123) - assert result == [11, 22] + assert result == {11, 22} assert captured["url"] == "https://api.github.com/graphql" # Query string correctly formatted assert captured["json"]["query"] == "Q 123 OWN REPO 10" @@ -152,7 +152,7 @@ def json(self): } monkeypatch.setattr(pru.requests, "post", lambda *a, **k: Resp()) - assert pru.get_issues_for_pr(5) == [] + assert pru.get_issues_for_pr(5) == set() def test_get_issues_for_pr_http_error(monkeypatch): _patch_action_inputs(monkeypatch) @@ -210,7 +210,7 @@ def fake_post(*a, **k): first = pru.get_issues_for_pr(900) second = pru.get_issues_for_pr(900) # should use cache - assert first == [9] and second == [9] + assert first == {9} and second == {9} assert calls["count"] == 1 # only one network call def test_get_issues_for_pr_different_numbers_not_cached(monkeypatch): @@ -242,6 +242,6 @@ def fake_post(url, json=None, **k): r1 = pru.get_issues_for_pr(1) r2 = pru.get_issues_for_pr(2) - assert r1 == [1] - assert r2 == [2] + assert r1 == {1} + assert r2 == {2} assert calls["nums"] == [1, 2] From 1a5d9e8d1275662b834437b404830e6b8439dc71 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 22 Sep 2025 16:30:18 +0200 Subject: [PATCH 20/33] - Added new unit test for final coverage of logic. --- .../chapters/service_chapters.py | 14 +- .../model/hierarchy_issue_record.py | 1 - .../factory/issue_hierarchy_record_factory.py | 2 +- show_only.md | 31 + tests/conftest.py | 191 +++- .../builder/test_release_notes_builder.py | 825 +++++++++++++++++- .../test_issue_hierarchy_record_factory.py | 183 +++- 7 files changed, 1201 insertions(+), 46 deletions(-) create mode 100644 show_only.md diff --git a/release_notes_generator/chapters/service_chapters.py b/release_notes_generator/chapters/service_chapters.py index f1b26716..1f1a1df3 100644 --- a/release_notes_generator/chapters/service_chapters.py +++ b/release_notes_generator/chapters/service_chapters.py @@ -136,8 +136,9 @@ def populate(self, records: dict[int | str, Record]) -> None: self.chapters[MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES].add_row(record_id, record.to_chapter_row()) self.used_record_numbers.append(record_id) else: - self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) - self.used_record_numbers.append(record_id) + if record_id not in self.used_record_numbers: + 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: """ @@ -174,6 +175,9 @@ def __populate_closed_issues(self, record: IssueRecord, record_id: int | str) -> if self.__is_row_present(record_id) and not self.duplicity_allowed(): return + if record_id in self.used_record_numbers: + return + self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) self.used_record_numbers.append(record_id) @@ -208,6 +212,9 @@ def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None if self.__is_row_present(record_id) and not self.duplicity_allowed(): return + if record_id in self.used_record_numbers: + return + self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) self.used_record_numbers.append(record_id) @@ -227,6 +234,9 @@ def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None if self.__is_row_present(record_id) and not self.duplicity_allowed(): return + if record_id in self.used_record_numbers: + return + # not record.is_present_in_chapters: self.chapters[OTHERS_NO_TOPIC].add_row(record_id, record.to_chapter_row()) self.used_record_numbers.append(record_id) diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index 40db1859..faee5467 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -115,7 +115,6 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str: if self.contains_release_notes(): sub_indent: str = " " * (self._level + 1) row = f"{row}\n{sub_indent}- _Release Notes_:" - sub_indent = " " * (self._level + 2) rls_block = "\n".join(f"{sub_indent}{line}" if line else "" for line in self.get_rls_notes().splitlines()) row = f"{row}\n{rls_block}" diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index a248ba19..2e81f3fa 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -249,4 +249,4 @@ def order_hierarchy_levels(self, level: int = 0) -> None: # This have to be done from up to down top_hierarchy_records = [rec for rec in self._records.values() if isinstance(rec, HierarchyIssueRecord)] for rec in top_hierarchy_records: - rec.order_hierarchy_levels(level=level + 1) + rec.order_hierarchy_levels(level=level) diff --git a/show_only.md b/show_only.md new file mode 100644 index 00000000..39a562e9 --- /dev/null +++ b/show_only.md @@ -0,0 +1,31 @@ +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +- - None: _HI302 open_ #302 +- _Release Notes_: + - Hierarchy level release note +- #451 _SI451 open_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- - None: _HI303 open_ #303 +- _Release Notes_: + - Hierarchy level release note +- #452 _SI452 open_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- - None: _HI304 open_ #304 +- _Release Notes_: + - Hierarchy level release note +- None: _HI350 open_ #350 + - _Release Notes_: + - Sub-hierarchy level release note + - #453 _SI453 open_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture diff --git a/tests/conftest.py b/tests/conftest.py index f2ae6643..8ca680b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,18 @@ class MockLabel: def __init__(self, name): self.name = name +def mock_safe_call_decorator(_rate_limiter): + def wrapper(fn): + if fn.__name__ == "get_issues_for_pr": + return mock_get_issues_for_pr + return fn + return wrapper + +def mock_get_issues_for_pr(pull_number: int) -> set[int]: + # if pull_number == 150: + # return [451] + return set() + # Fixtures for Custom Chapters @pytest.fixture @@ -581,7 +593,8 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( data = MinedData() # single issue record (closed) - solo_closed_issue = copy.deepcopy(mock_issue_closed) + solo_closed_issue = copy.deepcopy(mock_issue_closed) # 121 + solo_closed_issue.body += "\nRelease Notes:\n- Solo issue release note" solo_closed_issue.get_labels.return_value = [] # single hierarchy issue record - two sub-issues without PRs @@ -604,7 +617,7 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( sub_issue_3.body = "I401 open/nRelease Notes:\n- Hierarchy level release note" sub_issue_4 = copy.deepcopy(mock_closed_sub_issue) sub_issue_4.number = 451 - sub_issue_4.title = "SI451 open" + sub_issue_4.title = "SI451 closed" sub_issue_4.body = "I451 open/nRelease Notes:\n- Hierarchy level release note" mock_pr_closed_2 = copy.deepcopy(mock_pull_closed) mock_pr_closed_2.url = "http://example.com/pull/150" @@ -625,7 +638,7 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( sub_issue_5.body = "I402 open/nRelease Notes:\n- Hierarchy level release note" sub_issue_6 = copy.deepcopy(mock_closed_sub_issue) sub_issue_6.number = 452 - sub_issue_6.title = "SI452 open" + sub_issue_6.title = "SI452 closed" sub_issue_6.body = "I452 open/nRelease Notes:\n- Hierarchy level release note" mock_pr_closed_3 = copy.deepcopy(mock_pull_closed) mock_pr_closed_3.url = "http://example.com/pull/151" @@ -653,7 +666,7 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( sub_issue_7.body = "I403 open/nRelease Notes:\n- Hierarchy level release note" sub_issue_8 = copy.deepcopy(mock_closed_sub_issue) sub_issue_8.number = 453 - sub_issue_8.title = "SI453 open" + sub_issue_8.title = "SI453 closed" sub_issue_8.body = "I453 open/nRelease Notes:\n- Hierarchy level release note" mock_pr_closed_4 = copy.deepcopy(mock_pull_closed) mock_pr_closed_4.url = "http://example.com/pull/152" @@ -668,9 +681,9 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit.get_sub_issues.return_value = [sub_hierarchy_issue] # single pull request record (closed, merged) - mock_pr_closed_1 = copy.deepcopy(mock_pull_closed) + mock_pr_closed_1 = copy.deepcopy(mock_pull_closed) # 123 mock_pr_closed_1.get_labels.return_value = [] - mock_pr_merged_1 = copy.deepcopy(mock_pull_merged) + mock_pr_merged_1 = copy.deepcopy(mock_pull_merged) # 124 mock_pr_merged_1.get_labels.return_value = [] # single direct commit record @@ -679,17 +692,173 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( mock_commit_3.message = "Direct commit example" data.issues = [solo_closed_issue, - hi_two_sub_issues_no_prs, sub_issue_1, sub_issue_2, - hi_two_sub_issues_with_prs, sub_issue_3, sub_issue_4, - hi_two_sub_issues_with_prs_with_commit, sub_issue_5, sub_issue_6, - hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit, sub_hierarchy_issue, sub_issue_7, - sub_issue_8] + hi_two_sub_issues_no_prs, + hi_two_sub_issues_with_prs, + hi_two_sub_issues_with_prs_with_commit, + hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit, # index 4 + sub_issue_1, sub_issue_2, # index 5,6 + sub_issue_3, sub_issue_4, # index 7,8 + sub_issue_5, sub_issue_6, # index 9,10 + sub_issue_7, sub_issue_8, # index 11,12 + sub_hierarchy_issue] # index 13 data.pull_requests = [mock_pr_closed_1, mock_pr_merged_1, mock_pr_closed_2, mock_pr_closed_3, mock_pr_closed_4] data.commits = [mock_commit_1, mock_commit_2, mock_commit_3] return data +@pytest.fixture +def mined_data_isolated_record_types_with_labels_no_type_defined(mocker, mined_data_isolated_record_types_no_labels_no_type_defined): + data = mined_data_isolated_record_types_no_labels_no_type_defined + + # add labels to issues + # - Epic to root one + # - Feature to level 1 hierarchy issue + # - Task to sub-issue + # - enhancement to solo closed issue + # - bug to one of the PRs + # - enhancement to one of the PRs + + l_enh = mocker.Mock(spec=MockLabel) + l_enh.name = "enhancement" + l_epic = mocker.Mock(spec=MockLabel) + l_epic.name = "epic" + l_feature = mocker.Mock(spec=MockLabel) + l_feature.name = "feature" + l_api = mocker.Mock(spec=MockLabel) + l_api.name = "API" + l_bug = mocker.Mock(spec=MockLabel) + l_bug.name = "bug" + + data.issues[0].get_labels.return_value = [l_enh] + + data.issues[1].get_labels.return_value = [l_epic] # 301 + data.issues[2].get_labels.return_value = [l_epic] # 302 + data.issues[3].get_labels.return_value = [l_epic] # 303 + data.issues[4].get_labels.return_value = [l_epic] # 304 + + data.issues[13].get_labels.return_value = [l_feature] # 350 + + data.issues[5].get_labels.return_value = [l_api] + data.issues[6].get_labels.return_value = [l_api] + data.issues[7].get_labels.return_value = [l_api] + data.issues[8].get_labels.return_value = [l_api] + data.issues[9].get_labels.return_value = [l_api] + data.issues[10].get_labels.return_value = [l_api] + data.issues[11].get_labels.return_value = [l_api] + data.issues[12].get_labels.return_value = [l_api] + + data.pull_requests[0].get_labels.return_value = [l_bug] + data.pull_requests[4].get_labels.return_value = [l_bug] + + return data + + +@pytest.fixture +def mined_data_isolated_record_types_no_labels_with_type_defined(mocker, mined_data_isolated_record_types_no_labels_no_type_defined): + data = mined_data_isolated_record_types_no_labels_no_type_defined + + # add types to issues + # - Epic to root one + # - Feature to level 1 hierarchy issue + # - Task to sub-issue + # - enhancement to solo closed issue + # - bug to one of the PRs + # - enhancement to one of the PRs + + t_epic = mocker.Mock(spec=IssueType) + t_epic.name = "Epic" + t_feature = mocker.Mock(spec=IssueType) + t_feature.name = "Feature" + t_task = mocker.Mock(spec=IssueType) + t_task.name = "Task" + t_bug = mocker.Mock(spec=IssueType) + t_bug.name = "Bug" + + l_epic = mocker.Mock(spec=MockLabel) + l_epic.name = "epic" + l_feature = mocker.Mock(spec=MockLabel) + l_feature.name = "feature" + l_task = mocker.Mock(spec=MockLabel) + l_task.name = "task" + + data.issues[0].type = t_feature + data.issues[0].get_labels.return_value = [l_feature] + + data.issues[1].type = t_epic # 301 + data.issues[1].get_labels.return_value = [l_epic] + data.issues[2].type = t_epic # 302 + data.issues[2].get_labels.return_value = [l_epic] + data.issues[3].type = t_epic # 303 + data.issues[3].get_labels.return_value = [l_epic] + data.issues[4].type = t_epic # 304 + data.issues[4].get_labels.return_value = [l_epic] + + data.issues[13].type = t_feature # 350 + data.issues[13].get_labels.return_value = [l_feature] + + data.issues[5].type = t_task + data.issues[5].get_labels.return_value = [l_task] + data.issues[6].type = t_task + data.issues[6].get_labels.return_value = [l_task] + data.issues[7].type = t_task + data.issues[7].get_labels.return_value = [l_task] + data.issues[8].type = t_task + data.issues[8].get_labels.return_value = [l_task] + data.issues[9].type = t_task + data.issues[9].get_labels.return_value = [l_task] + data.issues[10].type = t_task + data.issues[10].get_labels.return_value = [l_task] + data.issues[11].type = t_task + data.issues[11].get_labels.return_value = [l_task] + data.issues[12].type = t_task + data.issues[12].get_labels.return_value = [l_task] + + return data + + +@pytest.fixture +def mined_data_isolated_record_types_with_labels_with_type_defined(mocker, mined_data_isolated_record_types_with_labels_no_type_defined): + data = mined_data_isolated_record_types_with_labels_no_type_defined + + # add types to issues + # - Epic to root one + # - Feature to level 1 hierarchy issue + # - Task to sub-issue + # - enhancement to solo closed issue + # - bug to one of the PRs + # - enhancement to one of the PRs + + t_epic = mocker.Mock(spec=IssueType) + t_epic.name = "Epic" + t_feature = mocker.Mock(spec=IssueType) + t_feature.name = "feature" + t_task = mocker.Mock(spec=IssueType) + t_task.name = "Task" + t_bug = mocker.Mock(spec=IssueType) + t_bug.name = "Bug" + + data.issues[0].type = t_bug + + data.issues[1].type = t_epic # 301 + data.issues[2].type = t_epic # 302 + data.issues[3].type = t_epic # 303 + data.issues[4].type = t_epic # 304 + + data.issues[13].type = t_feature # 350 + + data.issues[5].type = t_task + data.issues[6].type = t_task + data.issues[7].type = t_task + data.issues[8].type = t_task + data.issues[9].type = t_task + data.issues[10].type = t_task + data.issues[11].type = t_task + data.issues[12].type = t_task + + return data + + # Fixtures for Record(s) @pytest.fixture def record_with_issue_open_no_pull(request): diff --git a/tests/release_notes/builder/test_release_notes_builder.py b/tests/release_notes/builder/test_release_notes_builder.py index e70867b1..47a96d47 100644 --- a/tests/release_notes/builder/test_release_notes_builder.py +++ b/tests/release_notes/builder/test_release_notes_builder.py @@ -14,9 +14,14 @@ # limitations under the License. # import pytest +import time +from github import Github from release_notes_generator.builder.builder import ReleaseNotesBuilder from release_notes_generator.chapters.custom_chapters import CustomChapters +from release_notes_generator.record.factory.default_record_factory import DefaultRecordFactory +from release_notes_generator.record.factory.issue_hierarchy_record_factory import IssueHierarchyRecordFactory +from tests.conftest import mock_safe_call_decorator # pylint: disable=pointless-string-statement """ @@ -150,6 +155,578 @@ def __init__(self, name): http://example.com/changelog """ +RELEASE_NOTES_DATA_HIERARCHY_NO_LABELS_NO_TYPE = """### Closed Issues without Pull Request ⚠️ +- #121 _Fix the bug_ in + - Solo issue release note + +### Closed Issues without User Defined Labels ⚠️ +- 🔔 #121 _Fix the bug_ in + - Solo issue release note + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #124 _Fixed bug_ + - Fixed bug + - Improved performance + +### Closed PRs without Issue and User Defined Labels ⚠️ +- PR: #123 _Fixed bug_ + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +- None: _HI302 open_ #302 + - _Release Notes_: + - Hierarchy level release note + - #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- None: _HI303 open_ #303 + - _Release Notes_: + - Hierarchy level release note + - #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- None: _HI304 open_ #304 + - _Release Notes_: + - Hierarchy level release note + - None: _HI350 open_ #350 + - _Release Notes_: + - Sub-hierarchy level release note + - #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Direct commits ⚠️ +- Commit: merge_c... - Fixed bug + +### Others - No Topic ⚠️ +Previous filters caught all Issues or Pull Requests. + +#### Full Changelog +http://example.com/changelog +""" + + +RELEASE_NOTES_DATA_HIERARCHY_WITH_LABELS_NO_TYPE = """### Epics +- None: _HI302 open_ #302 + - _Release Notes_: + - Hierarchy level release note + - #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- None: _HI303 open_ #303 + - _Release Notes_: + - Hierarchy level release note + - #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- None: _HI304 open_ #304 + - _Release Notes_: + - Hierarchy level release note + - None: _HI350 open_ #350 + - _Release Notes_: + - Sub-hierarchy level release note + - #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Chapter 1 🛠 +- PR: #123 _Fixed bug_ + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Closed Issues without Pull Request ⚠️ +- #121 _Fix the bug_ in + - Solo issue release note + +### Closed Issues without User Defined Labels ⚠️ +All closed issues contain at least one of user defined labels. + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #124 _Fixed bug_ + - Fixed bug + - Improved performance + +### Closed PRs without Issue and User Defined Labels ⚠️ +All closed PRs are linked to issues. + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +- 🔔 None: _HI302 open_ #302 + - _Release Notes_: + - Hierarchy level release note + - 🔔 #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- 🔔 None: _HI303 open_ #303 + - _Release Notes_: + - Hierarchy level release note + - 🔔 #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- 🔔 None: _HI304 open_ #304 + - _Release Notes_: + - Hierarchy level release note + - 🔔 None: _HI350 open_ #350 + - _Release Notes_: + - Sub-hierarchy level release note + - 🔔 #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Direct commits ⚠️ +- Commit: merge_c... - Fixed bug + +### Others - No Topic ⚠️ +Previous filters caught all Issues or Pull Requests. + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_HIERARCHY_NO_LABELS_WITH_TYPE = """### Epics +- Epic: _HI302 open_ #302 + - _Release Notes_: + - Hierarchy level release note + - #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- Epic: _HI303 open_ #303 + - _Release Notes_: + - Hierarchy level release note + - #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- Epic: _HI304 open_ #304 + - _Release Notes_: + - Hierarchy level release note + - Feature: _HI350 open_ #350 + - _Release Notes_: + - Sub-hierarchy level release note + - #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Closed Issues without Pull Request ⚠️ +- #121 _Fix the bug_ in + - Solo issue release note + +### Closed Issues without User Defined Labels ⚠️ +All closed issues contain at least one of user defined labels. + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #124 _Fixed bug_ + - Fixed bug + - Improved performance + +### Closed PRs without Issue and User Defined Labels ⚠️ +- PR: #123 _Fixed bug_ + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +- 🔔 Epic: _HI302 open_ #302 + - _Release Notes_: + - Hierarchy level release note + - 🔔 #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- 🔔 Epic: _HI303 open_ #303 + - _Release Notes_: + - Hierarchy level release note + - 🔔 #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- 🔔 Epic: _HI304 open_ #304 + - _Release Notes_: + - Hierarchy level release note + - 🔔 Feature: _HI350 open_ #350 + - _Release Notes_: + - Sub-hierarchy level release note + - 🔔 #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Direct commits ⚠️ +- Commit: merge_c... - Fixed bug + +### Others - No Topic ⚠️ +Previous filters caught all Issues or Pull Requests. + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_HIERARCHY_WITH_LABELS_WITH_TYPE = """### Epics +- Epic: _HI302 open_ #302 + - _Release Notes_: + - Hierarchy level release note + - #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- Epic: _HI303 open_ #303 + - _Release Notes_: + - Hierarchy level release note + - #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- Epic: _HI304 open_ #304 + - _Release Notes_: + - Hierarchy level release note + - feature: _HI350 open_ #350 + - _Release Notes_: + - Sub-hierarchy level release note + - #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Chapter 1 🛠 +- PR: #123 _Fixed bug_ + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Closed Issues without Pull Request ⚠️ +- #121 _Fix the bug_ in + - Solo issue release note + +### Closed Issues without User Defined Labels ⚠️ +All closed issues contain at least one of user defined labels. + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #124 _Fixed bug_ + - Fixed bug + - Improved performance + +### Closed PRs without Issue and User Defined Labels ⚠️ +All closed PRs are linked to issues. + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +- 🔔 Epic: _HI302 open_ #302 + - _Release Notes_: + - Hierarchy level release note + - 🔔 #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- 🔔 Epic: _HI303 open_ #303 + - _Release Notes_: + - Hierarchy level release note + - 🔔 #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- 🔔 Epic: _HI304 open_ #304 + - _Release Notes_: + - Hierarchy level release note + - 🔔 feature: _HI350 open_ #350 + - _Release Notes_: + - Sub-hierarchy level release note + - 🔔 #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Direct commits ⚠️ +- Commit: merge_c... - Fixed bug + +### Others - No Topic ⚠️ +Previous filters caught all Issues or Pull Requests. + +#### Full Changelog +http://example.com/changelog +""" + + +RELEASE_NOTES_NO_DATA_HIERARCHY_NO_LABELS_NO_TYPE = """### Closed Issues without Pull Request ⚠️ +- #121 _Fix the bug_ in + - Solo issue release note +- #450 _SI450 closed_ in + - Hierarchy level release note + +### Closed Issues without User Defined Labels ⚠️ +- 🔔 #121 _Fix the bug_ in + - Solo issue release note +- 🔔 #450 _SI450 closed_ in + - Hierarchy level release note +- #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #124 _Fixed bug_ + - Fixed bug + - Improved performance + +### Closed PRs without Issue and User Defined Labels ⚠️ +- PR: #123 _Fixed bug_ + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +All merged PRs are linked to Closed issues. + +### Direct commits ⚠️ +- Commit: merge_c... - Fixed bug + +### Others - No Topic ⚠️ +Previous filters caught all Issues or Pull Requests. + +#### Full Changelog +http://example.com/changelog +""" + + +RELEASE_NOTES_NO_DATA_HIERARCHY_WITH_LABELS_NO_TYPE = """### Chapter 1 🛠 +- PR: #123 _Fixed bug_ + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Closed Issues without Pull Request ⚠️ +- #121 _Fix the bug_ in + - Solo issue release note +- #450 _SI450 closed_ in + - Hierarchy level release note + +### Closed Issues without User Defined Labels ⚠️ +- 🔔 #450 _SI450 closed_ in + - Hierarchy level release note +- #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #124 _Fixed bug_ + - Fixed bug + - Improved performance + +### Closed PRs without Issue and User Defined Labels ⚠️ +All closed PRs are linked to issues. + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +All merged PRs are linked to Closed issues. + +### Direct commits ⚠️ +- Commit: merge_c... - Fixed bug + +### Others - No Topic ⚠️ +Previous filters caught all Issues or Pull Requests. + +#### Full Changelog +http://example.com/changelog +""" + + +RELEASE_NOTES_NO_DATA_HIERARCHY_NO_LABELS_WITH_TYPE = """### Closed Issues without Pull Request ⚠️ +- #121 _Fix the bug_ in + - Solo issue release note +- #450 _SI450 closed_ in + - Hierarchy level release note + +### Closed Issues without User Defined Labels ⚠️ +- 🔔 #450 _SI450 closed_ in + - Hierarchy level release note +- #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #124 _Fixed bug_ + - Fixed bug + - Improved performance + +### Closed PRs without Issue and User Defined Labels ⚠️ +- PR: #123 _Fixed bug_ + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +All merged PRs are linked to Closed issues. + +### Direct commits ⚠️ +- Commit: merge_c... - Fixed bug + +### Others - No Topic ⚠️ +Previous filters caught all Issues or Pull Requests. + +#### Full Changelog +http://example.com/changelog +""" + + +RELEASE_NOTES_NO_DATA_HIERARCHY_WITH_LABELS_WITH_TYPE = """### Chapter 1 🛠 +- PR: #123 _Fixed bug_ + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Closed Issues without Pull Request ⚠️ +- #121 _Fix the bug_ in + - Solo issue release note +- #450 _SI450 closed_ in + - Hierarchy level release note + +### Closed Issues without User Defined Labels ⚠️ +- 🔔 #450 _SI450 closed_ in + - Hierarchy level release note +- #451 _SI451 closed_ in #150 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- #452 _SI452 closed_ in #151 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture +- #453 _SI453 closed_ in #152 + - Hierarchy level release note + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +### Merged PRs without Issue and User Defined Labels ⚠️ +- PR: #124 _Fixed bug_ + - Fixed bug + - Improved performance + +### Closed PRs without Issue and User Defined Labels ⚠️ +All closed PRs are linked to issues. + +### Merged PRs Linked to 'Not Closed' Issue ⚠️ +All merged PRs are linked to Closed issues. + +### Direct commits ⚠️ +- Commit: merge_c... - Fixed bug + +### Others - No Topic ⚠️ +Previous filters caught all Issues or Pull Requests. + +#### Full Changelog +http://example.com/changelog +""" + RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_ONE_LABEL_HIERARCHY = """### Chapter 1 🛠 - #122 _I1+bug_ in #123 - Fixed bug @@ -1012,32 +1589,250 @@ def test_build_closed_pr_service_chapter_without_issue_with_skip_label_on_pr( assert expected_release_notes == actual_release_notes -def test_build_hierarchy_issue_with_one_custom_label( - custom_chapters_not_print_empty_chapters, - record_with_hierarchy_issues, - record_with_issue_closed_one_pull_merged, - record_with_pr_only, - record_with_direct_commit, - mocker +def test_build_hierarchy_rls_notes_no_labels_no_type( + mocker, custom_chapters_not_print_empty_chapters, mined_data_isolated_record_types_no_labels_no_type_defined ): - expected_release_notes = RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_ONE_LABEL_HIERARCHY - rec_1 = record_with_hierarchy_issues - rec_2 = record_with_issue_closed_one_pull_merged - rec_3 = record_with_pr_only - rec_4 = record_with_direct_commit - mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=False) + expected_release_notes = RELEASE_NOTES_DATA_HIERARCHY_NO_LABELS_NO_TYPE + + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=True) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=True) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") + + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = IssueHierarchyRecordFactory(github=mock_github_client) + records = factory.generate(mined_data_isolated_record_types_no_labels_no_type_defined) + + builder = ReleaseNotesBuilder( + records=records, + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters_not_print_empty_chapters, + ) + + actual_release_notes = builder.build() + + assert expected_release_notes == actual_release_notes + + +def test_build_hierarchy_rls_notes_with_labels_no_type( + mocker, custom_chapters_not_print_empty_chapters, mined_data_isolated_record_types_with_labels_no_type_defined +): + expected_release_notes = RELEASE_NOTES_DATA_HIERARCHY_WITH_LABELS_NO_TYPE + + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=True) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=True) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") + + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = IssueHierarchyRecordFactory(github=mock_github_client) + records = factory.generate(mined_data_isolated_record_types_with_labels_no_type_defined) + + builder = ReleaseNotesBuilder( + records=records, + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters_not_print_empty_chapters, + ) + + actual_release_notes = builder.build() + + assert expected_release_notes == actual_release_notes + + +def test_build_hierarchy_rls_notes_no_labels_with_type( + mocker, custom_chapters_not_print_empty_chapters, mined_data_isolated_record_types_no_labels_with_type_defined +): + expected_release_notes = RELEASE_NOTES_DATA_HIERARCHY_NO_LABELS_WITH_TYPE + + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=True) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=True) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") + + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = IssueHierarchyRecordFactory(github=mock_github_client) + records = factory.generate(mined_data_isolated_record_types_no_labels_with_type_defined) + + builder = ReleaseNotesBuilder( + records=records, + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters_not_print_empty_chapters, + ) + + actual_release_notes = builder.build() + + assert expected_release_notes == actual_release_notes + +def test_build_hierarchy_rls_notes_with_labels_with_type( + mocker, custom_chapters_not_print_empty_chapters, mined_data_isolated_record_types_with_labels_with_type_defined +): + expected_release_notes = RELEASE_NOTES_DATA_HIERARCHY_WITH_LABELS_WITH_TYPE + + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=True) mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=True) mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = IssueHierarchyRecordFactory(github=mock_github_client) + records = factory.generate(mined_data_isolated_record_types_with_labels_with_type_defined) + + builder = ReleaseNotesBuilder( + records=records, + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters_not_print_empty_chapters, + ) + + actual_release_notes = builder.build() + + assert expected_release_notes == actual_release_notes + + +def test_build_no_hierarchy_rls_notes_no_labels_no_type_with_hierarchy_data( + mocker, custom_chapters_not_print_empty_chapters, mined_data_isolated_record_types_no_labels_no_type_defined +): + expected_release_notes = RELEASE_NOTES_NO_DATA_HIERARCHY_NO_LABELS_NO_TYPE + + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=True) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=False) + # mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") + + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = DefaultRecordFactory(github=mock_github_client) + records = factory.generate(mined_data_isolated_record_types_no_labels_no_type_defined) + builder = ReleaseNotesBuilder( - records={rec_1.record_id: rec_1, rec_2.record_id: rec_2, rec_3.record_id: rec_3, rec_4.record_id: rec_4}, + records=records, changelog_url=DEFAULT_CHANGELOG_URL, custom_chapters=custom_chapters_not_print_empty_chapters, ) actual_release_notes = builder.build() - print("XXX - actual release notes") + assert expected_release_notes == actual_release_notes + +def test_build_no_hierarchy_rls_notes_with_labels_no_type_with_hierarchy_data( + mocker, custom_chapters_not_print_empty_chapters, mined_data_isolated_record_types_with_labels_no_type_defined +): + expected_release_notes = RELEASE_NOTES_NO_DATA_HIERARCHY_WITH_LABELS_NO_TYPE + + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=True) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=False) + # mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") + + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = DefaultRecordFactory(github=mock_github_client) + records = factory.generate(mined_data_isolated_record_types_with_labels_no_type_defined) + + builder = ReleaseNotesBuilder( + records=records, + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters_not_print_empty_chapters, + ) + + actual_release_notes = builder.build() + + assert expected_release_notes == actual_release_notes + + +def test_build_no_hierarchy_rls_notes_no_labels_with_type_with_hierarchy_data( + mocker, custom_chapters_not_print_empty_chapters, mined_data_isolated_record_types_no_labels_with_type_defined +): + expected_release_notes = RELEASE_NOTES_NO_DATA_HIERARCHY_NO_LABELS_WITH_TYPE + + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=True) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=False) + # mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") + + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = DefaultRecordFactory(github=mock_github_client) + records = factory.generate(mined_data_isolated_record_types_no_labels_with_type_defined) + + builder = ReleaseNotesBuilder( + records=records, + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters_not_print_empty_chapters, + ) + + actual_release_notes = builder.build() + + assert expected_release_notes == actual_release_notes + + +def test_build_no_hierarchy_rls_notes_with_labels_with_type_with_hierarchy_data( + mocker, custom_chapters_not_print_empty_chapters, mined_data_isolated_record_types_with_labels_with_type_defined +): + expected_release_notes = RELEASE_NOTES_NO_DATA_HIERARCHY_WITH_LABELS_WITH_TYPE + + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=True) + mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=False) + # mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") + + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = DefaultRecordFactory(github=mock_github_client) + records = factory.generate(mined_data_isolated_record_types_with_labels_with_type_defined) + + builder = ReleaseNotesBuilder( + records=records, + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters_not_print_empty_chapters, + ) + + actual_release_notes = builder.build() + + print("XXX") print(actual_release_notes) print("XXX") diff --git a/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py b/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py index 181ac412..082c8543 100644 --- a/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py +++ b/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py @@ -17,7 +17,6 @@ from typing import cast from github import Github -from mypy.semanal_classprop import calculate_class_abstract_status from release_notes_generator.model.commit_record import CommitRecord from release_notes_generator.model.hierarchy_issue_record import HierarchyIssueRecord @@ -25,18 +24,7 @@ from release_notes_generator.model.mined_data import MinedData from release_notes_generator.model.pull_request_record import PullRequestRecord from release_notes_generator.record.factory.issue_hierarchy_record_factory import IssueHierarchyRecordFactory - -def mock_safe_call_decorator(_rate_limiter): - def wrapper(fn): - if fn.__name__ == "get_issues_for_pr": - return mock_get_issues_for_pr - return fn - return wrapper - -def mock_get_issues_for_pr(pull_number: int) -> set[int]: - # if pull_number == 150: - # return [451] - return set() +from tests.conftest import mock_safe_call_decorator # generate @@ -90,12 +78,14 @@ def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_ assert 0 == len(rec_hi_1.sub_hierarchy_issues.values()) assert 2 == len(rec_hi_1.sub_issues.values()) assert 0 == rec_hi_1.sub_issues[450].pull_requests_count() + assert 0 == rec_hi_1.level rec_hi_2 = cast(HierarchyIssueRecord, result[302]) assert 1 == rec_hi_2.pull_requests_count() assert 0 == len(rec_hi_2.sub_hierarchy_issues.values()) assert 2 == len(rec_hi_2.sub_issues.values()) assert 1 == rec_hi_2.sub_issues[451].pull_requests_count() + assert 0 == rec_hi_2.level rec_hi_3 = cast(HierarchyIssueRecord, result[303]) assert 1 == rec_hi_3.pull_requests_count() @@ -103,6 +93,7 @@ def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_ assert 2 == len(rec_hi_3.sub_issues.values()) assert 1 == rec_hi_3.sub_issues[452].pull_requests_count() assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").message + assert 0 == rec_hi_3.level rec_hi_4 = cast(HierarchyIssueRecord, result[304]) assert 1 == rec_hi_4.pull_requests_count() @@ -110,9 +101,12 @@ def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_ assert 0 == len(rec_hi_4.sub_issues.values()) assert 1 == rec_hi_4.pull_requests_count() assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").message + assert 0 == rec_hi_4.level + + rec_hi_5 = cast(HierarchyIssueRecord, result[304]) + assert 1 == rec_hi_5.sub_hierarchy_issues[350].level -# def test_generate_isolated_record_types_with_labels_no_type_defined() # - single issue record (closed) # - single hierarchy issue record - two sub-issues without PRs # - single hierarchy issue record - two sub-issues with PRs - no commits @@ -120,9 +114,61 @@ def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_ # - single hierarchy issue record - one sub hierarchy issues - two sub-issues with PRs - with commits # - single pull request record (closed, merged) # - single direct commit record +def test_generate_isolated_record_types_with_labels_no_type_defined(mocker, mined_data_isolated_record_types_with_labels_no_type_defined): + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = IssueHierarchyRecordFactory(github=mock_github_client) + + result = factory.generate(mined_data_isolated_record_types_with_labels_no_type_defined) + + assert 8 == len(result) + assert {121, 301, 302, 303, 304, 123, 124, "merge_commit_sha_direct"}.issubset(result.keys()) + + assert isinstance(result[121], IssueRecord) + assert isinstance(result[301], HierarchyIssueRecord) + assert isinstance(result[302], HierarchyIssueRecord) + assert isinstance(result[303], HierarchyIssueRecord) + assert isinstance(result[304], HierarchyIssueRecord) + assert isinstance(result[123], PullRequestRecord) + assert isinstance(result[124], PullRequestRecord) + assert isinstance(result["merge_commit_sha_direct"], CommitRecord) + + rec_i = cast(IssueRecord, result[121]) + assert 0 == rec_i.pull_requests_count() + + rec_hi_1 = cast(HierarchyIssueRecord, result[301]) + assert 0 == rec_hi_1.pull_requests_count() + assert 0 == len(rec_hi_1.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_1.sub_issues.values()) + assert 0 == rec_hi_1.sub_issues[450].pull_requests_count() + + rec_hi_2 = cast(HierarchyIssueRecord, result[302]) + assert 1 == rec_hi_2.pull_requests_count() + assert 0 == len(rec_hi_2.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_2.sub_issues.values()) + assert 1 == rec_hi_2.sub_issues[451].pull_requests_count() + + rec_hi_3 = cast(HierarchyIssueRecord, result[303]) + assert 1 == rec_hi_3.pull_requests_count() + assert 0 == len(rec_hi_3.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_3.sub_issues.values()) + assert 1 == rec_hi_3.sub_issues[452].pull_requests_count() + assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").message + + rec_hi_4 = cast(HierarchyIssueRecord, result[304]) + assert 1 == rec_hi_4.pull_requests_count() + assert 1 == len(rec_hi_4.sub_hierarchy_issues.values()) + assert 0 == len(rec_hi_4.sub_issues.values()) + assert 1 == rec_hi_4.pull_requests_count() + assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").message -# def test_generate_isolated_record_types_no_labels_with_type_defined() # - single issue record (closed) # - single hierarchy issue record - two sub-issues without PRs # - single hierarchy issue record - two sub-issues with PRs - no commits @@ -130,9 +176,61 @@ def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_ # - single hierarchy issue record - one sub hierarchy issues - two sub-issues with PRs - with commits # - single pull request record (closed, merged) # - single direct commit record +def test_generate_isolated_record_types_no_labels_with_type_defined(mocker, mined_data_isolated_record_types_no_labels_with_type_defined): + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = IssueHierarchyRecordFactory(github=mock_github_client) + + result = factory.generate(mined_data_isolated_record_types_no_labels_with_type_defined) + + assert 8 == len(result) + assert {121, 301, 302, 303, 304, 123, 124, "merge_commit_sha_direct"}.issubset(result.keys()) + + assert isinstance(result[121], IssueRecord) + assert isinstance(result[301], HierarchyIssueRecord) + assert isinstance(result[302], HierarchyIssueRecord) + assert isinstance(result[303], HierarchyIssueRecord) + assert isinstance(result[304], HierarchyIssueRecord) + assert isinstance(result[123], PullRequestRecord) + assert isinstance(result[124], PullRequestRecord) + assert isinstance(result["merge_commit_sha_direct"], CommitRecord) + + rec_i = cast(IssueRecord, result[121]) + assert 0 == rec_i.pull_requests_count() + + rec_hi_1 = cast(HierarchyIssueRecord, result[301]) + assert 0 == rec_hi_1.pull_requests_count() + assert 0 == len(rec_hi_1.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_1.sub_issues.values()) + assert 0 == rec_hi_1.sub_issues[450].pull_requests_count() + + rec_hi_2 = cast(HierarchyIssueRecord, result[302]) + assert 1 == rec_hi_2.pull_requests_count() + assert 0 == len(rec_hi_2.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_2.sub_issues.values()) + assert 1 == rec_hi_2.sub_issues[451].pull_requests_count() + + rec_hi_3 = cast(HierarchyIssueRecord, result[303]) + assert 1 == rec_hi_3.pull_requests_count() + assert 0 == len(rec_hi_3.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_3.sub_issues.values()) + assert 1 == rec_hi_3.sub_issues[452].pull_requests_count() + assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").message + + rec_hi_4 = cast(HierarchyIssueRecord, result[304]) + assert 1 == rec_hi_4.pull_requests_count() + assert 1 == len(rec_hi_4.sub_hierarchy_issues.values()) + assert 0 == len(rec_hi_4.sub_issues.values()) + assert 1 == rec_hi_4.pull_requests_count() + assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").message -# def test_generate_isolated_record_types_with_labels_with_type_defined() # - single issue record (closed) # - single hierarchy issue record - two sub-issues without PRs # - single hierarchy issue record - two sub-issues with PRs - no commits @@ -140,3 +238,56 @@ def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_ # - single hierarchy issue record - one sub hierarchy issues - two sub-issues with PRs - with commits # - single pull request record (closed, merged) # - single direct commit record +def test_generate_isolated_record_types_with_labels_with_type_defined(mocker, mined_data_isolated_record_types_with_labels_with_type_defined): + mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) + mock_github_client = mocker.Mock(spec=Github) + + mock_rate_limit = mocker.Mock() + mock_rate_limit.rate.remaining = 10 + mock_rate_limit.rate.reset.timestamp.return_value = time.time() + 3600 + mock_github_client.get_rate_limit.return_value = mock_rate_limit + + factory = IssueHierarchyRecordFactory(github=mock_github_client) + + result = factory.generate(mined_data_isolated_record_types_with_labels_with_type_defined) + + assert 8 == len(result) + assert {121, 301, 302, 303, 304, 123, 124, "merge_commit_sha_direct"}.issubset(result.keys()) + + assert isinstance(result[121], IssueRecord) + assert isinstance(result[301], HierarchyIssueRecord) + assert isinstance(result[302], HierarchyIssueRecord) + assert isinstance(result[303], HierarchyIssueRecord) + assert isinstance(result[304], HierarchyIssueRecord) + assert isinstance(result[123], PullRequestRecord) + assert isinstance(result[124], PullRequestRecord) + assert isinstance(result["merge_commit_sha_direct"], CommitRecord) + + rec_i = cast(IssueRecord, result[121]) + assert 0 == rec_i.pull_requests_count() + + rec_hi_1 = cast(HierarchyIssueRecord, result[301]) + assert 0 == rec_hi_1.pull_requests_count() + assert 0 == len(rec_hi_1.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_1.sub_issues.values()) + assert 0 == rec_hi_1.sub_issues[450].pull_requests_count() + + rec_hi_2 = cast(HierarchyIssueRecord, result[302]) + assert 1 == rec_hi_2.pull_requests_count() + assert 0 == len(rec_hi_2.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_2.sub_issues.values()) + assert 1 == rec_hi_2.sub_issues[451].pull_requests_count() + + rec_hi_3 = cast(HierarchyIssueRecord, result[303]) + assert 1 == rec_hi_3.pull_requests_count() + assert 0 == len(rec_hi_3.sub_hierarchy_issues.values()) + assert 2 == len(rec_hi_3.sub_issues.values()) + assert 1 == rec_hi_3.sub_issues[452].pull_requests_count() + assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").message + + rec_hi_4 = cast(HierarchyIssueRecord, result[304]) + assert 1 == rec_hi_4.pull_requests_count() + assert 1 == len(rec_hi_4.sub_hierarchy_issues.values()) + assert 0 == len(rec_hi_4.sub_issues.values()) + assert 1 == rec_hi_4.pull_requests_count() + assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").message From f0bd99a774c5a7d0e9f9b0ba7055bf48b409fc29 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Mon, 22 Sep 2025 16:51:33 +0200 Subject: [PATCH 21/33] Fixed problem from checkers and linters. --- .pylintrc | 4 ++-- .../model/hierarchy_issue_record.py | 9 +++++++ release_notes_generator/model/issue_record.py | 4 ++-- .../record/factory/default_record_factory.py | 13 ++++------ .../factory/issue_hierarchy_record_factory.py | 24 +++++++++++-------- 5 files changed, 32 insertions(+), 22 deletions(-) diff --git a/.pylintrc b/.pylintrc index a4f988ca..7096ec78 100644 --- a/.pylintrc +++ b/.pylintrc @@ -304,7 +304,7 @@ max-attributes=7 max-bool-expr=5 # Maximum number of branch for function / method body. -max-branches=12 +max-branches=13 # Maximum number of locals for function / method body. max-locals=15 @@ -316,7 +316,7 @@ max-parents=7 max-public-methods=20 # Maximum number of return / yield for function / method body. -max-returns=6 +max-returns=7 # Maximum number of statements in function / method body. max-statements=50 diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index faee5467..011f639d 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -140,6 +140,15 @@ def to_chapter_row(self, add_into_chapters: bool = True) -> str: return row def order_hierarchy_levels(self, level: int = 0) -> None: + """ + Orders the hierarchy levels of the issue and its sub-issues recursively. + + Parameters: + level (int): The starting level for the hierarchy. Default is 0. + + Returns: + None + """ self._level = level for sub_hierarchy_record in self.sub_hierarchy_issues.values(): sub_hierarchy_record.order_hierarchy_levels(level=level + 1) diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py index 9d734b48..b00f853a 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -96,8 +96,8 @@ def find_issue(self, issue_number: int) -> Optional["IssueRecord"]: """ if self._issue.number == issue_number: return self - else: - return None + + return None def to_chapter_row(self, add_into_chapters: bool = True) -> str: if add_into_chapters: diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index 502b220a..9d9ad648 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -65,9 +65,7 @@ def generate(self, data: MinedData) -> dict[int | str, Record]: def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: detected_issues = extract_issue_numbers_from_body(pull) logger.debug("Detected issues - from body: %s", detected_issues) - detected_issues.update( - safe_call(get_issues_for_pr)(pull_number=pull.number) - ) # TODO - safe call is now inside + detected_issues.update(self._safe_call(get_issues_for_pr)(pull_number=pull.number)) logger.debug("Detected issues - final: %s", detected_issues) for parent_issue_number in detected_issues: @@ -80,7 +78,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: parent_issue_number, ) parent_issue = ( - safe_call(data.repository.get_issue)(parent_issue_number) if data.repository else None + self._safe_call(data.repository.get_issue)(parent_issue_number) if data.repository else None ) if parent_issue is not None: self._create_record_for_issue(parent_issue) @@ -96,9 +94,6 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: parent_issue_number, ) - rate_limiter = GithubRateLimiter(self._github) - safe_call = safe_call_decorator(rate_limiter) - logger.debug("Registering issues to records...") for issue in data.issues: self._create_record_for_issue(issue) @@ -108,7 +103,9 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: pull_labels = [label.name for label in pull.get_labels()] skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) - if not safe_call(get_issues_for_pr)(pull_number=pull.number) and not extract_issue_numbers_from_body(pull): + if not self._safe_call(get_issues_for_pr)(pull_number=pull.number) and not extract_issue_numbers_from_body( + pull + ): self._records[pull.number] = PullRequestRecord(pull, skip=skip_record) logger.debug("Created record for PR %d: %s", pull.number, pull.title) else: diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 2e81f3fa..b09d18a2 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -22,7 +22,7 @@ from typing import cast, Optional from github import Github -from github.Issue import Issue, SubIssue +from github.Issue import Issue from github.PullRequest import PullRequest from release_notes_generator.model.commit_record import CommitRecord @@ -112,8 +112,10 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined pull_issues_set.update(extract_issue_numbers_from_body(pull)) pull_issues: list[int] = list(pull_issues_set) if len(pull_issues) > 0: + record_keys = self._records.keys() + for issue_number in pull_issues: - if issue_number not in self._records.keys(): + if issue_number not in record_keys: logger.warning( "Detected PR %d linked to issue %d which is not in the list of received issues. " "Fetching ...", @@ -125,7 +127,7 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined self._create_issue_record_using_sub_issues_existence(parent_issue) return - if issue_number in self._records.keys() and isinstance( + if issue_number in record_keys and isinstance( self._records[issue_number], (SubIssueRecord, HierarchyIssueRecord, IssueRecord) ): rec = cast(IssueRecord, self._records[issue_number]) @@ -156,7 +158,7 @@ def _create_issue_record_using_sub_issues_existence(self, issue: Issue) -> None: def _create_issue_record_using_sub_issues_not_existence(self, issue: Issue) -> None: # Expected to run after all issue with sub-issues are registered - if issue.number in self.__sub_issue_parents.keys(): + if issue.number in self.__sub_issue_parents.keys(): # pylint: disable=consider-iterating-dictionary self._create_record_for_sub_issue(issue) else: self._create_record_for_issue(issue) @@ -223,13 +225,15 @@ def _re_register_hierarchy_issues(self): sub_rec = self._records[sub_issue_number] if isinstance(sub_rec, SubIssueRecord): - parent_rec.sub_issues[sub_issue_number] = sub_rec # add to parent as SubIssueRecord - self._records.pop(sub_issue_number) # remove from main records as it is sub-one - self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one + parent_rec.sub_issues[sub_issue_number] = sub_rec # add to parent as SubIssueRecord + self._records.pop(sub_issue_number) # remove from main records as it is sub-one + self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one elif isinstance(sub_rec, HierarchyIssueRecord): - parent_rec.sub_hierarchy_issues[sub_issue_number] = sub_rec # add to parent as 'Sub' HierarchyIssueRecord - self._records.pop(sub_issue_number) # remove from main records as it is sub-one - self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one + parent_rec.sub_hierarchy_issues[sub_issue_number] = ( + sub_rec # add to parent as 'Sub' HierarchyIssueRecord + ) + self._records.pop(sub_issue_number) # remove from main records as it is sub-one + self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one else: logger.error("Detected IssueRecord in position of SubIssueRecord - skipping it") # Dev note: IssueRecord is expected to be stand-alone - not sub-issue From 2235fbaf2ce326c00f2abe9b32b49e80113fb9ef Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 08:27:11 +0200 Subject: [PATCH 22/33] Fixed Rabbit review notes - outside diff range. --- README.md | 1 + release_notes_generator/builder/builder.py | 2 +- release_notes_generator/chapters/base_chapters.py | 4 ++-- release_notes_generator/utils/pull_request_utils.py | 1 - 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6a8cc19e..d8244d03 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ Add the following step to your GitHub workflow (in example are used non-default - {"title": "New Features 🎉", "label": "feature"} - {"title": "Bugfixes 🛠", "label": "bug"} + hierarchy: true duplicity-scope: 'service' duplicity-icon: '🔁' published-at: true diff --git a/release_notes_generator/builder/builder.py b/release_notes_generator/builder/builder.py index 3e94915c..4da143c6 100644 --- a/release_notes_generator/builder/builder.py +++ b/release_notes_generator/builder/builder.py @@ -68,7 +68,7 @@ def build(self) -> str: service_chapters = ServiceChapters( print_empty_chapters=self.print_empty_chapters, user_defined_labels=user_defined_labels, - used_record_numbers=self.custom_chapters.populated_record_numbers, + used_record_numbers=self.custom_chapters.populated_record_numbers_list, ) service_chapters.populate(self.records) diff --git a/release_notes_generator/chapters/base_chapters.py b/release_notes_generator/chapters/base_chapters.py index 90209ca5..75174296 100644 --- a/release_notes_generator/chapters/base_chapters.py +++ b/release_notes_generator/chapters/base_chapters.py @@ -34,7 +34,7 @@ def __init__(self, sort_ascending: bool = True, print_empty_chapters: bool = Tru self.sort_ascending = sort_ascending self.print_empty_chapters = print_empty_chapters self.chapters: dict[str, Chapter] = {} - self.populated_record_numbers: list[int | str] = [] + self._populated_record_numbers: list[int | str] = [] # datetime point in time used as begin of release self._since: Optional[datetime] = None @@ -46,7 +46,7 @@ def populated_record_numbers_list(self) -> list[int | str]: @return: A list of populated record numbers. """ - return self.populated_record_numbers + return self._populated_record_numbers @property def since(self) -> datetime: diff --git a/release_notes_generator/utils/pull_request_utils.py b/release_notes_generator/utils/pull_request_utils.py index 81801338..dc4ad068 100644 --- a/release_notes_generator/utils/pull_request_utils.py +++ b/release_notes_generator/utils/pull_request_utils.py @@ -28,7 +28,6 @@ from release_notes_generator.utils.constants import ISSUES_FOR_PRS, LINKED_ISSUES_MAX -@lru_cache(maxsize=None) def extract_issue_numbers_from_body(pr: PullRequest) -> set[int]: """ Extracts the numbers of the issues mentioned in the body of the pull request. From c034c75e8850c99bb2f16e21ec0fad7d42f195dc Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 09:34:34 +0200 Subject: [PATCH 23/33] Rabbit: Fixed review notes and several nitpicks. --- .../chapters/custom_chapters.py | 2 +- release_notes_generator/miner.py | 10 ++++---- release_notes_generator/model/issue_record.py | 6 +++-- release_notes_generator/model/record.py | 4 ++-- .../model/sub_issue_record.py | 9 +++----- .../record/factory/default_record_factory.py | 12 ++++++---- .../factory/issue_hierarchy_record_factory.py | 23 +++++++++++-------- release_notes_generator/utils/constants.py | 2 +- tests/conftest.py | 6 ++--- .../builder/test_release_notes_builder.py | 17 +++++++------- .../test_issue_hierarchy_record_factory.py | 16 ++++++------- 11 files changed, 56 insertions(+), 51 deletions(-) diff --git a/release_notes_generator/chapters/custom_chapters.py b/release_notes_generator/chapters/custom_chapters.py index dc1b50e1..b90c65d2 100644 --- a/release_notes_generator/chapters/custom_chapters.py +++ b/release_notes_generator/chapters/custom_chapters.py @@ -63,7 +63,7 @@ def populate(self, records: dict[int | str, Record]) -> None: continue pulls_count = 1 - if isinstance(records[record_id], (HierarchyIssueRecord | IssueRecord | SubIssueRecord)): + if isinstance(records[record_id], (HierarchyIssueRecord, IssueRecord, SubIssueRecord)): pulls_count = cast(IssueRecord, records[record_id]).pull_requests_count() for record_label in records[record_id].labels: # iterate all labels of the record (issue, or 1st PR) diff --git a/release_notes_generator/miner.py b/release_notes_generator/miner.py index e6244f87..5576626f 100644 --- a/release_notes_generator/miner.py +++ b/release_notes_generator/miner.py @@ -133,10 +133,12 @@ def _get_issues(self, data: MinedData): return # Derive 'since' from release - data.since = data.release.created_at - if ActionInputs.get_published_at(): - data.since = data.release.published_at - + prefer_published = ActionInputs.get_published_at() + data.since = ( + data.release.published_at + if prefer_published and getattr(data.release, "published_at", None) + else data.release.created_at + ) issues_since = self._safe_call(data.repository.get_issues)( state=IssueRecord.ISSUE_STATE_ALL, since=data.since, diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py index b00f853a..c5b6910b 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -8,6 +8,7 @@ from github.Commit import Commit from github.Issue import Issue +from github.Label import Label from github.PullRequest import PullRequest from release_notes_generator.action_inputs import ActionInputs @@ -30,7 +31,7 @@ def __init__(self, issue: Issue, issue_labels: Optional[list[str]] = None, skip: super().__init__(skip=skip) self._issue: Issue = issue - self._labels = issue_labels if issue_labels is not None else [] + self._labels: Optional[list[Label]] = issue_labels if issue_labels is not None else None if issue is not None and issue.type is not None: self._issue_type = issue.type.name @@ -82,7 +83,8 @@ def issue_type(self) -> Optional[str]: # methods - override Record methods def get_labels(self) -> list[str]: - return [label.name for label in self._issue.get_labels()] + self._labels = [label.name for label in list(self._issue.get_labels())] + return self.labels def find_issue(self, issue_number: int) -> Optional["IssueRecord"]: """ diff --git a/release_notes_generator/model/record.py b/release_notes_generator/model/record.py index 0625d88a..bc325cf1 100644 --- a/release_notes_generator/model/record.py +++ b/release_notes_generator/model/record.py @@ -37,7 +37,7 @@ def __init__(self, labels: Optional[list[str]] = None, skip: bool = False): self._present_in_chapters = 0 self._skip = skip self._is_release_note_detected: Optional[bool] = None - self._labels = labels if labels is not None else [] + self._labels: Optional[list[str]] = labels if labels is not None else None self._rls_notes: Optional[str] = None # single annotation here # properties @@ -62,7 +62,7 @@ def labels(self) -> list[str]: Returns: list[str]: A list of labels associated with the record. """ - if len(self._labels) == 0: + if self._labels is None: self._labels = self.get_labels() return self._labels diff --git a/release_notes_generator/model/sub_issue_record.py b/release_notes_generator/model/sub_issue_record.py index fe1b99ac..ebed0aa8 100644 --- a/release_notes_generator/model/sub_issue_record.py +++ b/release_notes_generator/model/sub_issue_record.py @@ -23,11 +23,6 @@ class SubIssueRecord(IssueRecord): def __init__(self, sub_issue: SubIssue | Issue, issue_labels: Optional[list[str]] = None, skip: bool = False): super().__init__(sub_issue, issue_labels, skip) - self._labels = issue_labels if issue_labels is not None else [] - - self._pull_requests: dict[int, PullRequest] = {} - self._commits: dict[int, dict[str, Commit]] = {} - # properties - override IssueRecord properties @property @@ -36,6 +31,8 @@ def issue(self) -> SubIssue: Gets the issue associated with the record. Returns: The issue associated with the record. """ - return cast(SubIssue, self._issue) + if not isinstance(self._issue, SubIssue): + raise TypeError("SubIssueRecord.issue is expected to be a SubIssue") + return self._issue # properties - specific to IssueRecord diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index 9d9ad648..4ff0467a 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -65,8 +65,10 @@ def generate(self, data: MinedData) -> dict[int | str, Record]: def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: detected_issues = extract_issue_numbers_from_body(pull) logger.debug("Detected issues - from body: %s", detected_issues) - detected_issues.update(self._safe_call(get_issues_for_pr)(pull_number=pull.number)) - logger.debug("Detected issues - final: %s", detected_issues) + linked = self._safe_call(get_issues_for_pr)(pull_number=pull.number) + if linked: + detected_issues.update(linked) + logger.debug("Detected issues - merged: %s", detected_issues) for parent_issue_number in detected_issues: # create an issue record if not present for PR parent @@ -103,9 +105,9 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: pull_labels = [label.name for label in pull.get_labels()] skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) - if not self._safe_call(get_issues_for_pr)(pull_number=pull.number) and not extract_issue_numbers_from_body( - pull - ): + linked_from_api = self._safe_call(get_issues_for_pr)(pull_number=pull.number) or set() + linked_from_body = extract_issue_numbers_from_body(pull) + if not linked_from_api and not linked_from_body: self._records[pull.number] = PullRequestRecord(pull, skip=skip_record) logger.debug("Created record for PR %d: %s", pull.number, pull.title) else: diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index b09d18a2..2376666c 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -108,9 +108,10 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined related_commits = [c for c in data.commits if c.sha == pull.merge_commit_sha] self.__registered_commits.extend(c.sha for c in related_commits) - pull_issues_set: set[int] = self._safe_call(get_issues_for_pr)(pull_number=pull.number) - pull_issues_set.update(extract_issue_numbers_from_body(pull)) - pull_issues: list[int] = list(pull_issues_set) + linked_from_api = self._safe_call(get_issues_for_pr)(pull_number=pull.number) or set() + linked_from_body = extract_issue_numbers_from_body(pull) + pull_issues: list[int] = list(linked_from_api.union(linked_from_body)) + attached_any = False if len(pull_issues) > 0: record_keys = self._records.keys() @@ -125,7 +126,7 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined parent_issue = self._safe_call(data.repository.get_issue)(issue_number) if data.repository else None if parent_issue is not None: self._create_issue_record_using_sub_issues_existence(parent_issue) - return + record_keys = self._records.keys() if issue_number in record_keys and isinstance( self._records[issue_number], (SubIssueRecord, HierarchyIssueRecord, IssueRecord) @@ -138,13 +139,15 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined rec.register_commit(pull, c) logger.debug("Registering commit %s to PR %d", c.sha, pull.number) - return + attached_any = True + + if not attached_any: + pr_rec = PullRequestRecord(pull, pull_labels, skip_record) + for c in related_commits: # register commits to the PR record + pr_rec.register_commit(c) + self._records[pull.number] = pr_rec + logger.debug("Created record for PR %d: %s", pull.number, pull.title) - pr_rec = PullRequestRecord(pull, pull_labels, skip_record) - for c in related_commits: # register commits to the PR record - pr_rec.register_commit(c) - self._records[pull.number] = pr_rec - logger.debug("Created record for PR %d: %s", pull.number, pull.title) def _create_issue_record_using_sub_issues_existence(self, issue: Issue) -> None: # use presence of sub-issues as a hint for hierarchy issue or non hierarchy issue diff --git a/release_notes_generator/utils/constants.py b/release_notes_generator/utils/constants.py index 26242b8e..48712d41 100644 --- a/release_notes_generator/utils/constants.py +++ b/release_notes_generator/utils/constants.py @@ -40,7 +40,7 @@ ROW_FORMAT_LINK_PR = "row-format-link-pr" SUPPORTED_ROW_FORMAT_KEYS_HIERARCHY_ISSUE = ["type", "number", "title"] SUPPORTED_ROW_FORMAT_KEYS_ISSUE = ["number", "title", "pull-requests"] -SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST = ["number", "title", "pull-requests"] +SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST = ["number", "title"] # Features WARNINGS = "warnings" diff --git a/tests/conftest.py b/tests/conftest.py index 8ca680b7..883983b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -648,7 +648,7 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( mock_pr_closed_3.body += "\nCloses #452" mock_commit_1 = copy.deepcopy(mock_commit) mock_commit_1.sha = "merge_commit_sha_151" - mock_commit_1.message = "Fixed bug in PR 151" + mock_commit_1.commit.message = "Fixed bug in PR 151" hi_two_sub_issues_with_prs_with_commit.get_sub_issues.return_value = [sub_issue_5, sub_issue_6] # single hierarchy issue record - one sub hierarchy issues - two sub-issues with PRs - with commits @@ -676,7 +676,7 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( mock_pr_closed_4.body += "\nCloses #453" mock_commit_2 = copy.deepcopy(mock_commit) mock_commit_2.sha = "merge_commit_sha_152" - mock_commit_2.message = "Fixed bug in PR 152" + mock_commit_2.commit.message = "Fixed bug in PR 152" sub_hierarchy_issue.get_sub_issues.return_value = [sub_issue_7, sub_issue_8] hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit.get_sub_issues.return_value = [sub_hierarchy_issue] @@ -689,7 +689,7 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( # single direct commit record mock_commit_3 = copy.deepcopy(mock_commit) mock_commit_3.sha = "merge_commit_sha_direct" - mock_commit_3.message = "Direct commit example" + mock_commit_3.commit.message = "Direct commit example" data.issues = [solo_closed_issue, hi_two_sub_issues_no_prs, diff --git a/tests/release_notes/builder/test_release_notes_builder.py b/tests/release_notes/builder/test_release_notes_builder.py index 47a96d47..abbedf77 100644 --- a/tests/release_notes/builder/test_release_notes_builder.py +++ b/tests/release_notes/builder/test_release_notes_builder.py @@ -208,7 +208,7 @@ def __init__(self, name): * Awesome architecture ### Direct commits ⚠️ -- Commit: merge_c... - Fixed bug +- Commit: merge_c... - Direct commit example ### Others - No Topic ⚠️ Previous filters caught all Issues or Pull Requests. @@ -305,7 +305,7 @@ def __init__(self, name): * Awesome architecture ### Direct commits ⚠️ -- Commit: merge_c... - Fixed bug +- Commit: merge_c... - Direct commit example ### Others - No Topic ⚠️ Previous filters caught all Issues or Pull Requests. @@ -398,7 +398,7 @@ def __init__(self, name): * Awesome architecture ### Direct commits ⚠️ -- Commit: merge_c... - Fixed bug +- Commit: merge_c... - Direct commit example ### Others - No Topic ⚠️ Previous filters caught all Issues or Pull Requests. @@ -494,7 +494,7 @@ def __init__(self, name): * Awesome architecture ### Direct commits ⚠️ -- Commit: merge_c... - Fixed bug +- Commit: merge_c... - Direct commit example ### Others - No Topic ⚠️ Previous filters caught all Issues or Pull Requests. @@ -550,7 +550,7 @@ def __init__(self, name): All merged PRs are linked to Closed issues. ### Direct commits ⚠️ -- Commit: merge_c... - Fixed bug +- Commit: merge_c... - Direct commit example ### Others - No Topic ⚠️ Previous filters caught all Issues or Pull Requests. @@ -607,7 +607,7 @@ def __init__(self, name): All merged PRs are linked to Closed issues. ### Direct commits ⚠️ -- Commit: merge_c... - Fixed bug +- Commit: merge_c... - Direct commit example ### Others - No Topic ⚠️ Previous filters caught all Issues or Pull Requests. @@ -661,7 +661,7 @@ def __init__(self, name): All merged PRs are linked to Closed issues. ### Direct commits ⚠️ -- Commit: merge_c... - Fixed bug +- Commit: merge_c... - Direct commit example ### Others - No Topic ⚠️ Previous filters caught all Issues or Pull Requests. @@ -718,7 +718,7 @@ def __init__(self, name): All merged PRs are linked to Closed issues. ### Direct commits ⚠️ -- Commit: merge_c... - Fixed bug +- Commit: merge_c... - Direct commit example ### Others - No Topic ⚠️ Previous filters caught all Issues or Pull Requests. @@ -1750,7 +1750,6 @@ def test_build_no_hierarchy_rls_notes_with_labels_no_type_with_hierarchy_data( mocker.patch("release_notes_generator.record.factory.default_record_factory.safe_call_decorator", side_effect=mock_safe_call_decorator) mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_print_empty_chapters", return_value=True) mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_hierarchy", return_value=False) - # mocker.patch("release_notes_generator.builder.builder.ActionInputs.get_row_format_hierarchy_issue", return_value="{type}: _{title}_ {number}") mock_github_client = mocker.Mock(spec=Github) diff --git a/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py b/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py index 082c8543..b3447239 100644 --- a/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py +++ b/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py @@ -92,7 +92,7 @@ def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_ assert 0 == len(rec_hi_3.sub_hierarchy_issues.values()) assert 2 == len(rec_hi_3.sub_issues.values()) assert 1 == rec_hi_3.sub_issues[452].pull_requests_count() - assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").message + assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").commit.message assert 0 == rec_hi_3.level rec_hi_4 = cast(HierarchyIssueRecord, result[304]) @@ -100,7 +100,7 @@ def test_generate_isolated_record_types_no_labels_no_type_defined(mocker, mined_ assert 1 == len(rec_hi_4.sub_hierarchy_issues.values()) assert 0 == len(rec_hi_4.sub_issues.values()) assert 1 == rec_hi_4.pull_requests_count() - assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").message + assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").commit.message assert 0 == rec_hi_4.level rec_hi_5 = cast(HierarchyIssueRecord, result[304]) @@ -159,14 +159,14 @@ def test_generate_isolated_record_types_with_labels_no_type_defined(mocker, mine assert 0 == len(rec_hi_3.sub_hierarchy_issues.values()) assert 2 == len(rec_hi_3.sub_issues.values()) assert 1 == rec_hi_3.sub_issues[452].pull_requests_count() - assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").message + assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").commit.message rec_hi_4 = cast(HierarchyIssueRecord, result[304]) assert 1 == rec_hi_4.pull_requests_count() assert 1 == len(rec_hi_4.sub_hierarchy_issues.values()) assert 0 == len(rec_hi_4.sub_issues.values()) assert 1 == rec_hi_4.pull_requests_count() - assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").message + assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").commit.message # - single issue record (closed) @@ -221,14 +221,14 @@ def test_generate_isolated_record_types_no_labels_with_type_defined(mocker, mine assert 0 == len(rec_hi_3.sub_hierarchy_issues.values()) assert 2 == len(rec_hi_3.sub_issues.values()) assert 1 == rec_hi_3.sub_issues[452].pull_requests_count() - assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").message + assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").commit.message rec_hi_4 = cast(HierarchyIssueRecord, result[304]) assert 1 == rec_hi_4.pull_requests_count() assert 1 == len(rec_hi_4.sub_hierarchy_issues.values()) assert 0 == len(rec_hi_4.sub_issues.values()) assert 1 == rec_hi_4.pull_requests_count() - assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").message + assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").commit.message # - single issue record (closed) @@ -283,11 +283,11 @@ def test_generate_isolated_record_types_with_labels_with_type_defined(mocker, mi assert 0 == len(rec_hi_3.sub_hierarchy_issues.values()) assert 2 == len(rec_hi_3.sub_issues.values()) assert 1 == rec_hi_3.sub_issues[452].pull_requests_count() - assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").message + assert "Fixed bug in PR 151" == rec_hi_3.sub_issues[452].get_commit(151, "merge_commit_sha_151").commit.message rec_hi_4 = cast(HierarchyIssueRecord, result[304]) assert 1 == rec_hi_4.pull_requests_count() assert 1 == len(rec_hi_4.sub_hierarchy_issues.values()) assert 0 == len(rec_hi_4.sub_issues.values()) assert 1 == rec_hi_4.pull_requests_count() - assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").message + assert "Fixed bug in PR 152" == rec_hi_4.sub_hierarchy_issues[350].sub_issues[453].get_commit(152, "merge_commit_sha_152").commit.message From eb5cdbcac951aa35b6806a9233df7829a0f1d775 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 10:09:00 +0200 Subject: [PATCH 24/33] Rabbit: Fixed several nitpicks. --- release_notes_generator/action_inputs.py | 21 ++++++++++++++----- .../chapters/base_chapters.py | 2 +- release_notes_generator/model/issue_record.py | 2 -- release_notes_generator/model/record.py | 2 +- .../factory/issue_hierarchy_record_factory.py | 1 - .../record/factory/record_factory.py | 2 +- .../chapters/test_base_chapters.py | 11 ++++++++++ .../chapters/test_custom_chapters.py | 3 ++- tests/test_action_inputs.py | 11 ++++++++++ ...st_utils.py => test_pull_request_utils.py} | 0 10 files changed, 43 insertions(+), 12 deletions(-) rename tests/utils/{test_pull_reuqest_utils.py => test_pull_request_utils.py} (100%) diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index 3a573848..a880e850 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -174,9 +174,7 @@ def get_hierarchy() -> bool: Check if the hierarchy release notes structure is enabled. """ val = get_action_input("hierarchy", "false") - if val is None: - return False - return val.lower() == "true" + return str(val).strip().lower() == "true" @staticmethod def get_duplicity_scope() -> DuplicityScopeEnum: @@ -313,7 +311,7 @@ def get_row_format_hierarchy_issue() -> str: if ActionInputs._row_format_hierarchy_issue is None: ActionInputs._row_format_hierarchy_issue = ActionInputs._detect_row_format_invalid_keywords( get_action_input( - ROW_FORMAT_HIERARCHY_ISSUE, "{type}: _{title}_ {number} " + ROW_FORMAT_HIERARCHY_ISSUE, "{type}: _{title}_ {number}" ).strip(), # type: ignore[union-attr] row_type=ActionInputs.ROW_TYPE_HIERARCHY_ISSUE, clean=True, @@ -439,7 +437,14 @@ def validate_inputs() -> None: if not isinstance(row_format_pr, str) or not row_format_pr.strip(): errors.append("PR Row format must be a non-empty string.") - ActionInputs._detect_row_format_invalid_keywords(row_format_pr, row_type="PR") + ActionInputs._detect_row_format_invalid_keywords(row_format_pr, row_type=ActionInputs.ROW_TYPE_PR) + + row_format_hier_issue = ActionInputs.get_row_format_hierarchy_issue() + if not isinstance(row_format_hier_issue, str) or not row_format_hier_issue.strip(): + errors.append("Hierarchy Issue row format must be a non-empty string.") + ActionInputs._detect_row_format_invalid_keywords( + row_format_hier_issue, row_type=ActionInputs.ROW_TYPE_HIERARCHY_ISSUE + ) row_format_link_pr = ActionInputs.get_row_format_link_pr() ActionInputs.validate_input(row_format_link_pr, bool, "'row-format-link-pr' value must be a boolean.", errors) @@ -488,6 +493,12 @@ def _detect_row_format_invalid_keywords(row_format: str, row_type: str = "Issue" supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST case ActionInputs.ROW_TYPE_HIERARCHY_ISSUE: supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_HIERARCHY_ISSUE + case _: + logger.warning( + "Unknown row_type '%s' in _detect_row_format_invalid_keywords; defaulting to Issue keys.", + row_type, + ) + supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_ISSUE invalid_keywords = [keyword for keyword in keywords_in_braces if keyword not in supported_row_format_keys] cleaned_row_format = row_format diff --git a/release_notes_generator/chapters/base_chapters.py b/release_notes_generator/chapters/base_chapters.py index 75174296..e9f861fc 100644 --- a/release_notes_generator/chapters/base_chapters.py +++ b/release_notes_generator/chapters/base_chapters.py @@ -88,7 +88,7 @@ def to_string(self) -> str: chapter_string = chapter.to_string( sort_ascending=self.sort_ascending, print_empty_chapters=self.print_empty_chapters ) - if len(chapter_string) > 0: + if chapter_string: result += chapter_string + "\n\n" # Note: strip is required to remove leading newline chars when empty chapters are not printed option diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py index c5b6910b..bf4515d8 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -14,8 +14,6 @@ from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.record import Record -logger = logging.getLogger(__name__) - class IssueRecord(Record): """ diff --git a/release_notes_generator/model/record.py b/release_notes_generator/model/record.py index bc325cf1..01b628f0 100644 --- a/release_notes_generator/model/record.py +++ b/release_notes_generator/model/record.py @@ -37,7 +37,7 @@ def __init__(self, labels: Optional[list[str]] = None, skip: bool = False): self._present_in_chapters = 0 self._skip = skip self._is_release_note_detected: Optional[bool] = None - self._labels: Optional[list[str]] = labels if labels is not None else None + self._labels: Optional[list[str]] = labels self._rls_notes: Optional[str] = None # single annotation here # properties diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 2376666c..8934360a 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -148,7 +148,6 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined self._records[pull.number] = pr_rec logger.debug("Created record for PR %d: %s", pull.number, pull.title) - def _create_issue_record_using_sub_issues_existence(self, issue: Issue) -> None: # use presence of sub-issues as a hint for hierarchy issue or non hierarchy issue sub_issues = list(issue.get_sub_issues()) diff --git a/release_notes_generator/record/factory/record_factory.py b/release_notes_generator/record/factory/record_factory.py index 698fe86f..62fdbd00 100644 --- a/release_notes_generator/record/factory/record_factory.py +++ b/release_notes_generator/record/factory/record_factory.py @@ -15,7 +15,7 @@ # """ -This module contains the DefaultRecordFactory class which is responsible for generating +This module contains the RecordFactory base class used to generate records. """ import abc import logging diff --git a/tests/release_notes/chapters/test_base_chapters.py b/tests/release_notes/chapters/test_base_chapters.py index d41755e1..99e44736 100644 --- a/tests/release_notes/chapters/test_base_chapters.py +++ b/tests/release_notes/chapters/test_base_chapters.py @@ -14,6 +14,8 @@ # limitations under the License. # +from datetime import datetime + from release_notes_generator.chapters.base_chapters import BaseChapters @@ -29,6 +31,15 @@ def populate(self, records: dict[int, Record]): pass # Implement a minimal populate method for testing +def test_since_default_and_setter(): + chapters = Chapters() + assert chapters.since == datetime.min + + now = datetime(2024, 1, 1, 0, 0, 0) + chapters.since = now + assert chapters.since == now + + def test_add_row(): chapters = Chapters() chapters.add_row("Chapter 1", 1, "Row 1") diff --git a/tests/release_notes/chapters/test_custom_chapters.py b/tests/release_notes/chapters/test_custom_chapters.py index 628de4f2..9f51e5bf 100644 --- a/tests/release_notes/chapters/test_custom_chapters.py +++ b/tests/release_notes/chapters/test_custom_chapters.py @@ -16,6 +16,7 @@ from release_notes_generator.model.chapter import Chapter from release_notes_generator.chapters.custom_chapters import CustomChapters +from release_notes_generator.model.issue_record import IssueRecord from release_notes_generator.model.record import Record from release_notes_generator.utils.enums import DuplicityScopeEnum @@ -71,7 +72,7 @@ def test_populate(custom_chapters, mocker): def test_populate_no_pulls_count(custom_chapters, mocker): - record1 = mocker.Mock(spec=Record) + record1 = mocker.Mock(spec=IssueRecord) record1.labels = ["bug"] record1.pulls_count = 0 record1.is_present_in_chapters = False diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index beae123f..88d213a3 100644 --- a/tests/test_action_inputs.py +++ b/tests/test_action_inputs.py @@ -56,6 +56,7 @@ ("get_release_notes_title", "", "Release Notes title must be a non-empty string and have non-zero length."), ("get_coderabbit_release_notes_title", "", "CodeRabbit Release Notes title must be a non-empty string and have non-zero length."), ("get_coderabbit_summary_ignore_groups", [""], "CodeRabbit summary ignore groups must be a non-empty string and have non-zero length."), + ("get_hierarchy", "not_bool", "Hierarchy must be a boolean."), ] @@ -264,6 +265,16 @@ def test_detect_row_format_invalid_keywords_with_invalid_keywords(caplog): assert actual_errors == expected_errors +def test_get_row_format_hierarchy_issue_cleans_invalid_keywords(mocker, caplog): + caplog.set_level(logging.ERROR) + mocker.patch( + "release_notes_generator.action_inputs.get_action_input", + return_value="{type}: _{title}_ {number} {invalid}", + ) + fmt = ActionInputs.get_row_format_hierarchy_issue() + assert "{invalid}" not in fmt + + def test_clean_row_format_invalid_keywords_no_keywords(): expected_row_format = "{number} _{title}_ in {pull-requests}" actual_format = ActionInputs._detect_row_format_invalid_keywords(expected_row_format, clean=True) diff --git a/tests/utils/test_pull_reuqest_utils.py b/tests/utils/test_pull_request_utils.py similarity index 100% rename from tests/utils/test_pull_reuqest_utils.py rename to tests/utils/test_pull_request_utils.py From 4248b9a24dfbd4e678cb11217068fea50abf1446 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 10:24:27 +0200 Subject: [PATCH 25/33] Rabbit: Fixed several nitpicks. --- README.md | 2 +- .../chapters/service_chapters.py | 9 ++++-- release_notes_generator/generator.py | 4 +-- release_notes_generator/model/issue_record.py | 2 +- .../utils/pull_request_utils.py | 14 ++++----- show_only.md | 31 ------------------- .../release_notes/model/test_commit_record.py | 9 +----- 7 files changed, 18 insertions(+), 53 deletions(-) delete mode 100644 show_only.md diff --git a/README.md b/README.md index d8244d03..5bbd4dd6 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Generate Release Notes action is dedicated to enhance the quality and organizati | `from-tag-name` | The name of the tag from which you want to generate release notes. | No | '' | | `chapters` | An YAML array defining chapters and corresponding labels for categorization. Each chapter should have a title and a label matching your GitHub issues and PRs. | Yes | | | `hierarchy` | Set to true to enable issue hierarchy handling. When enabled, the action will organize issues based on their hierarchical relationships (e.g., Epics and their child issues). This is useful for projects that use issue types to represent different levels of work. | No | false | -| `row-format-hierarchy-issue` | The format of the row for the hierarchy issue in the release notes. The format can contain placeholders for the issue `number` and `title`. The placeholders are case-sensitive. | No | `"{type}: _{title}_ {number}"` | +| `row-format-hierarchy-issue` | The format of the row for the hierarchy issue in the release notes. Placeholders: `type`, `number`, `title` (case-sensitive). | No | `"{type}: _{title}_ {number}"` | | `row-format-issue` | The format of the row for the issue in the release notes. The format can contain placeholders for the issue `number`, `title`, and issues `pull-requests`. The placeholders are case-sensitive. | No | `"{number} _{title}_ in {pull-requests}"` | | `row-format-pr` | The format of the row for the PR in the release notes. The format can contain placeholders for the PR `number`, and `title`. The placeholders are case-sensitive. | No | `"{number} _{title}_"` | | `row-format-link-pr` | If defined `true`, the PR row will begin with a `"PR: "` string. Otherwise, no prefix will be added. | No | true | diff --git a/release_notes_generator/chapters/service_chapters.py b/release_notes_generator/chapters/service_chapters.py index 1f1a1df3..edc82d80 100644 --- a/release_notes_generator/chapters/service_chapters.py +++ b/release_notes_generator/chapters/service_chapters.py @@ -144,9 +144,12 @@ def __populate_closed_issues(self, record: IssueRecord, record_id: int | str) -> """ Populates the service chapters with closed issues. - @param record: The Record object representing the closed issue. - @param nr: The number of the record. - @return: None + Parameters: + record: The record to populate. + record_id: The ID of the record. + + Returns: + None """ # check record properties if it fits to a chapter: CLOSED_ISSUES_WITHOUT_PULL_REQUESTS populated = False diff --git a/release_notes_generator/generator.py b/release_notes_generator/generator.py index 716cc5cd..7e1015e7 100644 --- a/release_notes_generator/generator.py +++ b/release_notes_generator/generator.py @@ -103,12 +103,12 @@ def generate(self) -> Optional[str]: changelog_url=changelog_url, ).build() - def _get_record_factory(self, github: Github) -> RecordFactory: + def _get_record_factory(self, github: Github) -> DefaultRecordFactory: """ Determines and returns the appropriate RecordFactory instance based on the action inputs. Returns: - RecordFactory: An instance of either IssueHierarchyRecordFactory or RecordFactory. + DefaultRecordFactory: An instance of either IssueHierarchyRecordFactory or RecordFactory. """ if ActionInputs.get_hierarchy(): logger.info("Using IssueHierarchyRecordFactory based on action inputs.") diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py index bf4515d8..470317ad 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -230,7 +230,7 @@ def get_pr_links(self) -> list[str]: Returns: list[str]: A list of pull request links associated with the issue. """ - if len(self._pull_requests.values()) == 0: + if not self._pull_requests: return [] template = "#{number}" diff --git a/release_notes_generator/utils/pull_request_utils.py b/release_notes_generator/utils/pull_request_utils.py index dc4ad068..a27784c2 100644 --- a/release_notes_generator/utils/pull_request_utils.py +++ b/release_notes_generator/utils/pull_request_utils.py @@ -32,8 +32,11 @@ def extract_issue_numbers_from_body(pr: PullRequest) -> set[int]: """ Extracts the numbers of the issues mentioned in the body of the pull request. - @param pr: The pull request to extract the issue numbers from. - @return: The numbers of the issues mentioned in the body of the pull request as a list of integers. + Parameters: + pr (PullRequest): The pull request to extract numbers from. + + Returns: + Set of issue numbers mentioned in the pull request body. """ # Regex pattern to match issue numbers following keywords like "Close", "Fix", "Resolve" regex_pattern = re.compile(r"([Cc]los(e|es|ed)|[Ff]ix(es|ed)?|[Rr]esolv(e|es|ed))\s*#\s*([0-9]+)") @@ -44,15 +47,12 @@ def extract_issue_numbers_from_body(pr: PullRequest) -> set[int]: # Extract the issue numbers from the matches issue_numbers = {int(match[-1]) for match in issue_matches} - if pr.number == 3645: - print(f"PR #{pr.number} - Extracted issue numbers from body: {issue_numbers}") - return issue_numbers -@lru_cache(maxsize=None) +@lru_cache(maxsize=1024) def get_issues_for_pr(pull_number: int) -> set[int]: - """Update the placeholder values and formate the graphQL query""" + """Update the placeholder values and format the graphQL query""" github_api_url = "https://api.github.com/graphql" query = ISSUES_FOR_PRS.format( number=pull_number, diff --git a/show_only.md b/show_only.md deleted file mode 100644 index 39a562e9..00000000 --- a/show_only.md +++ /dev/null @@ -1,31 +0,0 @@ -### Merged PRs Linked to 'Not Closed' Issue ⚠️ -- - None: _HI302 open_ #302 -- _Release Notes_: - - Hierarchy level release note -- #451 _SI451 open_ in #150 - - Hierarchy level release note - - Fixed bug - - Improved performance - + More nice code - * Awesome architecture -- - None: _HI303 open_ #303 -- _Release Notes_: - - Hierarchy level release note -- #452 _SI452 open_ in #151 - - Hierarchy level release note - - Fixed bug - - Improved performance - + More nice code - * Awesome architecture -- - None: _HI304 open_ #304 -- _Release Notes_: - - Hierarchy level release note -- None: _HI350 open_ #350 - - _Release Notes_: - - Sub-hierarchy level release note - - #453 _SI453 open_ in #152 - - Hierarchy level release note - - Fixed bug - - Improved performance - + More nice code - * Awesome architecture diff --git a/tests/release_notes/model/test_commit_record.py b/tests/release_notes/model/test_commit_record.py index d960f2ca..87b13568 100644 --- a/tests/release_notes/model/test_commit_record.py +++ b/tests/release_notes/model/test_commit_record.py @@ -1,14 +1,7 @@ -import types - from release_notes_generator.model.commit_record import CommitRecord from tests.conftest import mock_commit -class DummyInnerCommit: - def __init__(self, sha: str, message: str): - self.sha = sha - self.message = message - def test_basic_properties(mock_commit): rec = CommitRecord(mock_commit) assert rec.record_id == "merge_commit_sha" @@ -53,7 +46,7 @@ def test_to_chapter_row_with_release_notes_injected(monkeypatch, mock_commit): monkeypatch.setattr( CommitRecord, "contains_release_notes", - lambda self: True, + lambda _: True, ) rec = CommitRecord(mock_commit) monkeypatch.setattr(rec, "get_rls_notes", lambda line_marks=None: "Extra release notes.") From 51d2c15a21a38e6547c8d849b5dd81eab549eee0 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 13:03:19 +0200 Subject: [PATCH 26/33] Rabbit: Fixed several nitpicks. --- action.yml | 8 +-- release_notes_generator/filter.py | 4 +- .../model/hierarchy_issue_record.py | 4 +- .../record/factory/default_record_factory.py | 3 +- .../factory/issue_hierarchy_record_factory.py | 2 +- tests/conftest.py | 63 +++++++------------ .../builder/test_release_notes_builder.py | 11 +--- 7 files changed, 32 insertions(+), 63 deletions(-) diff --git a/action.yml b/action.yml index 8e3d3545..acf084ac 100644 --- a/action.yml +++ b/action.yml @@ -14,7 +14,7 @@ # limitations under the License. # -name: 'Release notes scrapper' +name: 'Release notes scraper' description: 'Automatically generates release notes for a given release tag, categorized into chapters based on labels.' inputs: tag-name: @@ -77,7 +77,7 @@ inputs: required: false default: '' row-format-hierarchy-issue: - description: 'Format of the hierarchy issue in the release notes. Available placeholders: {number}, {title}. Placeholders are case-insensitive.' + description: 'Format of the hierarchy issue in the release notes. Available placeholders: {number}, {title}, {type}. Placeholders are case-insensitive.' required: false default: '{type}: _{title}_ {number}' row-format-issue: @@ -85,7 +85,7 @@ inputs: required: false default: '{number} _{title}_ in {pull-requests}' row-format-pr: - description: 'Format of the pr row in the release notes. Available placeholders: {number}, {title}, {pull-requests}. Placeholders are case-insensitive.' + description: 'Format of the pr row in the release notes. Available placeholders: {number}, {title}. Placeholders are case-insensitive.' required: false default: '{number} _{title}_' row-format-link-pr: @@ -131,7 +131,7 @@ runs: - name: Set PROJECT_ROOT and update PYTHONPATH run: | ACTION_ROOT="${{ github.action_path }}" - export PYTHONPATH="${PYTHONPATH}:${ACTION_ROOT}/release_notes_generator" + echo PYTHONPATH="${PYTHONPATH}:${ACTION_ROOT}/release_notes_generator" >> $GITHUB_ENV shell: bash - name: Call Release Notes Generator diff --git a/release_notes_generator/filter.py b/release_notes_generator/filter.py index 33ddf3c0..7afe69fd 100644 --- a/release_notes_generator/filter.py +++ b/release_notes_generator/filter.py @@ -138,8 +138,8 @@ def _filter_issues_default(self, data: MinedData) -> list: def _filter_issues_issue_hierarchy(self, data: MinedData) -> list: """ - Hierarchy filtering for issues: filter out closed issues before the release date, - but always include issues of certain types (e.g., "Epic", "Story"). + Hierarchy filtering for issues: include issues closed since the release date + or still open at generation time. Parameters: data (MinedData): The mined data containing issues and release information. diff --git a/release_notes_generator/model/hierarchy_issue_record.py b/release_notes_generator/model/hierarchy_issue_record.py index 011f639d..b59fcc2f 100644 --- a/release_notes_generator/model/hierarchy_issue_record.py +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -1,5 +1,5 @@ """ -A module that defines the IssueRecord class, which represents an issue record in the release notes. +A module that defines the HierarchyIssueRecord class for hierarchical issue rendering. """ import logging @@ -24,7 +24,7 @@ def __init__(self, issue: Issue, issue_labels: Optional[list[str]] = None, skip: self._level: int = 0 self._sub_issues: dict[int, SubIssueRecord] = {} - self._sub_hierarchy_issues: dict[int, HierarchyIssueRecord] = {} + self._sub_hierarchy_issues: dict[int, "HierarchyIssueRecord"] = {} @property def level(self) -> int: diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index 4ff0467a..bd4478d3 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -15,7 +15,7 @@ # """ -This module contains the DefaultRecordFactory class which is responsible for generating +DefaultRecordFactory builds Record objects (issues, pulls, commits) from mined GitHub data. """ import logging @@ -157,7 +157,6 @@ def _create_record_for_issue(self, issue: Issue, issue_labels: Optional[list[str Create a record for an issue. Parameters: - records (dict[int|str, Record]): The dictionary of records to add the issue record to. issue (Issue): The issue to create a record for. issue_labels (Optional[list[str]]): Optional set of labels for the issue. If not provided, labels will be fetched from the issue. diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 8934360a..12f2d84b 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -15,7 +15,7 @@ # """ -This module contains the DefaultRecordFactory class which is responsible for generating +IssueHierarchyRecordFactory builds hierarchical issue records (Epics/Features/Tasks) and associates PRs/commits. """ import logging diff --git a/tests/conftest.py b/tests/conftest.py index 883983b1..f93c7a1a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -230,7 +230,7 @@ def mock_open_sub_issue(mocker): issue.number = 400 issue.title = "SI400 open" issue.state_reason = None - issue.body = "I400 open/nRelease Notes:\n- Hierarchy level release note" + issue.body = "I400 open\nRelease Notes:\n- Hierarchy level release note" issue.type = None issue.created_at = datetime.now() @@ -247,7 +247,7 @@ def mock_closed_sub_issue(mocker): issue.number = 450 issue.title = "SI450 closed" issue.state_reason = None - issue.body = "I450 closed/nRelease Notes:\n- Hierarchy level release note" + issue.body = "I450 closed\nRelease Notes:\n- Hierarchy level release note" issue.type = None issue.created_at = datetime.now() @@ -264,7 +264,7 @@ def mock_open_hierarchy_issue(mocker): issue.number = 300 issue.title = "HI300 open" issue.state_reason = None - issue.body = "I300 open/nRelease Notes:\n- Hierarchy level release note" + issue.body = "I300 open\nRelease Notes:\n- Hierarchy level release note" issue.type = None issue.created_at = datetime.now() @@ -283,7 +283,7 @@ def mock_open_hierarchy_issue_epic(mocker): issue.number = 200 issue.title = "HI200 open" issue.state_reason = None - issue.body = "I200 open/nRelease Notes:\n- Epic level release note" + issue.body = "I200 open\nRelease Notes:\n- Epic level release note" issue.type = issue_type issue.created_at = datetime.now() @@ -306,7 +306,7 @@ def mock_open_hierarchy_issue_feature(mocker): issue.number = 201 issue.title = "HI201 open" issue.state_reason = None - issue.body = "HI201 open/nRelease Notes:\n- Feature level release note" + issue.body = "HI201 open\nRelease Notes:\n- Feature level release note" issue.type = issue_type issue.created_at = datetime.now() @@ -601,7 +601,7 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( hi_two_sub_issues_no_prs = copy.deepcopy(mock_open_hierarchy_issue) hi_two_sub_issues_no_prs.number = 301 hi_two_sub_issues_no_prs.title = "HI301 open" - hi_two_sub_issues_no_prs.body = "I301 open/nRelease Notes:\n- Hierarchy level release note" + hi_two_sub_issues_no_prs.body = "I301 open\nRelease Notes:\n- Hierarchy level release note" sub_issue_1 = copy.deepcopy(mock_open_sub_issue) sub_issue_2 = copy.deepcopy(mock_closed_sub_issue) hi_two_sub_issues_no_prs.get_sub_issues.return_value = [sub_issue_1, sub_issue_2] @@ -610,15 +610,15 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( hi_two_sub_issues_with_prs = copy.deepcopy(mock_open_hierarchy_issue) hi_two_sub_issues_with_prs.number = 302 hi_two_sub_issues_with_prs.title = "HI302 open" - hi_two_sub_issues_with_prs.body = "I302 open/nRelease Notes:\n- Hierarchy level release note" + hi_two_sub_issues_with_prs.body = "I302 open\nRelease Notes:\n- Hierarchy level release note" sub_issue_3 = copy.deepcopy(mock_open_sub_issue) sub_issue_3.number = 401 sub_issue_3.title = "SI401 open" - sub_issue_3.body = "I401 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_3.body = "I401 open\nRelease Notes:\n- Hierarchy level release note" sub_issue_4 = copy.deepcopy(mock_closed_sub_issue) sub_issue_4.number = 451 sub_issue_4.title = "SI451 closed" - sub_issue_4.body = "I451 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_4.body = "I451 open\nRelease Notes:\n- Hierarchy level release note" mock_pr_closed_2 = copy.deepcopy(mock_pull_closed) mock_pr_closed_2.url = "http://example.com/pull/150" mock_pr_closed_2.number = 150 @@ -631,15 +631,15 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( hi_two_sub_issues_with_prs_with_commit = copy.deepcopy(mock_open_hierarchy_issue) hi_two_sub_issues_with_prs_with_commit.number = 303 hi_two_sub_issues_with_prs_with_commit.title = "HI303 open" - hi_two_sub_issues_with_prs_with_commit.body = "I303 open/nRelease Notes:\n- Hierarchy level release note" + hi_two_sub_issues_with_prs_with_commit.body = "I303 open\nRelease Notes:\n- Hierarchy level release note" sub_issue_5 = copy.deepcopy(mock_open_sub_issue) sub_issue_5.number = 402 sub_issue_5.title = "SI402 open" - sub_issue_5.body = "I402 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_5.body = "I402 open\nRelease Notes:\n- Hierarchy level release note" sub_issue_6 = copy.deepcopy(mock_closed_sub_issue) sub_issue_6.number = 452 sub_issue_6.title = "SI452 closed" - sub_issue_6.body = "I452 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_6.body = "I452 open\nRelease Notes:\n- Hierarchy level release note" mock_pr_closed_3 = copy.deepcopy(mock_pull_closed) mock_pr_closed_3.url = "http://example.com/pull/151" mock_pr_closed_3.number = 151 @@ -655,19 +655,19 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit = copy.deepcopy(mock_open_hierarchy_issue) hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit.number = 304 hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit.title = "HI304 open" - hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit.body = "I304 open/nRelease Notes:\n- Hierarchy level release note" + hi_one_sub_hierarchy_two_sub_issues_with_prs_with_commit.body = "I304 open\nRelease Notes:\n- Hierarchy level release note" sub_hierarchy_issue = copy.deepcopy(mock_open_hierarchy_issue) sub_hierarchy_issue.number = 350 sub_hierarchy_issue.title = "HI350 open" - sub_hierarchy_issue.body = "I350 open/nRelease Notes:\n- Sub-hierarchy level release note" + sub_hierarchy_issue.body = "I350 open\nRelease Notes:\n- Sub-hierarchy level release note" sub_issue_7 = copy.deepcopy(mock_open_sub_issue) sub_issue_7.number = 403 sub_issue_7.title = "SI403 open" - sub_issue_7.body = "I403 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_7.body = "I403 open\nRelease Notes:\n- Hierarchy level release note" sub_issue_8 = copy.deepcopy(mock_closed_sub_issue) sub_issue_8.number = 453 sub_issue_8.title = "SI453 closed" - sub_issue_8.body = "I453 open/nRelease Notes:\n- Hierarchy level release note" + sub_issue_8.body = "I453 open\nRelease Notes:\n- Hierarchy level release note" mock_pr_closed_4 = copy.deepcopy(mock_pull_closed) mock_pr_closed_4.url = "http://example.com/pull/152" mock_pr_closed_4.number = 152 @@ -711,14 +711,6 @@ def mined_data_isolated_record_types_no_labels_no_type_defined( def mined_data_isolated_record_types_with_labels_no_type_defined(mocker, mined_data_isolated_record_types_no_labels_no_type_defined): data = mined_data_isolated_record_types_no_labels_no_type_defined - # add labels to issues - # - Epic to root one - # - Feature to level 1 hierarchy issue - # - Task to sub-issue - # - enhancement to solo closed issue - # - bug to one of the PRs - # - enhancement to one of the PRs - l_enh = mocker.Mock(spec=MockLabel) l_enh.name = "enhancement" l_epic = mocker.Mock(spec=MockLabel) @@ -758,14 +750,6 @@ def mined_data_isolated_record_types_with_labels_no_type_defined(mocker, mined_d def mined_data_isolated_record_types_no_labels_with_type_defined(mocker, mined_data_isolated_record_types_no_labels_no_type_defined): data = mined_data_isolated_record_types_no_labels_no_type_defined - # add types to issues - # - Epic to root one - # - Feature to level 1 hierarchy issue - # - Task to sub-issue - # - enhancement to solo closed issue - # - bug to one of the PRs - # - enhancement to one of the PRs - t_epic = mocker.Mock(spec=IssueType) t_epic.name = "Epic" t_feature = mocker.Mock(spec=IssueType) @@ -821,14 +805,6 @@ def mined_data_isolated_record_types_no_labels_with_type_defined(mocker, mined_d def mined_data_isolated_record_types_with_labels_with_type_defined(mocker, mined_data_isolated_record_types_with_labels_no_type_defined): data = mined_data_isolated_record_types_with_labels_no_type_defined - # add types to issues - # - Epic to root one - # - Feature to level 1 hierarchy issue - # - Task to sub-issue - # - enhancement to solo closed issue - # - bug to one of the PRs - # - enhancement to one of the PRs - t_epic = mocker.Mock(spec=IssueType) t_epic.name = "Epic" t_feature = mocker.Mock(spec=IssueType) @@ -906,8 +882,11 @@ def record_with_issue_closed_two_pulls(request): @pytest.fixture def record_with_hierarchy_issues(request): - rec_epic_issue = HierarchyIssueRecord(issue=request.getfixturevalue("mock_open_hierarchy_issue_epic")) # nr:200 - rec_epic_issue._labels = ["epic"] # override labels to have epic label + rec_epic_issue = HierarchyIssueRecord( + issue=request.getfixturevalue("mock_open_hierarchy_issue_epic"), + issue_labels=["epic"] + ) # nr:200 + rec_feature_issue = HierarchyIssueRecord(request.getfixturevalue("mock_open_hierarchy_issue_feature")) # nr:201 rec_feature_issue.level = 1 rec_epic_issue.sub_hierarchy_issues[rec_feature_issue.issue.number] = rec_feature_issue diff --git a/tests/release_notes/builder/test_release_notes_builder.py b/tests/release_notes/builder/test_release_notes_builder.py index abbedf77..cc3e8157 100644 --- a/tests/release_notes/builder/test_release_notes_builder.py +++ b/tests/release_notes/builder/test_release_notes_builder.py @@ -21,7 +21,7 @@ from release_notes_generator.chapters.custom_chapters import CustomChapters from release_notes_generator.record.factory.default_record_factory import DefaultRecordFactory from release_notes_generator.record.factory.issue_hierarchy_record_factory import IssueHierarchyRecordFactory -from tests.conftest import mock_safe_call_decorator +from tests.conftest import mock_safe_call_decorator, MockLabel # pylint: disable=pointless-string-statement """ @@ -75,9 +75,6 @@ """ # pylint: disable=too-few-public-methods -class MockLabel: - def __init__(self, name): - self.name = name DEFAULT_CHANGELOG_URL = "http://example.com/changelog" default_chapters = [ @@ -1297,8 +1294,6 @@ def test_build_closed_issue(custom_chapters_not_print_empty_chapters, record_wit actual_release_notes = builder.build() - print("Actual:\n%s", actual_release_notes) - print("Expected:\n%s", expected_release_notes) assert expected_release_notes == actual_release_notes @@ -1831,8 +1826,4 @@ def test_build_no_hierarchy_rls_notes_with_labels_with_type_with_hierarchy_data( actual_release_notes = builder.build() - print("XXX") - print(actual_release_notes) - print("XXX") - assert expected_release_notes == actual_release_notes From bdd10ed03230c2b24fb3acd0bdb49a436c40af6f Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 13:38:11 +0200 Subject: [PATCH 27/33] Fixed pytest warning. Fixed issues from linters. --- .pylintrc | 2 +- release_notes_generator/generator.py | 1 - release_notes_generator/model/issue_record.py | 4 +--- release_notes_generator/model/sub_issue_record.py | 4 +--- tests/test_release_notes_generator.py | 13 ++++++++++++- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.pylintrc b/.pylintrc index 7096ec78..693e6f9e 100644 --- a/.pylintrc +++ b/.pylintrc @@ -307,7 +307,7 @@ max-bool-expr=5 max-branches=13 # Maximum number of locals for function / method body. -max-locals=15 +max-locals=20 # Maximum number of parents for a class (see R0901). max-parents=7 diff --git a/release_notes_generator/generator.py b/release_notes_generator/generator.py index 7e1015e7..25f3a7ad 100644 --- a/release_notes_generator/generator.py +++ b/release_notes_generator/generator.py @@ -33,7 +33,6 @@ from release_notes_generator.model.record import Record from release_notes_generator.record.factory.default_record_factory import DefaultRecordFactory from release_notes_generator.record.factory.issue_hierarchy_record_factory import IssueHierarchyRecordFactory -from release_notes_generator.record.factory.record_factory import RecordFactory from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter from release_notes_generator.utils.utils import get_change_url diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py index 470317ad..375028d0 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -2,13 +2,11 @@ A module that defines the IssueRecord class, which represents an issue record in the release notes. """ -import logging import re from typing import Optional, Any from github.Commit import Commit from github.Issue import Issue -from github.Label import Label from github.PullRequest import PullRequest from release_notes_generator.action_inputs import ActionInputs @@ -29,7 +27,7 @@ def __init__(self, issue: Issue, issue_labels: Optional[list[str]] = None, skip: super().__init__(skip=skip) self._issue: Issue = issue - self._labels: Optional[list[Label]] = issue_labels if issue_labels is not None else None + self._labels: Optional[list[str]] = issue_labels if issue_labels is not None else None if issue is not None and issue.type is not None: self._issue_type = issue.type.name diff --git a/release_notes_generator/model/sub_issue_record.py b/release_notes_generator/model/sub_issue_record.py index ebed0aa8..4b21d2fe 100644 --- a/release_notes_generator/model/sub_issue_record.py +++ b/release_notes_generator/model/sub_issue_record.py @@ -3,11 +3,9 @@ """ import logging -from typing import Optional, cast +from typing import Optional -from github.Commit import Commit from github.Issue import SubIssue, Issue -from github.PullRequest import PullRequest from release_notes_generator.model.issue_record import IssueRecord diff --git a/tests/test_release_notes_generator.py b/tests/test_release_notes_generator.py index 65a39157..5b9ae70b 100644 --- a/tests/test_release_notes_generator.py +++ b/tests/test_release_notes_generator.py @@ -58,6 +58,8 @@ def test_generate_release_notes_latest_release_not_found( mock_repo.get_issues.return_value = [mock_issue_closed, mock_issue_closed_i1_bug] mock_repo.get_pulls.return_value = [mock_pull_closed_with_rls_notes_101, mock_pull_closed_with_rls_notes_102] mock_repo.get_commits.return_value = [mock_commit] + mock_repo.get_release.return_value = None + mock_repo.get_releases.return_value = [] mock_issue_closed.created_at = mock_repo.created_at + timedelta(days=2) mock_issue_closed_i1_bug.created_at = mock_repo.created_at + timedelta(days=7) @@ -65,11 +67,12 @@ def test_generate_release_notes_latest_release_not_found( mock_pull_closed_with_rls_notes_101.merged_at = mock_repo.created_at + timedelta(days=2) mock_pull_closed_with_rls_notes_102.merged_at = mock_repo.created_at + timedelta(days=7) - mocker.patch("release_notes_generator.miner.DataMiner.get_latest_release", return_value=None) mock_rate_limit = mocker.Mock() mock_rate_limit.rate.remaining = 1000 github_mock.get_rate_limit.return_value = mock_rate_limit + mocker.patch("release_notes_generator.record.factory.default_record_factory.get_issues_for_pr", return_value=None) + custom_chapters = CustomChapters(print_empty_chapters=True) release_notes = ReleaseNotesGenerator(github_mock, custom_chapters).generate() @@ -99,6 +102,8 @@ def test_generate_release_notes_latest_release_found_by_created_at( mock_repo.get_pulls.return_value = [mock_pull_closed_with_rls_notes_101, mock_pull_closed_with_rls_notes_102] mock_repo.get_commits.return_value = [mock_commit] mock_commit.commit.author.date = mock_repo.created_at + timedelta(days=1) + mock_repo.get_release.return_value = None + mock_repo.get_releases.return_value = [] mock_issue_closed_i1_bug.created_at = mock_repo.created_at + timedelta(days=7) mock_issue_closed_i1_bug.closed_at = mock_repo.created_at + timedelta(days=6) @@ -120,6 +125,8 @@ def test_generate_release_notes_latest_release_found_by_created_at( "{number} _{title}_ in {pull-requests} {unknown} {another-unknown}" if first_arg == ROW_FORMAT_ISSUE else None ) + mocker.patch("release_notes_generator.record.factory.default_record_factory.get_issues_for_pr", return_value=None) + custom_chapters = CustomChapters(print_empty_chapters=True) release_notes = ReleaseNotesGenerator(github_mock, custom_chapters).generate() @@ -149,6 +156,8 @@ def test_generate_release_notes_latest_release_found_by_published_at( mock_repo.get_pulls.return_value = [mock_pull_closed_with_rls_notes_101, mock_pull_closed_with_rls_notes_102] mock_repo.get_commits.return_value = [mock_commit] mock_commit.commit.author.date = mock_repo.created_at + timedelta(days=1) + mock_repo.get_release.return_value = None + mock_repo.get_releases.return_value = [] mock_issue_closed_i1_bug.created_at = mock_repo.created_at + timedelta(days=7) mock_issue_closed_i1_bug.closed_at = mock_repo.created_at + timedelta(days=8) @@ -166,6 +175,8 @@ def test_generate_release_notes_latest_release_found_by_published_at( mock_rate_limit.rate.remaining = 1000 github_mock.get_rate_limit.return_value = mock_rate_limit + mocker.patch("release_notes_generator.record.factory.default_record_factory.get_issues_for_pr", return_value=None) + custom_chapters = CustomChapters(print_empty_chapters=True) release_notes = ReleaseNotesGenerator(github_mock, custom_chapters).generate() From 720ef35ca86f6c2a0ee1648a9889c9e1be61fc87 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 14:22:40 +0200 Subject: [PATCH 28/33] Fixed Rabbit review notes. --- release_notes_generator/model/pull_request_record.py | 3 ++- release_notes_generator/model/record.py | 8 +------- .../record/factory/issue_hierarchy_record_factory.py | 10 +++++++--- release_notes_generator/utils/pull_request_utils.py | 8 ++++---- .../builder/test_release_notes_builder.py | 4 ++++ tests/release_notes/model/test_record.py | 2 +- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/release_notes_generator/model/pull_request_record.py b/release_notes_generator/model/pull_request_record.py index 02c6b667..3bf53f08 100644 --- a/release_notes_generator/model/pull_request_record.py +++ b/release_notes_generator/model/pull_request_record.py @@ -105,7 +105,8 @@ def contributors(self) -> list[str]: # methods - override Record methods def get_labels(self) -> list[str]: - return [label.name for label in self._pull_request.get_labels()] + self._labels = [label.name for label in list(self._pull_request.get_labels())] + return self.labels def to_chapter_row(self, add_into_chapters: bool = True) -> str: if add_into_chapters: diff --git a/release_notes_generator/model/record.py b/release_notes_generator/model/record.py index 01b628f0..22b435e2 100644 --- a/release_notes_generator/model/record.py +++ b/release_notes_generator/model/record.py @@ -172,13 +172,7 @@ def contain_all_labels(self, labels: list[str]) -> bool: Returns: bool: True if the record contains all of the specified labels, False otherwise. """ - if len(self.labels) != len(labels): - return False - - for lbl in self.labels: - if lbl not in labels: - return False - return True + return all(lbl in self.labels for lbl in labels) def contains_release_notes(self, re_check: bool = False) -> bool: """ diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 12f2d84b..a6d3bafe 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -214,6 +214,7 @@ def _create_record_for_sub_issue(self, issue: Issue, issue_labels: Optional[list def _re_register_hierarchy_issues(self): sub_issues_numbers = list(self.__sub_issue_parents.keys()) + made_progress = False for sub_issue_number in sub_issues_numbers: # remove issue(sub_issue_number) from current records and add it to parent # as sub-issue or sub-hierarchy-issue @@ -230,17 +231,20 @@ def _re_register_hierarchy_issues(self): parent_rec.sub_issues[sub_issue_number] = sub_rec # add to parent as SubIssueRecord self._records.pop(sub_issue_number) # remove from main records as it is sub-one self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one + made_progress = True elif isinstance(sub_rec, HierarchyIssueRecord): parent_rec.sub_hierarchy_issues[sub_issue_number] = ( sub_rec # add to parent as 'Sub' HierarchyIssueRecord ) self._records.pop(sub_issue_number) # remove from main records as it is sub-one self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one + made_progress = True else: - logger.error("Detected IssueRecord in position of SubIssueRecord - skipping it") - # Dev note: IssueRecord is expected to be stand-alone - not sub-issue + logger.error("Detected IssueRecord in position of SubIssueRecord - leaving as standalone and dropping mapping") + # Avoid infinite recursion by removing the unresolved mapping + self.__sub_issue_parents.pop(sub_issue_number, None) - if len(self.__sub_issue_parents.items()) > 0: + if self.__sub_issue_parents.items() and made_progress: self._re_register_hierarchy_issues() def order_hierarchy_levels(self, level: int = 0) -> None: diff --git a/release_notes_generator/utils/pull_request_utils.py b/release_notes_generator/utils/pull_request_utils.py index a27784c2..c9764f9e 100644 --- a/release_notes_generator/utils/pull_request_utils.py +++ b/release_notes_generator/utils/pull_request_utils.py @@ -67,12 +67,12 @@ def get_issues_for_pr(pull_number: int) -> set[int]: response = requests.post(github_api_url, json={"query": query}, headers=headers, verify=False, timeout=10) response.raise_for_status() # Raise an error for HTTP issues + data = response.json() + if "errors" in data and data["errors"]: + raise RuntimeError(f"GitHub GraphQL errors: {data['errors']}") numbers = { node["number"] - for node in response.json()["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"] + for node in data["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"] } - if pull_number == 3645: - print(f"PR #{pull_number} - Extracted issue numbers from GitHub API: {numbers}") - return numbers diff --git a/tests/release_notes/builder/test_release_notes_builder.py b/tests/release_notes/builder/test_release_notes_builder.py index cc3e8157..cbb47ec0 100644 --- a/tests/release_notes/builder/test_release_notes_builder.py +++ b/tests/release_notes/builder/test_release_notes_builder.py @@ -1643,6 +1643,10 @@ def test_build_hierarchy_rls_notes_with_labels_no_type( actual_release_notes = builder.build() + print("XXX") + print(actual_release_notes) + print("YYY") + assert expected_release_notes == actual_release_notes diff --git a/tests/release_notes/model/test_record.py b/tests/release_notes/model/test_record.py index ddac8a3e..95dc0dd3 100644 --- a/tests/release_notes/model/test_record.py +++ b/tests/release_notes/model/test_record.py @@ -83,7 +83,7 @@ def test_contain_all_labels(): rec = DummyRecord(labels=["bug", "feature"]) assert rec.contain_all_labels(["bug", "feature"]) assert not rec.contain_all_labels(["bug", "other"]) - assert not rec.contain_all_labels(["bug"]) + assert rec.contain_all_labels(["bug"]) def test_contains_release_notes_true(): rec = DummyRecord(rls_notes="Some notes") From 3923e17e7a3dafcd4dd292c897f4a3d459115895 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 14:56:30 +0200 Subject: [PATCH 29/33] Fixed Rabbit review notes. --- action.yml | 2 +- release_notes_generator/action_inputs.py | 28 +++++++++---------- .../chapters/service_chapters.py | 9 ++++-- release_notes_generator/model/record.py | 2 +- .../model/sub_issue_record.py | 6 ++-- .../record/factory/default_record_factory.py | 3 +- .../factory/issue_hierarchy_record_factory.py | 4 +-- .../utils/pull_request_utils.py | 2 +- tests/conftest.py | 2 ++ .../release_notes/model/test_commit_record.py | 3 +- 10 files changed, 31 insertions(+), 30 deletions(-) diff --git a/action.yml b/action.yml index acf084ac..0eea3e9e 100644 --- a/action.yml +++ b/action.yml @@ -128,7 +128,7 @@ runs: pip install -r ${{ github.action_path }}/requirements.txt shell: bash - - name: Set PROJECT_ROOT and update PYTHONPATH + - name: Update PYTHONPATH run: | ACTION_ROOT="${{ github.action_path }}" echo PYTHONPATH="${PYTHONPATH}:${ACTION_ROOT}/release_notes_generator" >> $GITHUB_ENV diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index a880e850..739a3d1f 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -174,7 +174,7 @@ def get_hierarchy() -> bool: Check if the hierarchy release notes structure is enabled. """ val = get_action_input("hierarchy", "false") - return str(val).strip().lower() == "true" + return str(val).strip().lower() in ("true", "1", "yes", "y", "on") @staticmethod def get_duplicity_scope() -> DuplicityScopeEnum: @@ -485,20 +485,18 @@ def _detect_row_format_invalid_keywords(row_format: str, row_type: str = "Issue" """ keywords_in_braces = re.findall(r"\{(.*?)\}", row_format) - supported_row_format_keys = [] - match row_type: - case ActionInputs.ROW_TYPE_ISSUE: - supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_ISSUE - case ActionInputs.ROW_TYPE_PR: - supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST - case ActionInputs.ROW_TYPE_HIERARCHY_ISSUE: - supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_HIERARCHY_ISSUE - case _: - logger.warning( - "Unknown row_type '%s' in _detect_row_format_invalid_keywords; defaulting to Issue keys.", - row_type, - ) - supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_ISSUE + mapping = { + ActionInputs.ROW_TYPE_ISSUE: SUPPORTED_ROW_FORMAT_KEYS_ISSUE, + ActionInputs.ROW_TYPE_PR: SUPPORTED_ROW_FORMAT_KEYS_PULL_REQUEST, + ActionInputs.ROW_TYPE_HIERARCHY_ISSUE: SUPPORTED_ROW_FORMAT_KEYS_HIERARCHY_ISSUE, + } + supported_row_format_keys = mapping.get(row_type) + if supported_row_format_keys is None: + logger.warning( + "Unknown row_type '%s' in _detect_row_format_invalid_keywords; defaulting to Issue keys.", + row_type, + ) + supported_row_format_keys = SUPPORTED_ROW_FORMAT_KEYS_ISSUE invalid_keywords = [keyword for keyword in keywords_in_braces if keyword not in supported_row_format_keys] cleaned_row_format = row_format diff --git a/release_notes_generator/chapters/service_chapters.py b/release_notes_generator/chapters/service_chapters.py index edc82d80..78d236b6 100644 --- a/release_notes_generator/chapters/service_chapters.py +++ b/release_notes_generator/chapters/service_chapters.py @@ -188,9 +188,12 @@ def __populate_pr(self, record: PullRequestRecord, record_id: int | str) -> None """ Populates the service chapters with pull requests. - @param record: The Record object representing the pull request. - @param nr: The number of the record. - @return: None + Parameters: + record: The record to populate. + record_id: The ID of the record. + + Returns: + None """ if record.is_merged: # check record properties if it fits to a chapter: MERGED_PRS_WITHOUT_ISSUE diff --git a/release_notes_generator/model/record.py b/release_notes_generator/model/record.py index 22b435e2..28367bee 100644 --- a/release_notes_generator/model/record.py +++ b/release_notes_generator/model/record.py @@ -122,7 +122,7 @@ def get_labels(self) -> list[str]: """ Gets the labels of the record. Returns: - set[str]: A list of labels associated with the record. + list[str]: A list of labels associated with the record. """ @abstractmethod diff --git a/release_notes_generator/model/sub_issue_record.py b/release_notes_generator/model/sub_issue_record.py index 4b21d2fe..6f1d80b1 100644 --- a/release_notes_generator/model/sub_issue_record.py +++ b/release_notes_generator/model/sub_issue_record.py @@ -1,11 +1,11 @@ """ -A module that defines the IssueRecord class, which represents an issue record in the release notes. +A module for the SubIssueRecord class, which represents a sub-issue record in the release notes. """ import logging from typing import Optional -from github.Issue import SubIssue, Issue +from github.Issue import SubIssue from release_notes_generator.model.issue_record import IssueRecord @@ -18,7 +18,7 @@ class SubIssueRecord(IssueRecord): Inherits from Record and provides additional functionality specific to issues. """ - def __init__(self, sub_issue: SubIssue | Issue, issue_labels: Optional[list[str]] = None, skip: bool = False): + def __init__(self, sub_issue: SubIssue, issue_labels: Optional[list[str]] = None, skip: bool = False): super().__init__(sub_issue, issue_labels, skip) # properties - override IssueRecord properties diff --git a/release_notes_generator/record/factory/default_record_factory.py b/release_notes_generator/record/factory/default_record_factory.py index bd4478d3..a3420610 100644 --- a/release_notes_generator/record/factory/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -47,7 +47,6 @@ class DefaultRecordFactory(RecordFactory): """ def __init__(self, github: Github) -> None: - self._github: Github = github rate_limiter = GithubRateLimiter(github) self._safe_call = safe_call_decorator(rate_limiter) @@ -108,7 +107,7 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: linked_from_api = self._safe_call(get_issues_for_pr)(pull_number=pull.number) or set() linked_from_body = extract_issue_numbers_from_body(pull) if not linked_from_api and not linked_from_body: - self._records[pull.number] = PullRequestRecord(pull, skip=skip_record) + self._records[pull.number] = PullRequestRecord(pull, pull_labels, skip=skip_record) logger.debug("Created record for PR %d: %s", pull.number, pull.title) else: logger.debug("Registering pull number: %s, title : %s", pull.number, pull.title) diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index a6d3bafe..adf50052 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -126,7 +126,6 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined parent_issue = self._safe_call(data.repository.get_issue)(issue_number) if data.repository else None if parent_issue is not None: self._create_issue_record_using_sub_issues_existence(parent_issue) - record_keys = self._records.keys() if issue_number in record_keys and isinstance( self._records[issue_number], (SubIssueRecord, HierarchyIssueRecord, IssueRecord) @@ -240,7 +239,8 @@ def _re_register_hierarchy_issues(self): self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one made_progress = True else: - logger.error("Detected IssueRecord in position of SubIssueRecord - leaving as standalone and dropping mapping") + logger.error("Detected IssueRecord in position of SubIssueRecord - leaving as standalone" + " and dropping mapping") # Avoid infinite recursion by removing the unresolved mapping self.__sub_issue_parents.pop(sub_issue_number, None) diff --git a/release_notes_generator/utils/pull_request_utils.py b/release_notes_generator/utils/pull_request_utils.py index c9764f9e..4bf7adca 100644 --- a/release_notes_generator/utils/pull_request_utils.py +++ b/release_notes_generator/utils/pull_request_utils.py @@ -52,7 +52,7 @@ def extract_issue_numbers_from_body(pr: PullRequest) -> set[int]: @lru_cache(maxsize=1024) def get_issues_for_pr(pull_number: int) -> set[int]: - """Update the placeholder values and format the graphQL query""" + """Fetch closing issue numbers for a PR via GitHub GraphQL and return them as a set.""" github_api_url = "https://api.github.com/graphql" query = ISSUES_FOR_PRS.format( number=pull_number, diff --git a/tests/conftest.py b/tests/conftest.py index f93c7a1a..0bcd44cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,7 @@ class MockLabel: def __init__(self, name): self.name = name + def mock_safe_call_decorator(_rate_limiter): def wrapper(fn): if fn.__name__ == "get_issues_for_pr": @@ -52,6 +53,7 @@ def wrapper(fn): return fn return wrapper + def mock_get_issues_for_pr(pull_number: int) -> set[int]: # if pull_number == 150: # return [451] diff --git a/tests/release_notes/model/test_commit_record.py b/tests/release_notes/model/test_commit_record.py index 87b13568..d335b5cf 100644 --- a/tests/release_notes/model/test_commit_record.py +++ b/tests/release_notes/model/test_commit_record.py @@ -1,5 +1,4 @@ from release_notes_generator.model.commit_record import CommitRecord -from tests.conftest import mock_commit def test_basic_properties(mock_commit): @@ -49,7 +48,7 @@ def test_to_chapter_row_with_release_notes_injected(monkeypatch, mock_commit): lambda _: True, ) rec = CommitRecord(mock_commit) - monkeypatch.setattr(rec, "get_rls_notes", lambda line_marks=None: "Extra release notes.") + monkeypatch.setattr(rec, "get_rls_notes", lambda _line_marks=None: "Extra release notes.") row = rec.to_chapter_row() assert "\nExtra release notes." in row From 91fdaa1a2aa5004acfd82a44e5bc346af998434a Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 14:59:33 +0200 Subject: [PATCH 30/33] Fix linters. --- release_notes_generator/model/sub_issue_record.py | 4 ++-- .../record/factory/issue_hierarchy_record_factory.py | 5 +++-- release_notes_generator/utils/pull_request_utils.py | 5 +---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/release_notes_generator/model/sub_issue_record.py b/release_notes_generator/model/sub_issue_record.py index 6f1d80b1..c2ac9d7f 100644 --- a/release_notes_generator/model/sub_issue_record.py +++ b/release_notes_generator/model/sub_issue_record.py @@ -5,7 +5,7 @@ import logging from typing import Optional -from github.Issue import SubIssue +from github.Issue import SubIssue, Issue from release_notes_generator.model.issue_record import IssueRecord @@ -18,7 +18,7 @@ class SubIssueRecord(IssueRecord): Inherits from Record and provides additional functionality specific to issues. """ - def __init__(self, sub_issue: SubIssue, issue_labels: Optional[list[str]] = None, skip: bool = False): + def __init__(self, sub_issue: SubIssue | Issue, issue_labels: Optional[list[str]] = None, skip: bool = False): super().__init__(sub_issue, issue_labels, skip) # properties - override IssueRecord properties diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index adf50052..2c8b1a55 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -239,8 +239,9 @@ def _re_register_hierarchy_issues(self): self.__sub_issue_parents.pop(sub_issue_number) # remove from sub-parents as it is now sub-one made_progress = True else: - logger.error("Detected IssueRecord in position of SubIssueRecord - leaving as standalone" - " and dropping mapping") + logger.error( + "Detected IssueRecord in position of SubIssueRecord - leaving as standalone" " and dropping mapping" + ) # Avoid infinite recursion by removing the unresolved mapping self.__sub_issue_parents.pop(sub_issue_number, None) diff --git a/release_notes_generator/utils/pull_request_utils.py b/release_notes_generator/utils/pull_request_utils.py index 4bf7adca..6127cfb1 100644 --- a/release_notes_generator/utils/pull_request_utils.py +++ b/release_notes_generator/utils/pull_request_utils.py @@ -70,9 +70,6 @@ def get_issues_for_pr(pull_number: int) -> set[int]: data = response.json() if "errors" in data and data["errors"]: raise RuntimeError(f"GitHub GraphQL errors: {data['errors']}") - numbers = { - node["number"] - for node in data["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"] - } + numbers = {node["number"] for node in data["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"]} return numbers From 7cc7b71430537ed138ab1e823d76156edc94d5d0 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 20:41:25 +0200 Subject: [PATCH 31/33] Fix Rabbit review notes. --- .../model/sub_issue_record.py | 6 +++--- .../factory/issue_hierarchy_record_factory.py | 21 +++++++++---------- .../utils/pull_request_utils.py | 7 +++---- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/release_notes_generator/model/sub_issue_record.py b/release_notes_generator/model/sub_issue_record.py index c2ac9d7f..7f95390e 100644 --- a/release_notes_generator/model/sub_issue_record.py +++ b/release_notes_generator/model/sub_issue_record.py @@ -14,8 +14,8 @@ class SubIssueRecord(IssueRecord): """ - A class used to represent an issue record in the release notes. - Inherits from Record and provides additional functionality specific to issues. + Represents a sub-issue record in the release notes. + Inherits from IssueRecord and specializes behavior for sub-issues. """ def __init__(self, sub_issue: SubIssue | Issue, issue_labels: Optional[list[str]] = None, skip: bool = False): @@ -30,7 +30,7 @@ def issue(self) -> SubIssue: Returns: The issue associated with the record. """ if not isinstance(self._issue, SubIssue): - raise TypeError("SubIssueRecord.issue is expected to be a SubIssue") + raise TypeError("Expected SubIssue") return self._issue # properties - specific to IssueRecord diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 2c8b1a55..609095bc 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -48,9 +48,9 @@ class IssueHierarchyRecordFactory(DefaultRecordFactory): def __init__(self, github: Github) -> None: super().__init__(github) - self.__registered_issues: list[int] = [] + self.__registered_issues: set[int] = set() self.__sub_issue_parents: dict[int, int] = {} - self.__registered_commits: list[str] = [] + self.__registered_commits: set[str] = set() def generate(self, data: MinedData) -> dict[int | str, Record]: """ @@ -106,7 +106,7 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined pull_labels = [label.name for label in pull.get_labels()] skip_record: bool = any(item in pull_labels for item in ActionInputs.get_skip_release_notes_labels()) related_commits = [c for c in data.commits if c.sha == pull.merge_commit_sha] - self.__registered_commits.extend(c.sha for c in related_commits) + self.__registered_commits.update(c.sha for c in related_commits) linked_from_api = self._safe_call(get_issues_for_pr)(pull_number=pull.number) or set() linked_from_body = extract_issue_numbers_from_body(pull) @@ -159,10 +159,9 @@ def _create_issue_record_using_sub_issues_existence(self, issue: Issue) -> None: def _create_issue_record_using_sub_issues_not_existence(self, issue: Issue) -> None: # Expected to run after all issue with sub-issues are registered - if issue.number in self.__sub_issue_parents.keys(): # pylint: disable=consider-iterating-dictionary - self._create_record_for_sub_issue(issue) - else: - self._create_record_for_issue(issue) + if issue.number in self.__sub_issue_parents: # already handled as SubIssue + return + self._create_record_for_issue(issue) def _create_record_for_hierarchy_issue(self, i: Issue, issue_labels: Optional[list[str]] = None) -> None: """ @@ -181,7 +180,7 @@ def _create_record_for_hierarchy_issue(self, i: Issue, issue_labels: Optional[li skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) self._records[i.number] = HierarchyIssueRecord(issue=i, skip=skip_record) - self.__registered_issues.append(i.number) + self.__registered_issues.add(i.number) logger.debug("Created record for hierarchy issue %d: %s", i.number, i.title) def _get_issue_labels_mix_with_type(self, issue: Issue) -> list[str]: @@ -199,7 +198,7 @@ def _create_record_for_issue(self, issue: Issue, issue_labels: Optional[list[str issue_labels = self._get_issue_labels_mix_with_type(issue) super()._create_record_for_issue(issue, issue_labels) - self.__registered_issues.append(issue.number) + self.__registered_issues.add(issue.number) def _create_record_for_sub_issue(self, issue: Issue, issue_labels: Optional[list[str]] = None) -> None: if issue_labels is None: @@ -207,7 +206,7 @@ def _create_record_for_sub_issue(self, issue: Issue, issue_labels: Optional[list skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels()) logger.debug("Created record for sub issue %d: %s", issue.number, issue.title) - self.__registered_issues.append(issue.number) + self.__registered_issues.add(issue.number) self._records[issue.number] = SubIssueRecord(issue, issue_labels, skip_record) def _re_register_hierarchy_issues(self): @@ -245,7 +244,7 @@ def _re_register_hierarchy_issues(self): # Avoid infinite recursion by removing the unresolved mapping self.__sub_issue_parents.pop(sub_issue_number, None) - if self.__sub_issue_parents.items() and made_progress: + if self.__sub_issue_parents and made_progress: self._re_register_hierarchy_issues() def order_hierarchy_levels(self, level: int = 0) -> None: diff --git a/release_notes_generator/utils/pull_request_utils.py b/release_notes_generator/utils/pull_request_utils.py index 6127cfb1..956797d3 100644 --- a/release_notes_generator/utils/pull_request_utils.py +++ b/release_notes_generator/utils/pull_request_utils.py @@ -15,7 +15,7 @@ # """ -This module contains utility functions for extracting issue numbers from pull request bodies. +This module contains utility functions for extracting and fetching issue numbers from pull requests. """ import re @@ -68,8 +68,7 @@ def get_issues_for_pr(pull_number: int) -> set[int]: response = requests.post(github_api_url, json={"query": query}, headers=headers, verify=False, timeout=10) response.raise_for_status() # Raise an error for HTTP issues data = response.json() - if "errors" in data and data["errors"]: + if data.get("errors"): raise RuntimeError(f"GitHub GraphQL errors: {data['errors']}") - numbers = {node["number"] for node in data["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"]} - return numbers + return {node["number"] for node in data["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"]} From 1d294f3574fad7132f60a7b0b0e7e76cd23c2df0 Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 20:46:42 +0200 Subject: [PATCH 32/33] One change reverted to fix issue. --- .../record/factory/issue_hierarchy_record_factory.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index 609095bc..c10a1e18 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -159,9 +159,10 @@ def _create_issue_record_using_sub_issues_existence(self, issue: Issue) -> None: def _create_issue_record_using_sub_issues_not_existence(self, issue: Issue) -> None: # Expected to run after all issue with sub-issues are registered - if issue.number in self.__sub_issue_parents: # already handled as SubIssue - return - self._create_record_for_issue(issue) + if issue.number in self.__sub_issue_parents.keys(): # pylint: disable=consider-iterating-dictionary + self._create_record_for_sub_issue(issue) + else: + self._create_record_for_issue(issue) def _create_record_for_hierarchy_issue(self, i: Issue, issue_labels: Optional[list[str]] = None) -> None: """ From 294ed5e039e5247d4fdf6eedce4f0b671f485a9c Mon Sep 17 00:00:00 2001 From: miroslavpojer Date: Wed, 24 Sep 2025 20:54:35 +0200 Subject: [PATCH 33/33] Fix Rabbit review notes. --- .../record/factory/issue_hierarchy_record_factory.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py index c10a1e18..3de18fa2 100644 --- a/release_notes_generator/record/factory/issue_hierarchy_record_factory.py +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -113,10 +113,8 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined pull_issues: list[int] = list(linked_from_api.union(linked_from_body)) attached_any = False if len(pull_issues) > 0: - record_keys = self._records.keys() - for issue_number in pull_issues: - if issue_number not in record_keys: + if issue_number not in self._records: logger.warning( "Detected PR %d linked to issue %d which is not in the list of received issues. " "Fetching ...", @@ -127,7 +125,7 @@ def _register_pull_and_its_commits_to_issue(self, pull: PullRequest, data: Mined if parent_issue is not None: self._create_issue_record_using_sub_issues_existence(parent_issue) - if issue_number in record_keys and isinstance( + if issue_number in self._records and isinstance( self._records[issue_number], (SubIssueRecord, HierarchyIssueRecord, IssueRecord) ): rec = cast(IssueRecord, self._records[issue_number])