diff --git a/.pylintrc b/.pylintrc index a4f988ca..693e6f9e 100644 --- a/.pylintrc +++ b/.pylintrc @@ -304,10 +304,10 @@ 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 +max-locals=20 # Maximum number of parents for a class (see R0901). max-parents=7 @@ -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/README.md b/README.md index 6f2596ec..5bbd4dd6 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,25 +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`. 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 | | +| `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. 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 | +| `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,30 +72,15 @@ 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! -### 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). - ## 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. @@ -144,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/action.yml b/action.yml index f4a27c46..0eea3e9e 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: @@ -32,10 +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' + default: 'false' duplicity-icon: description: 'Icon to be used for duplicity warning. Icon is placed before the record line.' required: false @@ -76,12 +76,16 @@ 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}, {type}. 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 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: @@ -124,10 +128,10 @@ 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 }}" - 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 @@ -137,7 +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_HIERARCHY: ${{ inputs.hierarchy }} INPUT_DUPLICITY_SCOPE: ${{ inputs.duplicity-scope }} INPUT_DUPLICITY_ICON: ${{ inputs.duplicity-icon }} INPUT_WARNINGS: ${{ inputs.warnings }} @@ -150,6 +154,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..e7c67725 100644 --- a/main.py +++ b/main.py @@ -27,7 +27,7 @@ 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.utils.gh_action import set_action_output from release_notes_generator.utils.logging_config import setup_logging @@ -49,7 +49,7 @@ 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() ) diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index 8ad92019..739a3d1f 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -43,12 +43,15 @@ 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, + 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 @@ -63,8 +66,11 @@ class ActionInputs: A class representing the inputs provided to the GH action. """ - REGIME_DEFAULT = "default" + 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 _row_format_link_pr = None @@ -163,11 +169,12 @@ def get_chapters() -> list[dict[str, str]]: return chapters @staticmethod - def get_regime() -> str: + def get_hierarchy() -> bool: """ - Get the regime parameter value from the action inputs. + Check if the hierarchy release notes structure is enabled. """ - return get_action_input("regime", "default") # type: ignore[return-value] # default defined + val = get_action_input("hierarchy", "false") + return str(val).strip().lower() in ("true", "1", "yes", "y", "on") @staticmethod def get_duplicity_scope() -> DuplicityScopeEnum: @@ -296,6 +303,21 @@ 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: + 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, + ) + return ActionInputs._row_format_hierarchy_issue + @staticmethod def get_row_format_issue() -> str: """ @@ -319,6 +341,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 ) @@ -374,10 +397,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]: - errors.append(f"Regime '{regime}' is not supported.") + 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) @@ -416,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) @@ -435,7 +463,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) @@ -456,7 +484,21 @@ 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] + + 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 for invalid_keyword in invalid_keywords: logger.error( 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 77% rename from release_notes_generator/builder/default_builder.py rename to release_notes_generator/builder/builder.py index 11c72de8..4da143c6 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. @@ -53,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/__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 79% rename from release_notes_generator/model/base_chapters.py rename to release_notes_generator/chapters/base_chapters.py index 2ae28ecb..e9f861fc 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 @@ -32,7 +34,10 @@ 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 @property def populated_record_numbers_list(self) -> list[int | str]: @@ -41,7 +46,23 @@ 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: + """ + 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 + + @since.setter + def since(self, value: Optional[datetime]): + self._since = value def add_row(self, chapter_key: str, number: int, row: str) -> None: """ @@ -67,7 +88,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 chapter_string: + 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/model/custom_chapters.py b/release_notes_generator/chapters/custom_chapters.py similarity index 85% rename from release_notes_generator/model/custom_chapters.py rename to release_notes_generator/chapters/custom_chapters.py index 45a42234..b90c65d2 100644 --- a/release_notes_generator/model/custom_chapters.py +++ b/release_notes_generator/chapters/custom_chapters.py @@ -18,16 +18,21 @@ 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.model.sub_issue_record import SubIssueRecord from release_notes_generator.utils.enums import DuplicityScopeEnum +logger = logging.getLogger(__name__) + class CustomChapters(BaseChapters): """ @@ -57,14 +62,14 @@ def populate(self, records: dict[int | str, Record]) -> None: ): 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): - pulls_count = cast(IssueRecord, records[record_id]).pull_requests_count() + 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) 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 from_yaml_array(self, chapters: list[dict[str, str]]) -> "CustomChapters": diff --git a/release_notes_generator/model/service_chapters.py b/release_notes_generator/chapters/service_chapters.py similarity index 92% rename from release_notes_generator/model/service_chapters.py rename to release_notes_generator/chapters/service_chapters.py index c52aa20b..78d236b6 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 @@ -136,16 +136,20 @@ 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: """ 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 @@ -174,6 +178,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) @@ -181,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 @@ -208,6 +218,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 +240,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/filter.py b/release_notes_generator/filter.py index 107eb4bd..7afe69fd 100644 --- a/release_notes_generator/filter.py +++ b/release_notes_generator/filter.py @@ -19,6 +19,8 @@ import logging from copy import deepcopy from typing import Optional + +from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.mined_data import MinedData logger = logging.getLogger(__name__) @@ -53,11 +55,11 @@ def filter(self, data: MinedData) -> MinedData: 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. - @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 +69,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 +109,50 @@ 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 filtering type - default or hierarchy. + + @param data: The mined data containing issues. + @return: The filtered list of issues. + """ + if ActionInputs.get_hierarchy(): + logger.debug("Used hierarchy issue filtering logic.") + return self._filter_issues_issue_hierarchy(data) + + 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. + + Parameters: + data (MinedData): The mined data containing issues and release information. + + 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)] + + def _filter_issues_issue_hierarchy(self, data: MinedData) -> list: + """ + 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. + + Returns: + list: The filtered list of issues. + """ + 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 + ), + data.issues, + ) + ) diff --git a/release_notes_generator/generator.py b/release_notes_generator/generator.py index fecd9997..25f3a7ad 100644 --- a/release_notes_generator/generator.py +++ b/release_notes_generator/generator.py @@ -28,12 +28,11 @@ 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.utils.github_rate_limiter import GithubRateLimiter from release_notes_generator.utils.utils import get_change_url @@ -80,6 +79,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 +92,26 @@ 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( - 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 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, github: Github) -> DefaultRecordFactory: + """ + 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: + DefaultRecordFactory: An instance of either IssueHierarchyRecordFactory or RecordFactory. + """ + 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/miner.py b/release_notes_generator/miner.py index cfd1ac3e..5576626f 100644 --- a/release_notes_generator/miner.py +++ b/release_notes_generator/miner.py @@ -117,24 +117,51 @@ 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 + 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, + ) + 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/commit_record.py b/release_notes_generator/model/commit_record.py index 3fe666cf..0dd4949c 100644 --- a/release_notes_generator/model/commit_record.py +++ b/release_notes_generator/model/commit_record.py @@ -51,13 +51,14 @@ 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 = 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()}" @@ -65,7 +66,10 @@ 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 "" + def get_labels(self) -> list[str]: + return [] + # methods - specific to CommitRecord 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..b59fcc2f --- /dev/null +++ b/release_notes_generator/model/hierarchy_issue_record.py @@ -0,0 +1,154 @@ +""" +A module that defines the HierarchyIssueRecord class for hierarchical issue rendering. +""" + +import logging +from typing import Optional, Any +from github.Issue import Issue + +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__) + + +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_labels: Optional[list[str]] = None, skip: bool = False): + super().__init__(issue, issue_labels, skip) + + self._level: int = 0 + self._sub_issues: dict[int, SubIssueRecord] = {} + self._sub_hierarchy_issues: dict[int, "HierarchyIssueRecord"] = {} + + @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: + 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) + + 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(label.name for label in pull.get_labels()) + + return list(labels) + + # methods - override ancestor methods + 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 "" + format_values: dict[str, Any] = {} + + # collect format values + 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: + 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 + 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_:" + 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._sub_hierarchy_issues.values(): + row = f"{row}\n{sub_hierarchy_issue.to_chapter_row()}" + + # add sub-issues + if len(self._sub_issues) > 0: + sub_indent = " " * (self._level + 1) + for sub_issue in self._sub_issues.values(): + 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() + ) + 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 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 1b4c351c..375028d0 100644 --- a/release_notes_generator/model/issue_record.py +++ b/release_notes_generator/model/issue_record.py @@ -23,16 +23,16 @@ 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_labels: Optional[list[str]] = None, skip: bool = False): super().__init__(skip=skip) self._issue: Issue = issue - self._issue_type: Optional[str] = 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 - - self._labels = {label.name for label in self._issue.get_labels()} + else: + self._issue_type = None self._pull_requests: dict[int, PullRequest] = {} self._commits: dict[int, dict[str, Commit]] = {} @@ -78,8 +78,28 @@ def issue_type(self) -> Optional[str]: # methods - override Record methods - def to_chapter_row(self) -> str: - super().to_chapter_row() + def get_labels(self) -> list[str]: + self._labels = [label.name for label in list(self._issue.get_labels())] + return self.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 + + return None + + 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 "" format_values: dict[str, Any] = {} @@ -177,7 +197,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 +228,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 not self._pull_requests: 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..3bf53f08 100644 --- a/release_notes_generator/model/pull_request_record.py +++ b/release_notes_generator/model/pull_request_record.py @@ -21,12 +21,10 @@ 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._labels = {label.name for label in self._pull_request.get_labels()} - self._commits: dict[str, Commit] = {} # properties - override Record properties @@ -106,8 +104,14 @@ def contributors(self) -> list[str]: # methods - override Record methods - def to_chapter_row(self) -> str: - super().to_chapter_row() + def get_labels(self) -> list[str]: + 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: + 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..28367bee 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: Optional[list[str]] = labels self._rls_notes: Optional[str] = None # single annotation here # properties @@ -63,7 +62,10 @@ def labels(self) -> list[str]: Returns: list[str]: A list of labels associated with the record. """ - return list(self._labels) + if self._labels is None: + self._labels = self.get_labels() + + return self._labels @property @abstractmethod @@ -101,16 +103,27 @@ 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 = 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. """ - self.added_into_chapters() - return "" - # abstract methods + @abstractmethod + def get_labels(self) -> list[str]: + """ + Gets the labels of the record. + Returns: + list[str]: A list of labels associated with the record. + """ @abstractmethod def get_rls_notes(self, line_marks: Optional[list[str]] = None) -> str: @@ -159,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/model/sub_issue_record.py b/release_notes_generator/model/sub_issue_record.py new file mode 100644 index 00000000..7f95390e --- /dev/null +++ b/release_notes_generator/model/sub_issue_record.py @@ -0,0 +1,36 @@ +""" +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 release_notes_generator.model.issue_record import IssueRecord + +logger = logging.getLogger(__name__) + + +class SubIssueRecord(IssueRecord): + """ + 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): + super().__init__(sub_issue, issue_labels, skip) + + # properties - override IssueRecord properties + + @property + def issue(self) -> SubIssue: + """ + Gets the issue associated with the record. + Returns: The issue associated with the record. + """ + if not isinstance(self._issue, SubIssue): + raise TypeError("Expected SubIssue") + return self._issue + + # properties - specific to IssueRecord 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 68% rename from release_notes_generator/record/default_record_factory.py rename to release_notes_generator/record/factory/default_record_factory.py index 872fadc4..a3420610 100644 --- a/release_notes_generator/record/default_record_factory.py +++ b/release_notes_generator/record/factory/default_record_factory.py @@ -15,11 +15,11 @@ # """ -This module contains the RecordFactory class which is responsible for generating records for release notes. +DefaultRecordFactory builds Record objects (issues, pulls, commits) from mined GitHub data. """ import logging -from typing import cast +from typing import cast, Optional from github import Github from github.Issue import Issue @@ -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 @@ -46,11 +46,16 @@ class DefaultRecordFactory(RecordFactory): A class used to generate records for release notes. """ - def generate(self, github: Github, data: MinedData) -> dict[int | str, Record]: + 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. 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,12 +64,14 @@ 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)) - 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 - 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 ...", @@ -72,13 +79,13 @@ 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: - 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( @@ -88,49 +95,44 @@ def register_pull_request(pull: PullRequest, skip_rec: bool) -> None: 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...") 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: 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) + 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, 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) 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(): @@ -145,21 +147,24 @@ 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) -> None: + def _create_record_for_issue(self, issue: Issue, issue_labels: Optional[list[str]] = None) -> None: """ Create a record for an issue. - @param i: Issue instance. - @return: None + Parameters: + 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 - issue_labels = [label.name for label in i.get_labels()] + 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()) - records[i.number] = IssueRecord(issue=i, skip=skip_record) - - logger.debug("Created record for 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 new file mode 100644 index 00000000..3de18fa2 --- /dev/null +++ b/release_notes_generator/record/factory/issue_hierarchy_record_factory.py @@ -0,0 +1,261 @@ +# +# 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. +# + +""" +IssueHierarchyRecordFactory builds hierarchical issue records (Epics/Features/Tasks) and associates PRs/commits. +""" + +import logging +from typing import cast, Optional + +from github import Github +from github.Issue import Issue +from github.PullRequest import PullRequest + +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 +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.pull_request_utils import get_issues_for_pr, extract_issue_numbers_from_body + +logger = logging.getLogger(__name__) + + +class IssueHierarchyRecordFactory(DefaultRecordFactory): + """ + A class used to generate records for release notes. + """ + + def __init__(self, github: Github) -> None: + super().__init__(github) + + self.__registered_issues: set[int] = set() + self.__sub_issue_parents: dict[int, int] = {} + self.__registered_commits: set[str] = set() + + def generate(self, data: MinedData) -> dict[int | str, Record]: + """ + Generate records for release notes. + Parameters: + 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. + """ + 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 + + 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) + + 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) + + # 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(self._records), + len(data.issues), + len(data.pull_requests), + len(data.commits), + ) + return self._records + + 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] + 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) + pull_issues: list[int] = list(linked_from_api.union(linked_from_body)) + attached_any = False + if len(pull_issues) > 0: + for issue_number in pull_issues: + 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 ...", + 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) + + if issue_number in self._records and isinstance( + self._records[issue_number], (SubIssueRecord, HierarchyIssueRecord, IssueRecord) + ): + rec = cast(IssueRecord, self._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 + rec.register_commit(pull, c) + logger.debug("Registering commit %s to PR %d", c.sha, pull.number) + + 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) + + 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_hierarchy_issue(issue) + for si in sub_issues: + # 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(): # 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: + """ + Create a hierarchy issue record and register sub-issues. + + Parameters: + 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 + if issue_labels is None: + 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.__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]: + labels: list[str] = [label.name for label in issue.get_labels()] + + if issue.type is not 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[str]] = None) -> None: + if issue_labels is None: + issue_labels = self._get_issue_labels_mix_with_type(issue) + + super()._create_record_for_issue(issue, issue_labels) + 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: + 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", issue.number, issue.title) + self.__registered_issues.add(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()) + + 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 + # 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. + 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] + + 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 + 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 - leaving as standalone" " and dropping mapping" + ) + # Avoid infinite recursion by removing the unresolved mapping + self.__sub_issue_parents.pop(sub_issue_number, None) + + if self.__sub_issue_parents and made_progress: + self._re_register_hierarchy_issues() + + 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 + 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) diff --git a/release_notes_generator/record/record_factory.py b/release_notes_generator/record/factory/record_factory.py similarity index 71% rename from release_notes_generator/record/record_factory.py rename to release_notes_generator/record/factory/record_factory.py index cf06b4b9..62fdbd00 100644 --- a/release_notes_generator/record/record_factory.py +++ b/release_notes_generator/record/factory/record_factory.py @@ -15,30 +15,31 @@ # """ -This module contains the RecordFactory class which is responsible for generating records for release notes. +This module contains the RecordFactory base class used to generate records. """ -from abc import ABCMeta, abstractmethod - -from github import Github +import abc +import logging 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 - def generate(self, github: Github, data: MinedData) -> dict[int | str, Record]: + @abc.abstractmethod + def generate(self, data: MinedData) -> dict[int | str, Record]: """ Generate records for release notes. - Parameters: - github (Github): GitHub instance. 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 + # fix unit test first to detect breaking changes diff --git a/release_notes_generator/utils/constants.py b/release_notes_generator/utils/constants.py index e5ca2318..48712d41 100644 --- a/release_notes_generator/utils/constants.py +++ b/release_notes_generator/utils/constants.py @@ -34,10 +34,13 @@ 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" -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"] # Features WARNINGS = "warnings" diff --git a/release_notes_generator/utils/pull_request_utils.py b/release_notes_generator/utils/pull_request_utils.py index e66997ee..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 the PullRequestRecord class which is responsible for representing a record in the release notes. +This module contains utility functions for extracting and fetching issue numbers from pull requests. """ import re @@ -28,13 +28,15 @@ 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. - @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]+)") @@ -48,9 +50,9 @@ def extract_issue_numbers_from_body(pr: PullRequest) -> set[int]: return issue_numbers -@lru_cache(maxsize=None) -def get_issues_for_pr(pull_number: int) -> list[int]: - """Update the placeholder values and formate the graphQL query""" +@lru_cache(maxsize=1024) +def get_issues_for_pr(pull_number: int) -> set[int]: + """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, @@ -65,8 +67,8 @@ 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 = [ - node["number"] - for node in response.json()["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"] - ] - return numbers + data = response.json() + if data.get("errors"): + raise RuntimeError(f"GitHub GraphQL errors: {data['errors']}") + + return {node["number"] for node in data["data"]["repository"]["pullRequest"]["closingIssuesReferences"]["nodes"]} diff --git a/tests/conftest.py b/tests/conftest.py index cb2a179d..0bcd44cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,26 +14,27 @@ # limitations under the License. # -import time - from datetime import datetime, timedelta +import copy 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.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.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.model.sub_issue_record import SubIssueRecord from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter @@ -45,6 +46,20 @@ 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 def custom_chapters(): @@ -60,6 +75,7 @@ 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"]), } @@ -165,6 +181,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 +225,165 @@ 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) + 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 = "label3" + 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_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) + 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): @@ -342,8 +518,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() @@ -398,11 +574,269 @@ 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.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 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() + + # single issue record (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 + 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 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" + 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 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" + 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.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 + 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 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" + 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.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] + + # single pull request record (closed, merged) + 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) # 124 + mock_pr_merged_1.get_labels.return_value = [] + + # single direct commit record + mock_commit_3 = copy.deepcopy(mock_commit) + mock_commit_3.sha = "merge_commit_sha_direct" + mock_commit_3.commit.message = "Direct commit example" + + data.issues = [solo_closed_issue, + 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 + + 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 + + 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 + + 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): @@ -412,6 +846,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): @@ -441,6 +882,40 @@ 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_hierarchy_issues(request): + 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 + + 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 + + # add sub_issue + 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 + 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) + + 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")) + rec_bug_issue.register_pull_request(request.getfixturevalue("mock_pull_closed_with_rls_notes_102")) + + return rec_epic_issue + @pytest.fixture def record_with_issue_open_one_pull_closed(request): 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/builder/test_release_notes_builder.py b/tests/release_notes/builder/test_release_notes_builder.py new file mode 100644 index 00000000..cbb47ec0 --- /dev/null +++ b/tests/release_notes/builder/test_release_notes_builder.py @@ -0,0 +1,1833 @@ +# +# Copyright 2023 ABSA Group Limited +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import pytest +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, MockLabel + +# pylint: disable=pointless-string-statement +""" + Issue can be in 2 states (each in 2 'sub' states): + - Open + - Open (initial) [state_reason = null] + - Reopened [state_reason = "reopened"] + - Closed + - Closed [state_reason = null] + - Closed (Not planned) [state_reason = "not_planned"] + + Issue can have these logical states: + - by linked PR + - With one + - With multiple + - Without + - by user labels + - With one + - With multiple + - Without + + Pull Request can be in 2 states: + - Open [state = open] + - Open (Reopened) [state = open, no other flag detected - additional comment required] + + Ready for review + - Closed [state - closed, all *_at = time, draft = false] + - Closed (not planned) [state = closed, merged_at = null, draft = false] + + Draft + - xx Closed xx [state - closed, Not possible to merge !!!] + - Closed (not planned) [state = closed, merged_at = null, draft = true] + + Pull Request can have these logical states: + - by user labels + - With one + - With multiple + - Without + - by link/mention Issue + - With one in state + - Open (init) + - Open (Reopened) + - Closed + - Closed (not planned) + - With multiple in these states + - Open (init) + - Open (Reopened) + - Closed + - Closed (not planned) + - Without linked Issue +""" + +# pylint: disable=too-few-public-methods + +DEFAULT_CHANGELOG_URL = "http://example.com/changelog" +default_chapters = [ + {"title": "Breaking Changes 💥", "label": "breaking-change"}, + {"title": "New Features 🎉", "label": "feature"}, + {"title": "New Features 🎉", "label": "enhancement"}, + {"title": "Bugfixes 🛠", "label": "bug"}, + ] + +RELEASE_NOTES_NO_DATA = """### Breaking Changes 💥 +No entries detected. + +### New Features 🎉 +No entries detected. + +### Bugfixes 🛠 +No entries detected. + +### Closed Issues without Pull Request ⚠️ +All closed issues linked to a Pull Request. + +### 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 ⚠️ +All merged PRs are linked to issues. + +### 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 ⚠️ +Previous filters caught all Issues or Pull Requests. + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_NO_DATA_NO_WARNING = """### Breaking Changes 💥 +No entries detected. + +### New Features 🎉 +No entries detected. + +### Bugfixes 🛠 +No entries detected. + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS = """#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_NO_DATA_NO_EMPTY_CHAPTERS = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS + +RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_ONE_LABEL = """### Chapter 1 🛠 +- #122 _I1+bug_ in #101, #102 + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + - PR 101 1st release note + - PR 101 2nd release note + - PR 102 1st release note + - PR 102 2nd release note + +#### Full Changelog +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... - Direct commit example + +### 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... - Direct commit example + +### 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... - Direct commit example + +### 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... - Direct commit example + +### 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... - Direct commit example + +### 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... - Direct commit example + +### 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... - Direct commit example + +### 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... - Direct commit example + +### 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 + - 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 + - Improved performance + + More nice code + * Awesome architecture + - PR 101 1st release note + - PR 101 2nd release note + - PR 102 1st release note + - PR 102 2nd release note + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_MORE_LABELS_DUPLICITY_REDUCTION_OFF = """### New Features 🎉 +- #1 _I1+0PR+2L-bug-enhancement_ in #101, #102 + - PR 101 1st release note + - PR 101 2nd release note + - PR 102 1st release note + - PR 102 2nd release note + +TODO - add bug chapter + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_ISSUE_NO_PR_NO_USER_LABELS = """### Closed Issues without Pull Request ⚠️ +- #121 _Fix the bug_ in + +### Closed Issues without User Defined Labels ⚠️ +- 🔔 #121 _Fix the bug_ in + +#### Full Changelog +http://example.com/changelog +""" + +# 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: #124 _Fixed bug_ + - Fixed bug + - Improved performance + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_NO_USER_LABELS = """### Closed PRs without Issue and User Defined Labels ⚠️ +- PR: #123 _Fixed bug_ + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_SKIP_USER_LABELS = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS + +RELEASE_NOTES_DATA_SERVICE_CHAPTERS_OPEN_ISSUE_AND_MERGED_PR_NO_USER_LABELS = """### Merged PRs Linked to 'Not Closed' Issue ⚠️ +- #122 _I1 open_ in #101, #102 + - PR 101 1st release note + - PR 101 2nd release note + - PR 102 1st release note + - PR 102 2nd release note + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_SERVICE_CHAPTERS_OPEN_ISSUE_AND_MERGED_PR_NO_USER_LABELS_ISSUE_NOT_PART_OF_RECORD = """### Others - No Topic ⚠️ +- PR: #101 _PR 101_ + - PR 101 1st release note + - PR 101 2nd release note + - PR 102 1st release note + - PR 102 2nd release note + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_CLOSED_ISSUE_NO_PR_WITH_USER_LABELS = """### Closed Issues without Pull Request ⚠️ +- #121 _Fix the bug_ in + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_PR_WITHOUT_USER_LABELS = """### Closed Issues without User Defined Labels ⚠️ +- #122 _I1_ in #101, #102 + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + - PR 101 1st release note + - PR 101 2nd release note + - PR 102 1st release note + - PR 102 2nd release note + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_OPEN_PR_WITHOUT_ISSUE = """### Others - No Topic ⚠️ +- PR: #123 _Fix bug_ + - Fixed bug + - Improved performance + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_MERGED_PR_WITH_USER_LABELS_DUPLICITY_REDUCTION_ON = """### Chapter 1 🛠 +- PR: #124 _Fixed bug_ + - Fixed bug + - Improved performance + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_MERGED_PRS_WITH_OPEN_ISSUES = """### Merged PRs Linked to 'Not Closed' Issue ⚠️ +- #122 _I1 open_ in #101 + - PR 101 1st release note + - PR 101 2nd release note +- #123 _I2 open_ in #102 + - PR 102 1st release note + - PR 102 2nd release note + +#### Full Changelog +http://example.com/changelog +""" + +RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_MERGED_PRS_WITHOUT_USER_LABELS = """### Closed Issues without User Defined Labels ⚠️ +- #121 _Fix the bug_ in #123 + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + +#### Full Changelog +http://example.com/changelog +""" + +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 #124 + - Fixed bug + - Improved performance + + More nice code + * Awesome architecture + - Fixed bug + - Improved performance + +#### Full Changelog +http://example.com/changelog +""" + +# build + + +@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) + + expected_release_notes = RELEASE_NOTES_NO_DATA + + builder = ReleaseNotesBuilder( + records={}, # empty record data set + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters, + ) + + actual_release_notes = builder.build() + assert expected_release_notes == actual_release_notes + + +@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) + + expected_release_notes = RELEASE_NOTES_NO_DATA_NO_WARNING + + builder = ReleaseNotesBuilder( + records={}, # empty record data set + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters, + ) + + actual_release_notes = builder.build() + assert expected_release_notes == actual_release_notes + + +@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 + 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 = ReleaseNotesBuilder( + records={}, + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters_no_empty_chapters, + ) + + actual_release_notes = builder.build() + assert expected_release_notes == actual_release_notes + + +@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 + 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 = ReleaseNotesBuilder( + records={}, + changelog_url=DEFAULT_CHANGELOG_URL, + custom_chapters=custom_chapters_no_empty_chapters, + ) + + actual_release_notes = builder.build() + assert expected_release_notes == actual_release_notes + + +# Test cases covering data variations +# --------------------------------------------------------------------------------------------- +# from custom/uer defined chapters +# --------------------------------------------------------------------------------------------- +# Happy paths - see closed issue in used defined chapters +# Test: issue in Closed (1st) state is visible in the release notes - with one label +# "test_name": "test_build_closed_issue_with_one_custom_label", +# "expected_release_notes": release_notes_data_custom_chapters_one_label, +# "records": __get_record_mock_1_issue_with_2_prs(mock_repo(), issue_labels=['bug']) + +# Test: issue in Closed (1st) state is visible in the release notes - with more label - duplicity reduction on +# "test_name": "test_build_closed_issue_with_more_custom_labels_duplicity_reduction_on", +# "expected_release_notes": release_notes_data_custom_chapters_more_labels_duplicity_reduction_on, +# "records": __get_record_mock_1_issue_with_2_prs(mock_repo(), issue_labels=['bug', 'enhancement']) + +# Test: issue in Closed (1st) state is visible in the release notes - with more label - duplicity reduction off +# TODO - switch off duplicity reduction +# "test_name": "test_build_closed_issue_with_more_custom_labels_duplicity_reduction_off", +# "expected_release_notes": release_notes_data_custom_chapters_more_labels_duplicity_reduction_off, +# "records": __get_record_mock_with_2_prs(issue_labels=['bug', 'enhancement']) + +# --------------------------------------------------------------------------------------------- +# from service chapters point of view +# --------------------------------------------------------------------------------------------- +# Happy paths - see closed issue in services chapters +# Test: issue in Closed (1st) - visible in service chapters - without pr and user defined labels - no labels +# "test_name": "test_build_closed_issue_service_chapter_without_pull_request_and_user_defined_label", +# "expected_release_notes": release_notes_data_service_chapters_closed_issue_no_pr_no_user_labels, +# "records": {0: Record(repo=mock_repo(), issue=__get_default_issue_mock(number=1, state="closed"))} + +# Test: pr in merged (1st) state is visible in the release notes service chapters - no labels +# "test_name": "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, +# "records": __get_record_mock_1_pr_with_no_issue(mock_repo()) + +# Test: pr in closed state is visible in the release notes service chapters - no labels +# "test_name": "test_build_merged_pr_service_chapter_without_issue_and_user_labels", +# "expected_release_notes": release_notes_data_service_chapters_closed_pr_no_issue_no_user_labels, +# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), is_merged=False) + +# Test: issue in open state with pr in merged state is visible in the release notes service chapters - no labels +# Reasons: Issue reopened after PR merge, Issue mention added after PR merge. +# "test_name": "test_build_open_issue_with_merged_pr_service_chapter_linked_to_not_closed_issue", +# "expected_release_notes": release_notes_data_service_chapters_open_issue_and_merged_pr_no_user_labels, +# "records": __get_record_mock_1_issue_with_2_prs(mock_repo(), issue_state="open") + +# Test: No Topic service chapter is here to catch unexpected and 'new' data combinations - do not lost them +# --------------------------------------------------------------------------------------------- +# from Issues states point of view +# --------------------------------------------------------------------------------------------- +# Alternative paths - see issue in all states without labels ==> in correct service chapters +# Test: issue in Open (Initial) state is not visible in the release notes - no labels +# "test_name": "test_build_open_issue", +# "expected_release_notes": release_notes_no_data_no_warning_no_empty_chapters, +# "records": {0: Record(Mock(), __get_default_issue_mock(number=1, state="open"))} + +# Test: issue in Open (Reopened) state is not visible in the release notes - no labels +# "test_name": "test_build_reopened_issue", +# "expected_release_notes": release_notes_no_data_no_warning_no_empty_chapters, +# "records": {0: Record(mock_repo(), __get_default_issue_mock(number=1, state="open", state_reason="reopened"))} + +# Test: issue in Closed (1st) state is not visible in the release notes - no labels +# "test_name": "test_build_closed_issue", +# "expected_release_notes": release_notes_data_service_chapters_closed_issue_no_pr_no_user_labels, +# "records": {0: Record(mock_repo(), __get_default_issue_mock(number=1, state="closed"))} + +# Test: issue in Closed (not_planned) state is visible in the release notes - no labels +# "test_name": "test_build_closed_not_planned_issue", +# "expected_release_notes": release_notes_data_service_chapters_closed_issue_no_pr_no_user_labels, +# "records": {0: Record(mock_repo(), +# __get_default_issue_mock(number=1, state="closed", state_reason="not_planned"))} + + +# --------------------------------------------------------------------------------------------- +# Alternative paths - see issue in all logical states ==> in correct service chapters +# Test: Closed Issue without linked PR with user labels ==> not part of custom chapters as there is no merged change +# "test_name": "test_build_closed_issue_with_user_labels_no_prs", +# "expected_release_notes": release_notes_data_closed_issue_no_pr_with_user_labels, +# "records": {0: Record(mock_repo(), +# __get_default_issue_mock(number=1, state="closed", labels=['bug', 'breaking-changes']))} + +# Test: Closed Issue without linked PR without user labels +# - covered in 'test_build_merged_pr_service_chapter_without_issue_and_user_labels' + +# Test: Closed Issue with 1+ merged PRs with 1+ user labels +# - covered in 'test_build_closed_issue_with_more_custom_labels_duplicity_reduction_off' + +# Test: Closed Issue with 1+ merged PRs without user labels +# "test_name": "test_build_closed_issue_with_prs_without_user_label", +# "expected_release_notes": release_notes_data_closed_issue_with_pr_without_user_labels, +# "records": __get_record_mock_1_issue_with_2_prs(mock_repo()) + +# --------------------------------------------------------------------------------------------- +# from PR states point of view +# --------------------------------------------------------------------------------------------- +# Alternative paths - see pull request in all states ==> in correct service chapters +# Test: Open PR without Issue ==> Open PR are ignored as they are not merged - no change to document +# - Note: this should not happen, but if this happens, it will be reported in Others - No Topic chapter +# "test_name": "test_build_open_pr_without_issue", +# "expected_release_notes": release_notes_data_open_pr_without_issue, +# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), state="open") + +# Test: Ready for review - Merged PR (is change in repo) +# "test_name": "test_build_merged_pr_without_issue_ready_for_review", +# "expected_release_notes": release_notes_data_service_chapters_merged_pr_no_issue_no_user_labels, +# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), state="closed") + +# Test: Ready for review - Closed PR (not planned) +# "test_name": "test_build_closed_pr_without_issue_ready_for_review", +# "expected_release_notes": release_notes_data_service_chapters_closed_pr_no_issue_no_user_labels, +# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), state="closed", is_merged=False) + +# Test: Draft - Closed PR (not planned) +# "test_name": "test_build_closed_pr_without_issue_draft", +# "expected_release_notes": release_notes_data_service_chapters_closed_pr_no_issue_no_user_labels, +# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), state="closed", is_merged=False, is_draft=True) + +# --------------------------------------------------------------------------------------------- +# Alternative paths - see pull request in all logical states ==> in correct service chapters +# Test: Merged PR without Issue without user label +# - covered in 'test_build_merged_pr_service_chapter_without_issue_and_user_labels' + +# Test: Merged PR without Issue with more user label - duplicity reduction on +# "test_name": "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, +# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), labels=['bug', 'enhancement']) + +# Test: Merged PR without Issue with more user label - duplicity reduction off - TODO +# "test_name": "test_merged_pr_without_issue_with_more_user_labels_duplicity_reduction_on", +# "expected_release_notes": release_notes_data_service_chapters_merged_pr_no_issue_no_user_labels, +# "records": __get_record_mock_1_pr_with_no_issue(labels=['bug', 'enhancement']) + +# Test: Merged PR with mentioned Open (Init) Issues | same to Reopen as it is same state +# "test_name": "test_merged_pr_with_open_init_issue_mention", +# "expected_release_notes": release_notes_data_merged_prs_with_open_issues, +# "records": __get_record_mock_2_issue_with_2_prs(mock_repo(), issue_1_state="open", issue_2_state="open") + +# Test: Merged PR with mentioned Closed Issues +# - covered in 'test_build_closed_issue_with_prs_without_user_label' + +# Test: Merged PR with mentioned Closed (not planned) Issues - without user labels +# "test_name": "test_merged_pr_with_closed_issue_mention_without_user_labels", +# "expected_release_notes": release_notes_data_closed_issue_with_merged_prs_without_user_labels, +# "records": __get_record_mock_1_issue_with_2_prs(mock_repo(), issue_state="closed", is_closed_not_planned=True) + +# Test: Merged PR with mentioned Closed (not planned) Issues - with user labels +# "test_name": "test_merged_pr_with_closed_issue_mention_with_user_labels", +# "expected_release_notes": release_notes_data_closed_issue_with_merged_prs_with_user_labels, +# "records": __get_record_mock_1_issue_with_2_prs(mock_repo(), issue_state="closed", is_closed_not_planned=True, +# issue_labels=['bug', 'enhancement']) + +# Test: Merged PR without mentioned Issue +# - 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, 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( + records={rec.record_id: rec}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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}, + 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 + + +@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}, + 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 + + +@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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +# 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, 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}, + 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 + + +@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, 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, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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 + + +@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, 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}, + 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_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_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() + + print("XXX") + print(actual_release_notes) + print("YYY") + + 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=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_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) + + 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() + + assert expected_release_notes == actual_release_notes 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 87% rename from tests/release_notes/model/test_base_chapters.py rename to tests/release_notes/chapters/test_base_chapters.py index 512a9c0c..99e44736 100644 --- a/tests/release_notes/model/test_base_chapters.py +++ b/tests/release_notes/chapters/test_base_chapters.py @@ -14,7 +14,9 @@ # limitations under the License. # -from release_notes_generator.model.base_chapters import BaseChapters +from datetime import datetime + +from release_notes_generator.chapters.base_chapters import BaseChapters # Local Record class for testing @@ -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/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 96% rename from tests/release_notes/model/test_custom_chapters.py rename to tests/release_notes/chapters/test_custom_chapters.py index 920ea37e..9f51e5bf 100644 --- a/tests/release_notes/model/test_custom_chapters.py +++ b/tests/release_notes/chapters/test_custom_chapters.py @@ -15,7 +15,8 @@ # 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.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/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/model/test_commit_record.py b/tests/release_notes/model/test_commit_record.py index 41c6d084..d335b5cf 100644 --- a/tests/release_notes/model/test_commit_record.py +++ b/tests/release_notes/model/test_commit_record.py @@ -1,76 +1,57 @@ -import types - from release_notes_generator.model.commit_record import CommitRecord -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, + lambda _: True, ) - commit = DummyCommit("aa11bb22cc33", "Add feature") - rec = CommitRecord(commit) - monkeypatch.setattr(rec, "get_rls_notes", lambda line_marks=None: "Extra release notes.") + 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..95dc0dd3 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 @@ -79,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") 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_default_record_factory.py similarity index 89% rename from tests/release_notes/test_record_factory.py rename to tests/release_notes/record/factory/test_default_record_factory.py index 70a4cd66..51a7fcdb 100644 --- a/tests/release_notes/test_record_factory.py +++ b/tests/release_notes/record/factory/test_default_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.default_record_factory import DefaultRecordFactory def setup_no_issues_pulls_commits(mocker): @@ -172,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) @@ -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 = DefaultRecordFactory(mock_github_client).generate(data) # Check if records for issues and PRs were created assert len(records) == 3 @@ -209,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) @@ -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 = DefaultRecordFactory(mock_github_client).generate(data) # Check if records for issues and PRs were created assert len(records) == 3 @@ -278,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 = DefaultRecordFactory().generate(mock_github_client, data) + mocker.patch("release_notes_generator.record.factory.default_record_factory.get_issues_for_pr", return_value=[2]) + records = DefaultRecordFactory(mock_github_client).generate(data) assert 2 == len(records) @@ -313,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 = DefaultRecordFactory().generate(mock_github_client, data) + mocker.patch("release_notes_generator.record.factory.default_record_factory.get_issues_for_pr", return_value=[2]) + records = DefaultRecordFactory(mock_github_client).generate(data) assert 2 == len(records) @@ -343,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) @@ -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 = DefaultRecordFactory(mock_github_client).generate(data) # Verify the record creation assert 2 == len(records) @@ -374,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) @@ -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 = DefaultRecordFactory(mock_github_client).generate(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 = DefaultRecordFactory(mock_github_client).generate(data) # Verify the record creation assert 2 == len(records) 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..b3447239 --- /dev/null +++ b/tests/release_notes/record/factory/test_issue_hierarchy_record_factory.py @@ -0,0 +1,293 @@ +# +# 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 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 +from tests.conftest import mock_safe_call_decorator + + +# 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()) + +# - 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) + + 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 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() + 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() + 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").commit.message + assert 0 == rec_hi_3.level + + 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").commit.message + assert 0 == rec_hi_4.level + + rec_hi_5 = cast(HierarchyIssueRecord, result[304]) + assert 1 == rec_hi_5.sub_hierarchy_issues[350].level + + +# - 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_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").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").commit.message + + +# - 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_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").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").commit.message + + +# - 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_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").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").commit.message diff --git a/tests/release_notes/test_release_notes_builder.py b/tests/release_notes/test_release_notes_builder.py deleted file mode 100644 index 8f552c16..00000000 --- a/tests/release_notes/test_release_notes_builder.py +++ /dev/null @@ -1,942 +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. -# - -from release_notes_generator.model.custom_chapters import CustomChapters -from release_notes_generator.builder.default_builder import DefaultReleaseNotesBuilder - -# pylint: disable=pointless-string-statement -""" - Issue can be in 2 states (each in 2 'sub' states): - - Open - - Open (initial) [state_reason = null] - - Reopened [state_reason = "reopened"] - - Closed - - Closed [state_reason = null] - - Closed (Not planned) [state_reason = "not_planned"] - - Issue can have these logical states: - - by linked PR - - With one - - With multiple - - Without - - by user labels - - With one - - With multiple - - Without - - Pull Request can be in 2 states: - - Open [state = open] - - Open (Reopened) [state = open, no other flag detected - additional comment required] - - Ready for review - - Closed [state - closed, all *_at = time, draft = false] - - Closed (not planned) [state = closed, merged_at = null, draft = false] - - Draft - - xx Closed xx [state - closed, Not possible to merge !!!] - - Closed (not planned) [state = closed, merged_at = null, draft = true] - - Pull Request can have these logical states: - - by user labels - - With one - - With multiple - - Without - - by link/mention Issue - - With one in state - - Open (init) - - Open (Reopened) - - Closed - - Closed (not planned) - - With multiple in these states - - Open (init) - - Open (Reopened) - - Closed - - Closed (not planned) - - 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"}, - {"title": "New Features 🎉", "label": "feature"}, - {"title": "New Features 🎉", "label": "enhancement"}, - {"title": "Bugfixes 🛠", "label": "bug"}, - ] - -RELEASE_NOTES_NO_DATA = """### Breaking Changes 💥 -No entries detected. - -### New Features 🎉 -No entries detected. - -### Bugfixes 🛠 -No entries detected. - -### Closed Issues without Pull Request ⚠️ -All closed issues linked to a Pull Request. - -### 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 ⚠️ -All merged PRs are linked to issues. - -### 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 ⚠️ -Previous filters caught all Issues or Pull Requests. - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_NO_DATA_NO_WARNING = """### Breaking Changes 💥 -No entries detected. - -### New Features 🎉 -No entries detected. - -### Bugfixes 🛠 -No entries detected. - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS = """#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_NO_DATA_NO_EMPTY_CHAPTERS = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS - -RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_ONE_LABEL = """### Chapter 1 🛠 -- #122 _I1+bug_ in #101, #102 - - Fixed bug - - Improved performance - + More nice code - * Awesome architecture - - PR 101 1st release note - - PR 101 2nd release note - - PR 102 1st release note - - PR 102 2nd release note - -#### 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 - - Improved performance - + More nice code - * Awesome architecture - - PR 101 1st release note - - PR 101 2nd release note - - PR 102 1st release note - - PR 102 2nd release note - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_CUSTOM_CHAPTERS_MORE_LABELS_DUPLICITY_REDUCTION_OFF = """### New Features 🎉 -- #1 _I1+0PR+2L-bug-enhancement_ in #101, #102 - - PR 101 1st release note - - PR 101 2nd release note - - PR 102 1st release note - - PR 102 2nd release note - -TODO - add bug chapter - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_ISSUE_NO_PR_NO_USER_LABELS = """### Closed Issues without Pull Request ⚠️ -- #121 _Fix the bug_ in - -### Closed Issues without User Defined Labels ⚠️ -- 🔔 #121 _Fix the bug_ in - -#### Full Changelog -http://example.com/changelog -""" - -# 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_ - - Fixed bug - - Improved performance - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_NO_USER_LABELS = """### Closed PRs without Issue and User Defined Labels ⚠️ -- PR: #123 _Fixed bug_ - - Fixed bug - - Improved performance - + More nice code - * Awesome architecture - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_PR_NO_ISSUE_SKIP_USER_LABELS = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS - -RELEASE_NOTES_DATA_SERVICE_CHAPTERS_OPEN_ISSUE_AND_MERGED_PR_NO_USER_LABELS = """### Merged PRs Linked to 'Not Closed' Issue ⚠️ -- #122 _I1 open_ in #101, #102 - - PR 101 1st release note - - PR 101 2nd release note - - PR 102 1st release note - - PR 102 2nd release note - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_SERVICE_CHAPTERS_OPEN_ISSUE_AND_MERGED_PR_NO_USER_LABELS_ISSUE_NOT_PART_OF_RECORD = """### Others - No Topic ⚠️ -- PR: #101 _PR 101_ - - PR 101 1st release note - - PR 101 2nd release note - - PR 102 1st release note - - PR 102 2nd release note - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_CLOSED_ISSUE_NO_PR_WITH_USER_LABELS = """### Closed Issues without Pull Request ⚠️ -- #121 _Fix the bug_ in - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_PR_WITHOUT_USER_LABELS = """### Closed Issues without User Defined Labels ⚠️ -- #122 _I1_ in #101, #102 - - Fixed bug - - Improved performance - + More nice code - * Awesome architecture - - PR 101 1st release note - - PR 101 2nd release note - - PR 102 1st release note - - PR 102 2nd release note - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_OPEN_PR_WITHOUT_ISSUE = """### Others - No Topic ⚠️ -- PR: #123 _Fix bug_ - - Fixed bug - - Improved performance - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_MERGED_PR_WITH_USER_LABELS_DUPLICITY_REDUCTION_ON = """### Chapter 1 🛠 -- PR: #123 _Fixed bug_ - - Fixed bug - - Improved performance - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_MERGED_PRS_WITH_OPEN_ISSUES = """### Merged PRs Linked to 'Not Closed' Issue ⚠️ -- #122 _I1 open_ in #101 - - PR 101 1st release note - - PR 101 2nd release note -- #123 _I2 open_ in #102 - - PR 102 1st release note - - PR 102 2nd release note - -#### Full Changelog -http://example.com/changelog -""" - -RELEASE_NOTES_DATA_CLOSED_ISSUE_WITH_MERGED_PRS_WITHOUT_USER_LABELS = """### Closed Issues without User Defined Labels ⚠️ -- #121 _Fix the bug_ in #123 - - Fixed bug - - Improved performance - + More nice code - * Awesome architecture - -#### Full Changelog -http://example.com/changelog -""" - -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 - - Fixed bug - - Improved performance - + More nice code - * Awesome architecture - - Fixed bug - - Improved performance - -#### Full Changelog -http://example.com/changelog -""" - -# build - - -def test_build_no_data(): - custom_chapters = CustomChapters() - custom_chapters.from_yaml_array(default_chapters) - - expected_release_notes = RELEASE_NOTES_NO_DATA - - builder = DefaultReleaseNotesBuilder( - records={}, # empty record data set - changelog_url=DEFAULT_CHANGELOG_URL, - custom_chapters=custom_chapters, - ) - - actual_release_notes = builder.build() - assert expected_release_notes == actual_release_notes - - -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) - - expected_release_notes = RELEASE_NOTES_NO_DATA_NO_WARNING - - builder = DefaultReleaseNotesBuilder( - records={}, # empty record data set - changelog_url=DEFAULT_CHANGELOG_URL, - custom_chapters=custom_chapters, - ) - - actual_release_notes = builder.build() - assert expected_release_notes == actual_release_notes - - -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) - - expected_release_notes = RELEASE_NOTES_NO_DATA_NO_WARNING_NO_EMPTY_CHAPTERS - - builder = DefaultReleaseNotesBuilder( - records={}, - changelog_url=DEFAULT_CHANGELOG_URL, - custom_chapters=custom_chapters_no_empty_chapters, - ) - - actual_release_notes = builder.build() - assert expected_release_notes == actual_release_notes - - -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) - - expected_release_notes = RELEASE_NOTES_NO_DATA_NO_EMPTY_CHAPTERS - - builder = DefaultReleaseNotesBuilder( - records={}, - changelog_url=DEFAULT_CHANGELOG_URL, - custom_chapters=custom_chapters_no_empty_chapters, - ) - - actual_release_notes = builder.build() - assert expected_release_notes == actual_release_notes - - -# Test cases covering data variations -# --------------------------------------------------------------------------------------------- -# from custom/uer defined chapters -# --------------------------------------------------------------------------------------------- -# Happy paths - see closed issue in used defined chapters -# Test: issue in Closed (1st) state is visible in the release notes - with one label -# "test_name": "test_build_closed_issue_with_one_custom_label", -# "expected_release_notes": release_notes_data_custom_chapters_one_label, -# "records": __get_record_mock_1_issue_with_2_prs(mock_repo(), issue_labels=['bug']) - -# Test: issue in Closed (1st) state is visible in the release notes - with more label - duplicity reduction on -# "test_name": "test_build_closed_issue_with_more_custom_labels_duplicity_reduction_on", -# "expected_release_notes": release_notes_data_custom_chapters_more_labels_duplicity_reduction_on, -# "records": __get_record_mock_1_issue_with_2_prs(mock_repo(), issue_labels=['bug', 'enhancement']) - -# Test: issue in Closed (1st) state is visible in the release notes - with more label - duplicity reduction off -# TODO - switch off duplicity reduction -# "test_name": "test_build_closed_issue_with_more_custom_labels_duplicity_reduction_off", -# "expected_release_notes": release_notes_data_custom_chapters_more_labels_duplicity_reduction_off, -# "records": __get_record_mock_with_2_prs(issue_labels=['bug', 'enhancement']) - -# --------------------------------------------------------------------------------------------- -# from service chapters point of view -# --------------------------------------------------------------------------------------------- -# Happy paths - see closed issue in services chapters -# Test: issue in Closed (1st) - visible in service chapters - without pr and user defined labels - no labels -# "test_name": "test_build_closed_issue_service_chapter_without_pull_request_and_user_defined_label", -# "expected_release_notes": release_notes_data_service_chapters_closed_issue_no_pr_no_user_labels, -# "records": {0: Record(repo=mock_repo(), issue=__get_default_issue_mock(number=1, state="closed"))} - -# Test: pr in merged (1st) state is visible in the release notes service chapters - no labels -# "test_name": "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, -# "records": __get_record_mock_1_pr_with_no_issue(mock_repo()) - -# Test: pr in closed state is visible in the release notes service chapters - no labels -# "test_name": "test_build_merged_pr_service_chapter_without_issue_and_user_labels", -# "expected_release_notes": release_notes_data_service_chapters_closed_pr_no_issue_no_user_labels, -# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), is_merged=False) - -# Test: issue in open state with pr in merged state is visible in the release notes service chapters - no labels -# Reasons: Issue reopened after PR merge, Issue mention added after PR merge. -# "test_name": "test_build_open_issue_with_merged_pr_service_chapter_linked_to_not_closed_issue", -# "expected_release_notes": release_notes_data_service_chapters_open_issue_and_merged_pr_no_user_labels, -# "records": __get_record_mock_1_issue_with_2_prs(mock_repo(), issue_state="open") - -# Test: No Topic service chapter is here to catch unexpected and 'new' data combinations - do not lost them -# --------------------------------------------------------------------------------------------- -# from Issues states point of view -# --------------------------------------------------------------------------------------------- -# Alternative paths - see issue in all states without labels ==> in correct service chapters -# Test: issue in Open (Initial) state is not visible in the release notes - no labels -# "test_name": "test_build_open_issue", -# "expected_release_notes": release_notes_no_data_no_warning_no_empty_chapters, -# "records": {0: Record(Mock(), __get_default_issue_mock(number=1, state="open"))} - -# Test: issue in Open (Reopened) state is not visible in the release notes - no labels -# "test_name": "test_build_reopened_issue", -# "expected_release_notes": release_notes_no_data_no_warning_no_empty_chapters, -# "records": {0: Record(mock_repo(), __get_default_issue_mock(number=1, state="open", state_reason="reopened"))} - -# Test: issue in Closed (1st) state is not visible in the release notes - no labels -# "test_name": "test_build_closed_issue", -# "expected_release_notes": release_notes_data_service_chapters_closed_issue_no_pr_no_user_labels, -# "records": {0: Record(mock_repo(), __get_default_issue_mock(number=1, state="closed"))} - -# Test: issue in Closed (not_planned) state is visible in the release notes - no labels -# "test_name": "test_build_closed_not_planned_issue", -# "expected_release_notes": release_notes_data_service_chapters_closed_issue_no_pr_no_user_labels, -# "records": {0: Record(mock_repo(), -# __get_default_issue_mock(number=1, state="closed", state_reason="not_planned"))} - - -# --------------------------------------------------------------------------------------------- -# Alternative paths - see issue in all logical states ==> in correct service chapters -# Test: Closed Issue without linked PR with user labels ==> not part of custom chapters as there is no merged change -# "test_name": "test_build_closed_issue_with_user_labels_no_prs", -# "expected_release_notes": release_notes_data_closed_issue_no_pr_with_user_labels, -# "records": {0: Record(mock_repo(), -# __get_default_issue_mock(number=1, state="closed", labels=['bug', 'breaking-changes']))} - -# Test: Closed Issue without linked PR without user labels -# - covered in 'test_build_merged_pr_service_chapter_without_issue_and_user_labels' - -# Test: Closed Issue with 1+ merged PRs with 1+ user labels -# - covered in 'test_build_closed_issue_with_more_custom_labels_duplicity_reduction_off' - -# Test: Closed Issue with 1+ merged PRs without user labels -# "test_name": "test_build_closed_issue_with_prs_without_user_label", -# "expected_release_notes": release_notes_data_closed_issue_with_pr_without_user_labels, -# "records": __get_record_mock_1_issue_with_2_prs(mock_repo()) - -# --------------------------------------------------------------------------------------------- -# from PR states point of view -# --------------------------------------------------------------------------------------------- -# Alternative paths - see pull request in all states ==> in correct service chapters -# Test: Open PR without Issue ==> Open PR are ignored as they are not merged - no change to document -# - Note: this should not happen, but if this happens, it will be reported in Others - No Topic chapter -# "test_name": "test_build_open_pr_without_issue", -# "expected_release_notes": release_notes_data_open_pr_without_issue, -# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), state="open") - -# Test: Ready for review - Merged PR (is change in repo) -# "test_name": "test_build_merged_pr_without_issue_ready_for_review", -# "expected_release_notes": release_notes_data_service_chapters_merged_pr_no_issue_no_user_labels, -# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), state="closed") - -# Test: Ready for review - Closed PR (not planned) -# "test_name": "test_build_closed_pr_without_issue_ready_for_review", -# "expected_release_notes": release_notes_data_service_chapters_closed_pr_no_issue_no_user_labels, -# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), state="closed", is_merged=False) - -# Test: Draft - Closed PR (not planned) -# "test_name": "test_build_closed_pr_without_issue_draft", -# "expected_release_notes": release_notes_data_service_chapters_closed_pr_no_issue_no_user_labels, -# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), state="closed", is_merged=False, is_draft=True) - -# --------------------------------------------------------------------------------------------- -# Alternative paths - see pull request in all logical states ==> in correct service chapters -# Test: Merged PR without Issue without user label -# - covered in 'test_build_merged_pr_service_chapter_without_issue_and_user_labels' - -# Test: Merged PR without Issue with more user label - duplicity reduction on -# "test_name": "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, -# "records": __get_record_mock_1_pr_with_no_issue(mock_repo(), labels=['bug', 'enhancement']) - -# Test: Merged PR without Issue with more user label - duplicity reduction off - TODO -# "test_name": "test_merged_pr_without_issue_with_more_user_labels_duplicity_reduction_on", -# "expected_release_notes": release_notes_data_service_chapters_merged_pr_no_issue_no_user_labels, -# "records": __get_record_mock_1_pr_with_no_issue(labels=['bug', 'enhancement']) - -# Test: Merged PR with mentioned Open (Init) Issues | same to Reopen as it is same state -# "test_name": "test_merged_pr_with_open_init_issue_mention", -# "expected_release_notes": release_notes_data_merged_prs_with_open_issues, -# "records": __get_record_mock_2_issue_with_2_prs(mock_repo(), issue_1_state="open", issue_2_state="open") - -# Test: Merged PR with mentioned Closed Issues -# - covered in 'test_build_closed_issue_with_prs_without_user_label' - -# Test: Merged PR with mentioned Closed (not planned) Issues - without user labels -# "test_name": "test_merged_pr_with_closed_issue_mention_without_user_labels", -# "expected_release_notes": release_notes_data_closed_issue_with_merged_prs_without_user_labels, -# "records": __get_record_mock_1_issue_with_2_prs(mock_repo(), issue_state="closed", is_closed_not_planned=True) - -# Test: Merged PR with mentioned Closed (not planned) Issues - with user labels -# "test_name": "test_merged_pr_with_closed_issue_mention_with_user_labels", -# "expected_release_notes": release_notes_data_closed_issue_with_merged_prs_with_user_labels, -# "records": __get_record_mock_1_issue_with_2_prs(mock_repo(), issue_state="closed", is_closed_not_planned=True, -# issue_labels=['bug', 'enhancement']) - -# Test: Merged PR without mentioned Issue -# - covered in 'test_build_merged_pr_service_chapter_without_issue_and_user_labels' - - -def test_build_closed_issue_with_one_custom_label( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_two_pulls, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_closed_issue_with_more_custom_labels_duplicity_reduction_on( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_two_pulls, mocker -): - 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.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_closed_issue_service_chapter_without_pull_request_and_user_defined_label( - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_merged_pr_service_chapter_without_issue_and_user_labels( - custom_chapters_not_print_empty_chapters, pull_request_record_merged, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_closed_pr_service_chapter_without_issue_and_user_labels( - custom_chapters_not_print_empty_chapters, pull_request_record_closed, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_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 -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - changelog_url=DEFAULT_CHANGELOG_URL, - custom_chapters=custom_chapters_not_print_empty_chapters, - ) - - 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 - - -def test_build_reopened_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 - rec.issue.state_reason = "reopened" - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_closed_not_planned_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 - rec.issue.state_reason = "not_planned" - mocker.patch("release_notes_generator.builder.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_closed_issue_with_user_labels_no_prs( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_no_pull, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_closed_issue_with_prs_without_user_label( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_two_pulls, mocker -): - 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.base_builder.ActionInputs.get_print_empty_chapters", return_value=False) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_open_pr_without_issue( - custom_chapters_not_print_empty_chapters, pull_request_record_open, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_merged_pr_without_issue_ready_for_review( - custom_chapters_not_print_empty_chapters, pull_request_record_merged, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_closed_pr_without_issue_ready_for_review( - custom_chapters_not_print_empty_chapters, pull_request_record_closed, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_closed_pr_without_issue_non_draft( - custom_chapters_not_print_empty_chapters, pull_request_record_closed, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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 - - -# TODO - research situation when PR is not merged and is in draft state - -def test_merged_pr_without_issue_with_more_user_labels_duplicity_reduction_on( - custom_chapters_not_print_empty_chapters, pull_request_record_merged, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_merged_pr_with_open_init_issue_mention( - custom_chapters_not_print_empty_chapters, record_with_two_issue_open_two_pulls_closed, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - 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_merged_pr_with_closed_issue_mention_without_user_labels( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_one_pull, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_merged_pr_with_closed_issue_mention_with_user_labels( - custom_chapters_not_print_empty_chapters, record_with_issue_closed_one_pull_merged, mocker -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_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 -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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_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 -): - 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) - - builder = DefaultReleaseNotesBuilder( - records={rec.record_id: rec}, - 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 diff --git a/tests/test_action_inputs.py b/tests/test_action_inputs.py index 8eb2373c..88d213a3 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,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_regime", "not_supported", "Regime 'not_supported' is not supported."), + ("get_hierarchy", "not_bool", "Hierarchy must be a boolean."), ] @@ -265,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/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 1103f65f..5b9ae70b 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 @@ -59,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) @@ -66,12 +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) - 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 + 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() @@ -101,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) @@ -112,7 +115,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 @@ -123,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() @@ -152,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) @@ -164,12 +170,13 @@ 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 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() diff --git a/tests/utils/test_pull_reuqest_utils.py b/tests/utils/test_pull_request_utils.py similarity index 97% rename from tests/utils/test_pull_reuqest_utils.py rename to tests/utils/test_pull_request_utils.py index 135a7883..c183d54b 100644 --- a/tests/utils/test_pull_reuqest_utils.py +++ b/tests/utils/test_pull_request_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]