diff --git a/.github/workflows/release_notes_comments_migration.yml b/.github/workflows/release_notes_comments_migration.yml deleted file mode 100644 index 61103777..00000000 --- a/.github/workflows/release_notes_comments_migration.yml +++ /dev/null @@ -1,110 +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. -# - -name: Copy Release Notes to Related Issues - -on: - pull_request: - types: [closed] - branches: [ master ] - -jobs: - copy_release_notes: - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - steps: - - name: Fetch PR Comments - id: get-comments - uses: actions/github-script@v7 - with: - github-token: ${{secrets.GITHUB_TOKEN}} - script: | - const prNumber = context.payload.pull_request.number; - const repoName = context.repo.repo; - const repoOwner = context.repo.owner; - const releaseNotesRegex = /release notes/i; - - const comments = await github.rest.issues.listComments({ - owner: repoOwner, - repo: repoName, - issue_number: prNumber, - }); - - const releaseNoteComment = comments.data.find(comment => releaseNotesRegex.test(comment.body)); - const releaseNoteBody = releaseNoteComment ? releaseNoteComment.body : ''; - console.log(`Release Note Body: ${releaseNoteBody}`); - core.setOutput('releaseNoteBody', releaseNoteBody); - - - name: Print Extracted releaseNoteBody - run: | - echo "Extracted Release Note Body:" - echo "${{ steps.get-comments.outputs.releaseNoteBody }}" - echo "RELEASE_NOTE_BODY<> $GITHUB_ENV - echo "${{ steps.get-comments.outputs.releaseNoteBody }}" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - - name: Parse PR Description for Related Issues - id: find-issues - uses: actions/github-script@v7 - with: - github-token: ${{secrets.GITHUB_TOKEN}} - script: | - const description = context.payload.pull_request.body; - const issueNumbers = []; - const regexPattern = /([Cc]los(e|es|ed)|[Ff]ix(es|ed)?|[Rr]esolv(e|es|ed))\s*#\s*([0-9]+)/g; - - let match; - while ((match = regexPattern.exec(description)) !== null) { - // This is necessary to avoid infinite loops with zero-width matches - if (match.index === regexPattern.lastIndex) { - regexPattern.lastIndex++; - } - - // The actual issue number is in the last group of the match - const issueNumber = match[match.length - 1]; - if (issueNumber) { - issueNumbers.push(issueNumber); - } - } - - core.setOutput('issueNumbers', issueNumbers.join(', ')); - - - name: Print Extracted Issue Numbers - run: | - echo "Extracted Issue Numbers: ${{ steps.find-issues.outputs.issueNumbers }}" - echo "ISSUE_NUMBERS=${{ steps.find-issues.outputs.issueNumbers }}" >> $GITHUB_ENV - - - name: Post Comment to Issues - if: ${{ steps.get-comments.outputs.releaseNoteBody }} && ${{ steps.find-issues.outputs.issueNumbers }} - uses: actions/github-script@v7 - with: - github-token: ${{secrets.GITHUB_TOKEN}} - script: | - const issueNumbers = process.env.ISSUE_NUMBERS; - const commentBody = process.env.RELEASE_NOTE_BODY; - const repoName = context.repo.repo; - const repoOwner = context.repo.owner; - - for (const issueNumber of issueNumbers.split(', ')) { - if (issueNumber && commentBody) { - await github.rest.issues.createComment({ - owner: repoOwner, - repo: repoName, - issue_number: issueNumber, - body: commentBody - }); - } - } diff --git a/.pylintrc b/.pylintrc index 67b06997..714ca34f 100644 --- a/.pylintrc +++ b/.pylintrc @@ -115,6 +115,9 @@ unsafe-load-any-extension=no # In verbose mode, extra non-checker-related info will be displayed. #verbose= +[MASTER] + +ignore-paths=tests [BASIC] diff --git a/README.md b/README.md index 0ed47c7e..dfa5a275 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,13 @@ - [Usage Example](#usage-example) - [Features](#features) - [Built-in](#built-in) - - [Release Notes Extraction Process](#release-notes-extraction-process) + - [Release Notes Support](#release-notes-support) + - [Handling Issue Mentioned By Multiple PRs](#handling-issue-mentioned-by-multiple-prs) + - [No Release Notes Found](#no-release-notes-found) + - [Issue, Pull Request or Commit Row formatting](#issue-pull-request-or-commit-row-formatting) + - [Supported row format keywords](#supported-row-format-keywords) - [Contributors Mention](#contributors-mention) - - [Handling Multiple PRs](#handling-multiple-prs) + - [Handling Issue Mentioned By Multiple PRs](#handling-issue-mentioned-by-multiple-prs) - [No Release Notes Found](#no-release-notes-found) - [Select start date for closed issues and PRs](#select-start-date-for-closed-issues-and-prs) - [Enable skipping of release notes for specific issues using label](#enable-skipping-of-release-notes-for-specific-issues-using-label) @@ -54,12 +58,12 @@ Generate Release Notes action is dedicated to enhance the quality and organizati ### `row-format-issue` - **Description**: 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. - **Required**: No -- **Default**: `#{number} _{title}_ in {pull-requests}"` +- **Default**: `#{number} _{title}_ {pull-requests} {assignee} {implemented-by} {contributed-by}"` ### `row-format-pr` - **Description**: The format of the row for the PR in the release notes. The format can contain placeholders for the PR `number`, `title`, and PR `pull-requests`. The placeholders are case-sensitive. - **Required**: No -- **Default**: `#{number} _{title}_"` +- **Default**: `#{number} _{title}_ {assignee} {implemented-by} {contributed-by}` ### `row-format-link-pr` - **Description**: If defined `true`, the PR row will begin with a `"PR: "` string. Otherwise, no prefix will be added. @@ -104,17 +108,35 @@ Generate Release Notes action is dedicated to enhance the quality and organizati - **Required**: No - **Default**: true (Empty chapters are printed.) -### `chapters-to-pr-without-issue` -- **Description**: Set it to false to avoid the application of custom chapters for PRs without linked issues. -- **Required**: No -- **Default**: true (Custom chapters are applied to PRs without linked issues.) - ## 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. See the [example of output](./examples/output_example.md). +### Supported Row Types +#### Issue Row +An issue row may have multiple pull requests linked to it. These pull requests are associated using GitHub-supported keywords like closes, fixes, or resolves. + +**Example** +- #33 _Example bugfix_ in [#44](https://github.com/absa-group/living-doc-example-project/pull/44), [#36](https://github.com/absa-group/living-doc-example-project/pull/36), [#35](https://github.com/absa-group/living-doc-example-project/pull/35), [#34](https://github.com/absa-group/living-doc-example-project/pull/34) assigned to @miroslavpojer developed by @miroslavpojer co-authored by Saša Zejnilović + - Another solved typos. Hello from second RLS notes comment. + - Solved some typos. + +#### Pull Request Row +A pull request row represents one PR made against the repository. This pull request does not mention any issues. + +**Example** +- PR: #41 _Initial commit._ assigned to @miroslavpojer developed by @miroslavpojer + - Test release notes nr1 + - Test release notes nr2 + +#### Direct Commit Row +A direct commit row represents a commit that is not tied to any pull request or issue. This commit is not associated with any pull request. + +**Example** +- Commit: fbe8e558f914cd58d8e7aab8c7d0c77f934aa707 developed by @miroslavpojer + ## Usage Example ### Prerequisites @@ -165,37 +187,67 @@ Add the following step to your GitHub workflow (in example are used non-default warnings: false print-empty-chapters: false - chapters-to-pr-without-issue: false + row-format-issue: '#{number} _{title}_ {pull-requests} {assignee} {developed-by} {co-authored-by}' + row-format-pr: '#{number} _{title}_ {assignee} {developed-by} {co-authored-by}' + row-format-link-pr: true ``` ## Features ### Built-in -#### Release Notes Extraction Process - -This action requires that your GitHub issues include comments with specific release notes. Here's how it works: +#### Release Notes Support +This action enables GitHub pull requests to include a dedicated section for release notes in its description, making it easier for maintainers to track changes and updates. -**Extraction Method**: -- The action scans through comments on each closed issue since the last release. It identifies comments that follow the specified format and extracts the content as part of the release notes. -- The time considered for the previous release is based on its creation time. This means that the action will look for issues closed after the creation time of the most recent release to ensure that all relevant updates since that release are included. - -**Comment Format** -- For an issue's contributions to be included in the release notes, it must contain a comment starting with "Release Notes" followed by the note content. This comment is typically added by the contributors. -- Here is an example of the content for a 'Release Notes' string, which is not case-sensitive: +- **Format:** The section must begin with the title `Release Notes:`, followed by the release notes in bullet points. +- **Example:** Here is an example of how to structure the release notes (case-sensitive): ``` -Release Notes +Release Notes: - This update introduces a new caching mechanism that improves performance by 20%. ``` -- Using `-` as a bullet point for each note is the best practice. The Markdown parser will automatically convert it to a list. -- These comments are not required for action functionality. If an issue does not contain a "Release Notes" comment, it will be marked accordingly in the release notes. This helps maintainers quickly identify which issues need attention for documentation. +- **Best Practice:** Use `-` for bullet points. The Markdown parser will automatically format them as a list. +- **Optional:** Including release notes is not mandatory for the action of this GH action. -#### Contributors Mention -Along with the release note content, the action also gathers a list of contributors for each issue. This includes issue assignees and authors of linked pull requests' commits, providing acknowledgment for their contributions in the release notes. +The action scans pull request descriptions for the `Release Notes:` section and extracts any content that follows the specified format. -#### Handling Multiple PRs -If an issue is linked to multiple PRs, the action fetches and aggregates contributions from all linked PRs. +#### Handling Issue Mentioned By Multiple PRs +If an issue is linked from multiple PRs, the action fetches and aggregates developers and contributions from all linked PRs. #### No Release Notes Found -If no valid "Release Notes" comment is found in an issue, it will be marked accordingly. This helps maintainers quickly identify which issues need attention for documentation. +If no valid `Release Notes:` section is found in a pull request description, it will be mentioned in dedicated service chapters. This helps maintainers quickly identify which pull request need attention for documentation. + +#### Issue, Pull Request or Commit Row formatting +Format of the different row types can be customized. The placeholders are case-sensitive. Each row type supports different set of keywords. + +##### Supported row format keywords +- **Issue & Pull Request** + - `{number}`: + - Issue or PR number. + - `{title}`: + - Issue or PR title. + - `{pull-requests}`: + - List of PRs linked to the issue. Adds a list of PRs linked to the issue in the row with `in` prefix: + - _Example:_ `#{number} _{title}_ {pull-requests}` => "[#43]() _title_ in [#PR1](), [#PR2](), [#PR3]()" + - Not used in PR row format. Pull Request type already define single PR. + - `{assingee}`: + - Issue or PR assignee. Adds a login of assignees in the row with `assigned to` prefix: + - `#{number} _{title}_ {assignee}` => "[#43]() _title_ implemented by @login1" + - `{assignees}`: + - Issue or PR assignees. Adds a list of assignees logins in the row with `assigned to` prefix: + - `#{number} _{title}_ {assignees}` => "[#43]() _title_ implemented by @login1, @login2" + - This is alternative representation of multiple assignees provided by GitHub. + - `{developed-by}`: + - List of PR developer(s) login(s). Adds a login of commit authors for PR(s) in the row with `developed by` prefix: + - `#{number} _{title}_ {developed-by}` => "[#43]() _title_ developed by @login1" + - `{co-authored-by}`: + - List of PR contributors. Adds a login of contributors in the row with `co-authored by` prefix: + - `#{number} _{title}_ {co-authored-by}` => "[#43]() _title_ co-authored by @login1 @login2" + - Co-authors are detected in PR commit messages by detection of GitHub supported trailer `Co-authored-by`. +- **Commit** + - `{sha}`: + - Commit SHA. + - `{author}`: + - Commit author login. + - `{co-authors}`: + - List of commit contributors. Co-authors are detected in commit messages by detection of GitHub supported trailer `Co-authored-by`. ### Select start date for closed issues and PRs By set **published-at** to true the action will use the `published-at` timestamp of the latest release as the reference point for searching closed issues and PRs, instead of the `created-at` date. If first release, repository creation date is used. @@ -350,6 +402,12 @@ pytest tests/ This will execute all tests located in the tests directory and generate a code coverage report. +TODO: add another example for partial run +pytest tests/release_notes_generator/utils/test_utils.py + +debug only - how to run measuremnt on isolated test set +pytest --cov=. tests/release_notes_generator/utils/test_utils.py --cov-fail-under=80 --cov-report=html + ## Code Coverage Code coverage is collected using pytest-cov coverage tool. To run the tests and collect coverage information, use the following command: @@ -358,6 +416,8 @@ Code coverage is collected using pytest-cov coverage tool. To run the tests and pytest --cov=release_notes_generator --cov-report html tests/ ``` + + See the coverage report on the path: ``` @@ -382,7 +442,6 @@ export INPUT_WARNINGS="true" export INPUT_PUBLISHED_AT="true" export INPUT_SKIP_RELEASE_NOTES_LABEL="ignore-in-release" export INPUT_PRINT_EMPTY_CHAPTERS="true" -export INPUT_CHAPTERS_TO_PR_WITHOUT_ISSUE="true" export INPUT_VERBOSE="true" # CI in-build variables diff --git a/action.yml b/action.yml index c531a99a..3492062e 100644 --- a/action.yml +++ b/action.yml @@ -47,26 +47,30 @@ inputs: description: 'Print chapters even if they are empty.' required: false default: 'true' - chapters-to-pr-without-issue: - description: 'Apply custom chapters for PRs without linked issues.' - required: false - default: 'true' verbose: description: 'Print verbose logs.' required: false default: 'false' row-format-issue: - description: 'Format of the issue row in the release notes. Available placeholders: {link}, {title}, {pull-requests}. Placeholders are case-insensitive.' + description: 'Format of the issue row in the release notes. Available placeholders: {number}, {title}, {pull-requests}, {assignee}, {assignees}, {developed-by}, {co-authored-by}. Placeholders are case-insensitive.' required: false - default: '#{number} _{title}_ in {pull-requests}' + default: '#{number} _{title}_ {pull-requests} {assignee} {developed-by} {co-authored-by}' row-format-pr: - description: 'Format of the pr row in the release notes. Available placeholders: {link}, {title}, {pull-requests}. Placeholders are case-insensitive.' + description: 'Format of the pr row in the release notes. Available placeholders: {number}, {title}, {assignee}, {assignees}, {developed-by}, {co-authored-by}. Placeholders are case-insensitive.' + required: false + default: '#{number} _{title}_ {assignee} {developed-by} {co-authored-by}' + row-format-commit: + description: 'Format of the commit row in the release notes. Available placeholders: {sha}, {author}, {co-authored-by}. Placeholders are case-insensitive.' required: false - default: '#{number} _{title}_' + default: '{sha} {author} {co-authored-by}' row-format-link-pr: description: 'Add prefix "PR:" before link to PR when not linked an Issue.' required: false default: 'true' + row-format-link-commit: + description: 'Add prefix "Commit:" before link to direct commit.' + required: false + default: 'true' outputs: release-notes: @@ -118,12 +122,13 @@ runs: INPUT_PUBLISHED_AT: ${{ inputs.published-at }} INPUT_SKIP_RELEASE_NOTES_LABEL: ${{ inputs.skip-release-notes-label }} INPUT_PRINT_EMPTY_CHAPTERS: ${{ inputs.print-empty-chapters }} - INPUT_CHAPTERS_TO_PR_WITHOUT_ISSUE: ${{ inputs.chapters-to-pr-without-issue }} INPUT_VERBOSE: ${{ inputs.verbose }} INPUT_GITHUB_REPOSITORY: ${{ github.repository }} INPUT_ROW_FORMAT_ISSUE: ${{ inputs.row-format-issue }} INPUT_ROW_FORMAT_PR: ${{ inputs.row-format-pr }} + INPUT_ROW_FORMAT_COMMIT: ${{ inputs.row-format-commit }} INPUT_ROW_FORMAT_LINK_PR: ${{ inputs.row-format-link-pr }} + INPUT_ROW_FORMAT_LINK_COMMIT: ${{ inputs.row-format-link-commit }} run: | python ${{ github.action_path }}/main.py shell: bash diff --git a/examples/output_example.md b/examples/output_example.md index 57f05119..d3ee59e3 100644 --- a/examples/output_example.md +++ b/examples/output_example.md @@ -2,75 +2,193 @@ No entries detected. ### New Features 🎉 -- #22 _REQ: Submit Reviews_ in [#31](https://github.com/company/test-project/pull/31) - - Now only book purchasers can submit reviews, with mandatory text and star ratings. -- #52 _Add Tag into Release Draft_ in [#59](https://github.com/company/test-project/pull/59), [#58](https://github.com/company/test-project/pull/58), [#57](https://github.com/company/test-project/pull/57), [#56](https://github.com/company/test-project/pull/56), [#55](https://github.com/company/test-project/pull/55), [#54](https://github.com/company/test-project/pull/54), [#53](https://github.com/company/test-project/pull/53) -- #82 _Create tag after success RLS notes generation_ in [#87](https://github.com/company/test-project/pull/87), [#86](https://github.com/company/test-project/pull/86), [#85](https://github.com/company/test-project/pull/85), [#84](https://github.com/company/test-project/pull/84), [#83](https://github.com/company/test-project/pull/83) +- #22 _REQ: Submit Reviews_ in [#31](https://github.com/absa-group/living-doc-example-project/pull/31) developed by @miroslavpojer + - Now only book purchasers can submit reviews, with mandatory text and star ratings. +- #52 _Add Tag into Release Draft_ in [#59](https://github.com/absa-group/living-doc-example-project/pull/59), [#58](https://github.com/absa-group/living-doc-example-project/pull/58), [#57](https://github.com/absa-group/living-doc-example-project/pull/57), [#56](https://github.com/absa-group/living-doc-example-project/pull/56), [#55](https://github.com/absa-group/living-doc-example-project/pull/55), [#54](https://github.com/absa-group/living-doc-example-project/pull/54), [#53](https://github.com/absa-group/living-doc-example-project/pull/53) assigned to @miroslavpojer developed by @miroslavpojer +- #82 _Create tag after success RLS notes generation_ in [#87](https://github.com/absa-group/living-doc-example-project/pull/87), [#86](https://github.com/absa-group/living-doc-example-project/pull/86), [#85](https://github.com/absa-group/living-doc-example-project/pull/85), [#84](https://github.com/absa-group/living-doc-example-project/pull/84), [#83](https://github.com/absa-group/living-doc-example-project/pull/83) assigned to @miroslavpojer developed by @miroslavpojer ### Bugfixes 🛠 -- #33 _Example bugfix_ in [#44](https://github.com/company/test-project/pull/44), [#36](https://github.com/company/test-project/pull/36), [#35](https://github.com/company/test-project/pull/35), [#34](https://github.com/company/test-project/pull/34) - - Another solved typos. Hello from second RLS notes comment. - - Solved some typos. -- PR: #41 _Initial commit._ - - Test release notes nr1 - - Test release notes nr2 +- #33 _Example bugfix_ in [#44](https://github.com/absa-group/living-doc-example-project/pull/44), [#36](https://github.com/absa-group/living-doc-example-project/pull/36), [#35](https://github.com/absa-group/living-doc-example-project/pull/35), [#34](https://github.com/absa-group/living-doc-example-project/pull/34) assigned to @miroslavpojer developed by @miroslavpojer co-authored by Saša Zejnilović + - Another solved typos. Hello from second RLS notes comment. + - Solved some typos. +- PR: #41 _Initial commit._ assigned to @miroslavpojer developed by @miroslavpojer + - Test release notes nr1 + - Test release notes nr2 ### Closed Issues without Pull Request ⚠️ -- #3 _FEAT: User Authentication_ -- #4 _FEAT: Book Browsing_ -- #6 _FEAT: Shopping Cart_ +- #3 _FEAT: User Authentication_ assigned to @miroslavpojer +- #4 _FEAT: Book Browsing_ assigned to @miroslavpojer +- #6 _FEAT: Shopping Cart_ assigned to @miroslavpojer - #37 _Example Issue without PR_ - #38 _Example Issue without Release notes comment_ - #88 _Test issue_ ### Closed Issues without User Defined Labels ⚠️ -- #1 _Initial version of project_ in [#2](https://github.com/company/test-project/pull/2) -- #7 _REQ: User Login Functionality_ in [#13](https://github.com/company/test-project/pull/13) -- #8 _REQ: User Registration Functionality_ in [#13](https://github.com/company/test-project/pull/13) -- #9 _REQ: View Book List_ in [#14](https://github.com/company/test-project/pull/14) -- #10 _REQ: Detailed Book Information_ in [#14](https://github.com/company/test-project/pull/14) -- #11 _REQ: Adding Books to Shopping Cart_ in [#15](https://github.com/company/test-project/pull/15) -- #12 _REQ: Viewing Shopping Cart Contents_ in [#15](https://github.com/company/test-project/pull/15) -- #23 _REQ: View Reviews_ in [#27](https://github.com/company/test-project/pull/27) -- #29 _Introduce workflow logic for Release notes_ in [#28](https://github.com/company/test-project/pull/28) -- #30 _Introduce Release notes logic_ in [#32](https://github.com/company/test-project/pull/32) +- #1 _Initial version of project_ in [#2](https://github.com/absa-group/living-doc-example-project/pull/2) assigned to @miroslavpojer developed by @miroslavpojer +- #7 _REQ: User Login Functionality_ in [#13](https://github.com/absa-group/living-doc-example-project/pull/13) assigned to @miroslavpojer developed by @miroslavpojer +- #8 _REQ: User Registration Functionality_ in [#13](https://github.com/absa-group/living-doc-example-project/pull/13) assigned to @miroslavpojer developed by @miroslavpojer +- #9 _REQ: View Book List_ in [#14](https://github.com/absa-group/living-doc-example-project/pull/14) assigned to @miroslavpojer developed by @miroslavpojer +- #10 _REQ: Detailed Book Information_ in [#14](https://github.com/absa-group/living-doc-example-project/pull/14) assigned to @miroslavpojer developed by @miroslavpojer +- #11 _REQ: Adding Books to Shopping Cart_ in [#15](https://github.com/absa-group/living-doc-example-project/pull/15) assigned to @miroslavpojer developed by @miroslavpojer +- #12 _REQ: Viewing Shopping Cart Contents_ in [#15](https://github.com/absa-group/living-doc-example-project/pull/15) assigned to @miroslavpojer developed by @miroslavpojer +- #23 _REQ: View Reviews_ in [#27](https://github.com/absa-group/living-doc-example-project/pull/27) developed by @miroslavpojer +- #29 _Introduce workflow logic for Release notes_ in [#28](https://github.com/absa-group/living-doc-example-project/pull/28) assigned to @miroslavpojer developed by @miroslavpojer +- #30 _Introduce Release notes logic_ in [#32](https://github.com/absa-group/living-doc-example-project/pull/32) assigned to @miroslavpojer developed by @miroslavpojer ### Merged PRs without Issue and User Defined Labels ⚠️ -- PR: #5 _BugFix - correct Issue GH folder location_ -- PR: #16 _repository improvement_ -- PR: #26 _Initial test headers_ -- PR: #39 _Initial commit._ -- PR: #40 _Initial commit._ -- PR: #42 _Initial commit._ -- PR: #43 _Feature/new tag_ -- PR: #45 _Initial commit._ -- PR: #46 _Revert "- Improved README.md (#36)"_ -- PR: #47 _- Added code for received tag format and correct version increase._ -- PR: #48 _Update of tag checks._ -- PR: #49 _Feature/tag checks update_ -- PR: #50 _Feature/tag checks update_ -- PR: #51 _Feature/tag checks update_ -- PR: #61 _New check implemented._ -- PR: #62 _Feature/add first tag check_ -- PR: #63 _New check implemented._ -- PR: #64 _Experiment with improving release worklflows._ -- PR: #66 _- Prepared workflow for RLS notes generation testing._ +All merged PRs are linked to issues. ### Closed PRs without Issue and User Defined Labels ⚠️ -- PR: #60 _Test change to test close of PR instead of Merge._ -- PR: #65 _Fake change in PR to get PR._ -- PR: #92 _Fake change._ +All closed PRs are linked to issues. ### Merged PRs Linked to 'Not Closed' Issue ⚠️ -- #20 _REQ: Search by Keywords_ in [#44](https://github.com/company/test-project/pull/44) -- 🔔 #33 _Example bugfix_ in [#44](https://github.com/company/test-project/pull/44), [#36](https://github.com/company/test-project/pull/36), [#35](https://github.com/company/test-project/pull/35), [#34](https://github.com/company/test-project/pull/34) +- PR: #5 _BugFix - correct Issue GH folder location_ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #16 _repository improvement_ assigned to @miroslavpojer developed by @miroslavpojer +- #20 _REQ: Search by Keywords_ in [#44](https://github.com/absa-group/living-doc-example-project/pull/44) assigned to @miroslavpojer developed by @miroslavpojer +- PR: #26 _Initial test headers_ assigned to @miroslavpojer developed by @miroslavpojer +- 🔔 #33 _Example bugfix_ in [#44](https://github.com/absa-group/living-doc-example-project/pull/44), [#36](https://github.com/absa-group/living-doc-example-project/pull/36), [#35](https://github.com/absa-group/living-doc-example-project/pull/35), [#34](https://github.com/absa-group/living-doc-example-project/pull/34) assigned to @miroslavpojer developed by @miroslavpojer co-authored by Saša Zejnilović - Another solved typos. Hello from second RLS notes comment. - Solved some typos. -- PR: #80 _Feature/multiline excludes_ -- #81 _Test multiline excludes in filename inspector related yml_ in [#79](https://github.com/company/test-project/pull/79), [#78](https://github.com/company/test-project/pull/78), [#77](https://github.com/company/test-project/pull/77), [#76](https://github.com/company/test-project/pull/76), [#75](https://github.com/company/test-project/pull/75), [#74](https://github.com/company/test-project/pull/74), [#73](https://github.com/company/test-project/pull/73), [#72](https://github.com/company/test-project/pull/72), [#71](https://github.com/company/test-project/pull/71), [#70](https://github.com/company/test-project/pull/70), [#69](https://github.com/company/test-project/pull/69), [#68](https://github.com/company/test-project/pull/68), [#67](https://github.com/company/test-project/pull/67) +- PR: #39 _Initial commit._ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #40 _Initial commit._ assigned to @miroslavpojer developed by @miroslavpojer +- 🔔 PR: #41 _Initial commit._ assigned to @miroslavpojer developed by @miroslavpojer + - Test release notes nr1 + - Test release notes nr2 +- PR: #42 _Initial commit._ developed by @miroslavpojer +- PR: #43 _Feature/new tag_ developed by @miroslavpojer +- PR: #45 _Initial commit._ developed by @miroslavpojer +- PR: #46 _Revert "- Improved README.md (#36)"_ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #47 _- Added code for received tag format and correct version increase._ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #48 _Update of tag checks._ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #49 _Feature/tag checks update_ developed by @miroslavpojer +- PR: #50 _Feature/tag checks update_ developed by @miroslavpojer +- PR: #51 _Feature/tag checks update_ developed by @miroslavpojer +- PR: #61 _New check implemented._ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #62 _Feature/add first tag check_ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #63 _New check implemented._ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #64 _Experiment with improving release worklflows._ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #66 _- Prepared workflow for RLS notes generation testing._ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #80 _Feature/multiline excludes_ developed by @miroslavpojer +- #81 _Test multiline excludes in filename inspector related yml_ in [#79](https://github.com/absa-group/living-doc-example-project/pull/79), [#78](https://github.com/absa-group/living-doc-example-project/pull/78), [#77](https://github.com/absa-group/living-doc-example-project/pull/77), [#76](https://github.com/absa-group/living-doc-example-project/pull/76), [#75](https://github.com/absa-group/living-doc-example-project/pull/75), [#74](https://github.com/absa-group/living-doc-example-project/pull/74), [#73](https://github.com/absa-group/living-doc-example-project/pull/73), [#72](https://github.com/absa-group/living-doc-example-project/pull/72), [#71](https://github.com/absa-group/living-doc-example-project/pull/71), [#70](https://github.com/absa-group/living-doc-example-project/pull/70), [#69](https://github.com/absa-group/living-doc-example-project/pull/69), [#68](https://github.com/absa-group/living-doc-example-project/pull/68), [#67](https://github.com/absa-group/living-doc-example-project/pull/67) developed by @miroslavpojer + +### Isolated commits without Issue or PR ⚠️ +- Commit: 0079dab11f408346aeb755fd8feda74692798b70 developed by @MobiTikula +- Commit: 02d4c4942ea5b84f0772c30d7eb1656584e711d5 developed by @miroslavpojer +- Commit: 07e7d0b4c55ae77e76a25a09a6cffa0dd4ba64a0 developed by @miroslavpojer +- Commit: 1133a080f5b20f7233b1c581d8bed05ed8004bda developed by @MobiTikula +- Commit: 1245a0e1aac79d2f829b8c830cbf013cea2ee377 developed by @miroslavpojer +- Commit: 15e21c60c75dbcdf3c450e2d527f970ec58b13dd developed by @miroslavpojer +- Commit: 1ab371b180aa256d97ce083ae718ea00d051d1a0 developed by @miroslavpojer +- Commit: 1d9893e931a4fa4bcf14ddbdc769a61ee849f324 developed by @miroslavpojer +- Commit: 1ea2b6c56e3748a2263a04dd4ddfeddd1bcf950a developed by @miroslavpojer +- Commit: 2474c94397a01d9c415d36a81f00c968cecc8f7e developed by @miroslavpojer +- Commit: 24e3341c32700dce7087ac90eb67e50686e37bdf developed by @miroslavpojer +- Commit: 265b572f822e298b25a1e5f3b15f8bb687b28689 developed by @MobiTikula +- Commit: 28077755712474bcdef706b843e29225e9d62fa6 developed by @miroslavpojer +- Commit: 2b04213354bf6fefe0ed2014fc37efe25fac219d developed by @miroslavpojer +- Commit: 30b29bd8e349b1a3dfe987c73077ffa6062143d3 developed by @miroslavpojer +- Commit: 31d0f476a4261bc5e95aa9199550b7f34e28ce2f developed by @miroslavpojer +- Commit: 359fa1837cf50a59478c1baa1813ba8dc6c85a02 developed by @MobiTikula +- Commit: 372af6befcd4322a8373546ac7d2d2ada2df3239 developed by @miroslavpojer +- Commit: 37433594e49caac4b6d0be04717159af6524e9ae developed by @miroslavpojer +- Commit: 388a86018dfebff2a2a600c35ed4465ff46b6513 developed by @miroslavpojer +- Commit: 39262f97c18c2ea6e92840f3f391f9b9f21fbee9 developed by @MobiTikula +- Commit: 3acab3020b87add18536c0540930e14fea8735a5 developed by @miroslavpojer +- Commit: 3e2962b2a47c5e0b6c158919917e34ec030fbe46 developed by @miroslavpojer +- Commit: 431948ff52f2b3b2cd02937e37ce12db206ed409 developed by @miroslavpojer +- Commit: 4376c86428544a078cb9f4d4e94d5e04dffc90f2 developed by @miroslavpojer +- Commit: 45a5167d5ed8c9eb55d7e46fba59538984cea9ab developed by @miroslavpojer +- Commit: 45a6c4cf9ac216cb33283d266230a05ac5515f56 developed by @miroslavpojer +- Commit: 473435ef03bd134d313be99cce33230c94f121df developed by @MobiTikula +- Commit: 51515b9a7511a6fc9730381f084c1b3825c42a9c developed by @miroslavpojer +- Commit: 54c1701c6ec362a12ac410712f38a1b7c3e43a6f developed by @miroslavpojer +- Commit: 55573e7c2c735748f15ee3047715a3a8090c82f7 developed by @miroslavpojer +- Commit: 5773353f27a19b70023f441e0dfad0c521a61b2c developed by @MobiTikula +- Commit: 5af8b6541b5e7fc363b9c82680ab3ed5a80b9d00 developed by @miroslavpojer +- Commit: 5d56fb0093a55f0ac7be00b062b5d7435f2f7149 developed by @miroslavpojer +- Commit: 5e0819b024848ea47c816216d4d2611bf34325b4 developed by @miroslavpojer co-authored by @Zejnilovic +- Commit: 626c834012b9ff03be19ed136360fab156a39820 developed by @miroslavpojer +- Commit: 63a7c3649501e018e9058a212ec4ff3a7fb93f0f developed by @miroslavpojer +- Commit: 652257c0b0d6731cabb01d9a5250ccd705acf797 developed by @MobiTikula +- Commit: 6621a866272ecc11ccbc076932a2f4cf66c300f6 developed by @miroslavpojer +- Commit: 66c7ac96b6ac00cad18e0625325ef01e854c0ed4 developed by @miroslavpojer +- Commit: 6af306a84587e07279488c015a627524e914654f developed by @miroslavpojer +- Commit: 6d591b5a66520cc63454782d361ac2206049ad97 developed by @miroslavpojer +- Commit: 732e0d88b189753bae3107208bca15896f43ba91 developed by @miroslavpojer +- Commit: 73e53e7de0e1d32ea71f869c55974a4a4382928e developed by @miroslavpojer +- Commit: 793b152c85aadbfe1d629843be6ec13172781616 developed by @miroslavpojer +- Commit: 7b561dfd2c6aa10b35e9a0da742ec892c453fa5b developed by @miroslavpojer +- Commit: 7cf308c9fb871ca04007c86caebb417deff62fb2 developed by @miroslavpojer +- Commit: 7e2869e4af6f0c923dc94b32818caf0fb996ab3b developed by @miroslavpojer +- Commit: 7ef6ff7bf9ec287015a2089cb492df821cdc89bc developed by @miroslavpojer +- Commit: 8026c42ec13fe041a32497949d7547e3776f9a0c developed by @miroslavpojer +- Commit: 8329068487c8411405ae26283845d7da372229f0 developed by @MobiTikula +- Commit: 8bee957dad3e6b8292947fe0048c955031bab48c developed by @miroslavpojer +- Commit: 8cc758fdfd78c05182a607ae6766267a513a3b1b developed by @miroslavpojer +- Commit: 8dbfbd02cb95566cecef5d634d1ed3d011c3ebb7 developed by @MobiTikula +- Commit: 91a2f977cfe62673f884b8c3ea7d1c5676278c05 developed by @miroslavpojer +- Commit: 937431a9115c62e58a2632436a6a5e7da597602e developed by @MobiTikula +- Commit: 95e4b7511bd31f087adb237ca5295be4192f16a2 developed by @miroslavpojer +- Commit: 97b9045f27e5c2bc4c6ec6d8568753fb86576f3c developed by @miroslavpojer +- Commit: 99a5832f6352271f0b3c957dd3ff0659be9b7f9a developed by @miroslavpojer +- Commit: a88317e4a1e4d71e9833730596d221372e250601 developed by @miroslavpojer +- Commit: a9de37fb1ce57a347d6bd48f28822a4ba151449e developed by @miroslavpojer +- Commit: ab08ab8fb88ad9b512c6af229f45fbd2f5ccd59c developed by @miroslavpojer +- Commit: ac73f8a8df0c5daecaf3ab1bb12714c314bc3adc developed by @miroslavpojer +- Commit: ad133333efa1804c9985dd5a74841edba545050a developed by @miroslavpojer +- Commit: adcbb15e28a894f0164f46f013ccbdbb5fb332f0 developed by @MobiTikula +- Commit: b02f55063ed92817f82845cbd6ebdd3ff3de831c developed by @MobiTikula +- Commit: b0d0156d1667e8f54f8905f3f86d96a0e5d24570 developed by @miroslavpojer +- Commit: b452951e76bfd4c3aa75afbc5adce220d6bb75e3 developed by @miroslavpojer +- Commit: b56d5081a9abf6936fe6cd9f152c519681b7f8a0 developed by @miroslavpojer +- Commit: b88b26e3e6c1564e147e67245d273f302d09dcfb developed by @MobiTikula +- Commit: bb182b9c265e42362c463867701072fe6323aca5 developed by @miroslavpojer +- Commit: bbfaba8537f8653726a09605f4f07eb2deb395da developed by @MobiTikula +- Commit: bc4b15b27680ee87c0e6ad8a4c3ea4903bc57c7e developed by @miroslavpojer +- Commit: be301dae3750fd22de8e71e50e687d67c8586457 developed by @miroslavpojer +- Commit: bf6fbe45fd2d8f4c38fa4e1d43d8c694255e6c69 developed by @miroslavpojer +- Commit: c005044299b3dcf8431e007d50fc7e8ea416edf1 developed by @miroslavpojer +- Commit: c1e777634ad116ee1fb43696ae714183d1823cb1 developed by @MobiTikula +- Commit: c5190a640fe08e52f656eaf5efdb430cc8316ccd developed by @miroslavpojer +- Commit: c5a1eacee2ff8fbd71f3633ee6f89247d6cdba85 developed by @miroslavpojer +- Commit: cb326bfa04e9d7ef0e6f4126c9f5623032b4949a developed by @miroslavpojer +- Commit: d111b1a81cb26f9695af2114a1b718d553b26791 developed by @miroslavpojer +- Commit: d1c9cd08118bee7f0e4a77e859dbd86f48c84ba2 developed by @miroslavpojer +- Commit: d4ef636e0bdba9a796b9daa82f0a70964b19b702 developed by @miroslavpojer +- Commit: d63a04d93b4234b5436522b412b4cff773c3711d developed by @miroslavpojer +- Commit: da4f2b79a4f95ef9af2d7b10a974261b047c8647 developed by @miroslavpojer +- Commit: dafee2947bb3e243e18e29bf5d374b52aa96650a developed by @miroslavpojer +- Commit: dbc2e4c0b335ea65c6ef8fc63a7453fd9f8d1281 developed by @miroslavpojer +- Commit: dd8077dbaeb3f932acefc5bb80676be12db05c66 developed by @miroslavpojer +- Commit: df07e8f6ed2951781bdd41003dab830d3cf9853f developed by @miroslavpojer +- Commit: df0b851314d44c908475d01305f0d77c6fec153b developed by @miroslavpojer +- Commit: e15c3cdc66f3c629eaabe37bc62a8edab76b36ca developed by @MobiTikula +- Commit: e2b9add863066bfb705484c93b1f0847414d05f5 developed by @miroslavpojer +- Commit: e32a6ea8a05c7d48d5ab30a545119c718e3a3d37 developed by @miroslavpojer +- Commit: e6b762e10943105c4e2eeebf47450f2523c14066 developed by @miroslavpojer +- Commit: e8880e6f87bc1409d8873db80319aa01e3ab3138 developed by @miroslavpojer co-authored by @Zejnilovic +- Commit: e8d31f7e3f762a9dcc4702b00288c2fb09ed4c4d developed by @miroslavpojer +- Commit: ea32e110bfbfd7c8ff93423526fd104683f1654b developed by @miroslavpojer +- Commit: ea77959ea7eadd0b45e88beb2b6076135ec1915c developed by @miroslavpojer +- Commit: ec5f22a668f6a2dd78bafc57fc374b4f3a2b308e developed by @miroslavpojer +- Commit: ed1abf3683fdfcdb2e1151dd429f13d83fe38d71 developed by @miroslavpojer +- Commit: ef6c0511f4a47285de9a81148099e27c92697801 developed by @miroslavpojer +- Commit: ef8af9f0438c976b200ff2f07b29c0c141376b28 developed by @miroslavpojer +- Commit: f07ac856c926049947f99099cdf352b69ffae468 developed by @miroslavpojer +- Commit: f13f8492165f1d546434d78300058fe498b66d36 developed by @miroslavpojer +- Commit: f1f8a8c60e468a33cb09ccf676da549cc4655e23 developed by @MobiTikula +- Commit: f3be9de73279f971b1d63bec7726989b1e9a4ebf developed by @miroslavpojer +- Commit: f5ce04ef13ee4588ba5f5ee37d091f0843a3c8a1 developed by @miroslavpojer +- Commit: f71e781a33799a5b25d32bc3f5079059e93ed116 developed by @MobiTikula +- Commit: f871bbc609bdd91e2b02fd7f70a10090c1d2cb7c developed by @miroslavpojer +- Commit: f87a5ed12bb41de12c8351ce139dd426341e68f4 developed by @miroslavpojer +- Commit: fbd219a6d4d739cac5e69b8e0f727e1a666fea91 developed by @MobiTikula +- Commit: fbe8e558f914cd58d8e7aab8c7d0c77f934aa707 developed by @miroslavpojer ### Others - No Topic ⚠️ -Previous filters caught all Issues or Pull Requests. +- PR: #60 _Test change to test close of PR instead of Merge._ assigned to @miroslavpojer developed by @miroslavpojer +- PR: #65 _Fake change in PR to get PR._ developed by @Zejnilovic, @miroslavpojer +- PR: #92 _Fake change._ developed by @miroslavpojer #### Full Changelog -https://github.com/company/test-project/commits/v0.1.0 +https://github.com/absa-group/living-doc-example-project/commits/v0.1.0 diff --git a/pyproject.toml b/pyproject.toml index 474d0aff..7cf6438e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,7 @@ [tool.black] line-length = 120 target-version = ['py311'] +force-exclude = '''test''' + +[tool.coverage.run] +omit = ["tests/*"] diff --git a/release_notes_generator/action_inputs.py b/release_notes_generator/action_inputs.py index 519b689d..f428af39 100644 --- a/release_notes_generator/action_inputs.py +++ b/release_notes_generator/action_inputs.py @@ -34,12 +34,13 @@ WARNINGS, RUNNER_DEBUG, PRINT_EMPTY_CHAPTERS, - CHAPTERS_TO_PR_WITHOUT_ISSUE, DUPLICITY_SCOPE, DUPLICITY_ICON, ROW_FORMAT_LINK_PR, ROW_FORMAT_ISSUE, ROW_FORMAT_PR, + ROW_FORMAT_COMMIT, + ROW_FORMAT_LINK_COMMIT, ) from release_notes_generator.utils.enums import DuplicityScopeEnum from release_notes_generator.utils.gh_action import get_action_input @@ -138,13 +139,6 @@ def get_print_empty_chapters() -> bool: """ return get_action_input(PRINT_EMPTY_CHAPTERS, "true").lower() == "true" - @staticmethod - def get_chapters_to_pr_without_issue() -> bool: - """ - Get the chapters to PR without issue parameter value from the action inputs. - """ - return get_action_input(CHAPTERS_TO_PR_WITHOUT_ISSUE, "true").lower() == "true" - @staticmethod def validate_input(input_value, expected_type: type, error_message: str, error_buffer: list) -> bool: """ @@ -167,14 +161,23 @@ def get_row_format_issue() -> str: """ Get the issue row format for the release notes. """ - return get_action_input(ROW_FORMAT_ISSUE, "#{number} _{title}_ in {pull-requests}").strip() + return get_action_input( + ROW_FORMAT_ISSUE, "#{number} _{title}_ {pull-requests} {assignee} {developed-by} {co-authored-by}" + ).strip() @staticmethod def get_row_format_pr() -> str: """ Get the pr row format for the release notes. """ - return get_action_input(ROW_FORMAT_PR, "#{number} _{title}_").strip() + return get_action_input(ROW_FORMAT_PR, "#{number} _{title}_ {assignee} {developed-by} {co-authored-by}").strip() + + @staticmethod + def get_row_format_commit() -> str: + """ + Get the commit row format for the release notes. + """ + return get_action_input(ROW_FORMAT_COMMIT, "{sha} {author} {co-authored-by}").strip() @staticmethod def get_row_format_link_pr() -> bool: @@ -183,6 +186,13 @@ def get_row_format_link_pr() -> bool: """ return get_action_input(ROW_FORMAT_LINK_PR, "true").lower() == "true" + @staticmethod + def get_row_format_link_commit() -> bool: + """ + Get the value controlling whether the row format should include a 'Commit:' prefix when linking to direct commit. + """ + return get_action_input(ROW_FORMAT_LINK_COMMIT, "true").lower() == "true" + @staticmethod def validate_inputs(): """ @@ -227,6 +237,18 @@ def validate_inputs(): verbose = ActionInputs.get_verbose() ActionInputs.validate_input(verbose, bool, "Verbose logging must be a boolean.", errors) + 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) + + row_format_link_commit = ActionInputs.get_row_format_link_commit() + ActionInputs.validate_input( + row_format_link_commit, bool, "'row-format-link-commit' value must be a boolean.", errors + ) + + # Features + print_empty_chapters = ActionInputs.get_print_empty_chapters() + ActionInputs.validate_input(print_empty_chapters, bool, "Print empty chapters must be a boolean.", errors) + row_format_issue = ActionInputs.get_row_format_issue() if not isinstance(row_format_issue, str) or not row_format_issue.strip(): errors.append("Issue row format must be a non-empty string.") @@ -239,17 +261,11 @@ def validate_inputs(): errors.extend(detect_row_format_invalid_keywords(row_format_pr, row_type="PR")) - 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) + row_format_commit = ActionInputs.get_row_format_commit() + if not isinstance(row_format_commit, str) or not row_format_commit.strip(): + errors.append("Commit Row format must be a non-empty string.") - # Features - print_empty_chapters = ActionInputs.get_print_empty_chapters() - ActionInputs.validate_input(print_empty_chapters, bool, "Print empty chapters must be a boolean.", errors) - - chapters_to_pr_without_issue = ActionInputs.get_chapters_to_pr_without_issue() - ActionInputs.validate_input( - chapters_to_pr_without_issue, bool, "Chapters to PR without issue must be a boolean.", errors - ) + errors.extend(detect_row_format_invalid_keywords(row_format_commit, row_type="Commit")) # Log errors if any if errors: @@ -260,9 +276,16 @@ def validate_inputs(): logging.debug("Repository: %s/%s", owner, repo_name) logger.debug("Tag name: %s", tag_name) logger.debug("Chapters JSON: %s", chapters_json) + logger.debug("Duplication scope: %s", ActionInputs.get_duplicity_scope()) + logger.debug("Duplication icon: %s", duplicity_icon) + logger.debug("Warnings: %s", warnings) logger.debug("Published at: %s", published_at) logger.debug("Skip release notes label: %s", skip_release_notes_label) - logger.debug("Verbose logging: %s", verbose) - logger.debug("Warnings: %s", warnings) logger.debug("Print empty chapters: %s", print_empty_chapters) - logger.debug("Chapters to PR without issue: %s", chapters_to_pr_without_issue) + logger.debug("Verbose logging: %s", verbose) + logger.debug("Github repository: %s", repository_id) + logger.debug("Row format issue: %s", row_format_issue) + logger.debug("Row format PR: %s", row_format_pr) + logger.debug("Row format commit: %s", row_format_commit) + logger.debug("Row format link PR: %s", row_format_link_pr) + logger.debug("Row format link commit: %s", row_format_commit) diff --git a/release_notes_generator/builder.py b/release_notes_generator/builder.py index 37df7cad..e65fe62e 100644 --- a/release_notes_generator/builder.py +++ b/release_notes_generator/builder.py @@ -22,7 +22,7 @@ from itertools import chain from release_notes_generator.model.custom_chapters import CustomChapters -from release_notes_generator.model.record import Record +from release_notes_generator.model.base_record import Record from release_notes_generator.model.service_chapters import ServiceChapters from release_notes_generator.action_inputs import ActionInputs diff --git a/release_notes_generator/generator.py b/release_notes_generator/generator.py index c59abb2a..8ad37d2c 100644 --- a/release_notes_generator/generator.py +++ b/release_notes_generator/generator.py @@ -25,7 +25,7 @@ from github import Github from release_notes_generator.model.custom_chapters import CustomChapters -from release_notes_generator.model.record import Record +from release_notes_generator.model.base_record import Record from release_notes_generator.builder import ReleaseNotesBuilder from release_notes_generator.record.record_factory import RecordFactory from release_notes_generator.action_inputs import ActionInputs @@ -89,19 +89,21 @@ def generate(self) -> Optional[str]: pulls = pulls_all = self._safe_call(repo.get_pulls)(state="closed") commits = commits_all = list(self._safe_call(repo.get_commits)()) + logger.info("Count of issues: %d. (Note: Mixture of Issues and PRs from API :-(.)", len(list(issues))) if rls is not None: - logger.info("Count of issues: %d", len(list(issues))) - # filter out merged PRs and commits before the since date pulls = list(filter(lambda pull: pull.merged_at is not None and pull.merged_at > since, list(pulls_all))) logger.debug("Count of pulls reduced from %d to %d", len(list(pulls_all)), len(pulls)) commits = list(filter(lambda commit: commit.commit.author.date > since, list(commits_all))) logger.debug("Count of commits reduced from %d to %d", len(list(commits_all)), len(commits)) + else: + logger.info("Count of pulls: %d", len(list(pulls))) + logger.info("Count of commits: %d", len(list(commits))) changelog_url = get_change_url(tag_name=ActionInputs.get_tag_name(), repository=repo, git_release=rls) - rls_notes_records: dict[int, Record] = RecordFactory.generate( + rls_notes_records: dict[int | str, Record] = RecordFactory.generate( github=self.github_instance, repo=repo, issues=list(issues), # PaginatedList --> list diff --git a/release_notes_generator/model/base_chapters.py b/release_notes_generator/model/base_chapters.py index fd3514a6..79007d0f 100644 --- a/release_notes_generator/model/base_chapters.py +++ b/release_notes_generator/model/base_chapters.py @@ -20,7 +20,7 @@ from abc import ABC, abstractmethod from release_notes_generator.model.chapter import Chapter -from release_notes_generator.model.record import Record +from release_notes_generator.model.base_record import Record class BaseChapters(ABC): diff --git a/release_notes_generator/model/base_record.py b/release_notes_generator/model/base_record.py new file mode 100644 index 00000000..2981deef --- /dev/null +++ b/release_notes_generator/model/base_record.py @@ -0,0 +1,328 @@ +# +# 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 BaseChapters class which is responsible for representing the base chapters. +""" + +import logging +from abc import ABC, abstractmethod +from typing import Optional + +from github.Issue import Issue +from github.PullRequest import PullRequest +from github.Repository import Repository +from github.Commit import Commit + +from release_notes_generator.utils.constants import ( + RELEASE_NOTE_DETECTION_PATTERN, + RELEASE_NOTE_LINE_MARK, + PR_STATE_CLOSED, +) + +logger = logging.getLogger(__name__) + + +class Record(ABC): + """ + A class used to represent a record in the release notes. + """ + + LINK_TO_PR_TEMPLATE = "[#{number}](https://github.com/{full_name}/pull/{number})" + + def __init__(self, repo: Repository, safe_call): + self._repo: Repository = repo + self._safe_call = safe_call + + self.__is_release_note_detected: bool = False + self.__present_in_chapters = 0 + + @property + def is_present_in_chapters(self) -> bool: + """Check if the record is present in chapters.""" + return self.__present_in_chapters > 0 + + @property + def present_in_chapters(self) -> int: + """Gets the count of chapters in which the record is present.""" + return self.__present_in_chapters + + @property + @abstractmethod + def id(self) -> Optional[int | str]: + """Get the id of the record.""" + pass + + @property + @abstractmethod + def issue(self) -> Optional[Issue]: + """Get the record's issue.""" + pass + + @property + @abstractmethod + def pull_requests(self) -> list[PullRequest]: + """Get the record's pull requests.""" + pass + + @property + @abstractmethod + def commits(self) -> list[Commit]: + """Get the record's commits.""" + pass + + @property + @abstractmethod + def labels(self) -> list[str]: + """Get labels of the record.""" + pass + + # Note: assignee & assignees are related to this GitHub Docs - https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/assigning-issues-and-pull-requests-to-other-github-users#about-issue-and-pull-request-assignees + @property + @abstractmethod + def assignee(self) -> Optional[str]: + """Get record's assignee.""" + pass + + @property + @abstractmethod + def assignees(self) -> Optional[str]: + """Get record's assignees.""" + pass + + @property + @abstractmethod + def developers(self) -> Optional[str]: + """Get record's developers.""" + pass + + @property + @abstractmethod + def contributors(self) -> Optional[str]: + """Get record's contributors.""" + pass + + @abstractmethod + def pr_links(self) -> Optional[str]: + """Get links to record's pull requests.""" + pass + + @abstractmethod + def pr_contains_issue_mentions(self) -> bool: + """Checks if the record's pull requests contains issue mentions.""" + pass + + @abstractmethod + def is_state(self, state: str) -> bool: + """Check if the record's state is the specified state.""" + pass + + # TODO - review of all method doc strings - same format, return values + @abstractmethod + def register_pull_request(self, pr: PullRequest) -> None: + """ + Registers a pull request with the record. + + @param pr: The PullRequest object to register. + @return: None + """ + pass + + @abstractmethod + def register_commit(self, commit: Commit) -> bool: + """ + Registers a commit with the record. + + @param commit: The Commit object to register. + @return: True if record is registered and valid for one of record's pull requests, False otherwise. + """ + pass + + @abstractmethod + def to_chapter_row(self) -> str: + """ + Converts the record to a string row usable in a chapter. + + @return: The record as a row string. + """ + pass + + @abstractmethod + def fetch_pr_commits(self) -> None: + pass + + @abstractmethod + def get_sha_of_all_commits(self) -> set[str]: + pass + + # TODO - remove after fix of unit tests + # @abstractmethod + # def count_of_commits(self) -> int: + # """ + # Get count of commits in all record pull requests. + # + # @return: The count of commits in the records. + # """ + # pass + + # TODO - remove after fix of unit tests + # @abstractmethod + # def pull_request_by_number(self, pr_number: int) -> Optional[PullRequest]: + # """ + # Gets a pull request associated with the record. + # + # @param index: The index of the pull request. + # @return: The PullRequest instance. + # """ + # if index < 0 or index >= len(self.__pulls): + # return None + # return self.__pulls[index] + + @staticmethod + def get_contributors_for_commit(commit: Commit) -> list[str]: + """ + Gets Contributors from commit message. + + @param commit: The commit to get contributors from. + @return: A list of contributors. + """ + + logins = [] + for line in commit.commit.message.split("\n"): + if "Co-authored-by:" in line: + name = line.split("Co-authored-by:")[1].strip() + if name not in logins: + logins.append(name) + + return logins + + @staticmethod + def is_pull_request_merged(pull: PullRequest) -> bool: + """ + Checks if the pull request is merged. + + @param pull: The pull request to check. + @return: A boolean indicating whether the pull request is merged. + """ + return pull.state == PR_STATE_CLOSED and pull.merged_at is not None and pull.closed_at is not None + + def increment_present_in_chapters(self) -> None: + """ + Increments the count of chapters in which the record is present. + + @return: None + """ + self.__present_in_chapters += 1 + + # TODO in Issue named 'Configurable regex-based Release note detection in the PR body' + # - 'Release notest:' as detection pattern default - can be defined by user + # - '-' as leading line mark for each release note to be used + def get_rls_notes(self, detection_pattern=RELEASE_NOTE_DETECTION_PATTERN, line_mark=RELEASE_NOTE_LINE_MARK) -> str: + """ + Gets the release notes of the record. + + @param detection_pattern: The detection pattern to use. + @param line_mark: The line mark to use. + @return: The release notes of the record as a string. + """ + release_notes = "" + + # Iterate over all PRs + for pull in self.pull_requests: + body_lines = pull.body.split("\n") if pull.body is not None else [] + inside_release_notes = False + + for line in body_lines: + if detection_pattern in line: + inside_release_notes = True + + if detection_pattern not in line and inside_release_notes: + if line.strip().startswith(line_mark): + release_notes += f" {line}\n" + else: + break + + # Return the concatenated release notes + return release_notes.rstrip() + + def contains_release_notes(self) -> bool: + """Checks if the record contains release notes.""" + if self.__is_release_note_detected: + return self.__is_release_note_detected + + rls_notes = self.get_rls_notes() + # if RELEASE_NOTE_LINE_MARK in self.get_rls_notes(): + if RELEASE_NOTE_LINE_MARK in rls_notes: + self.__is_release_note_detected = True + + return self.__is_release_note_detected + + def contains_min_one_label(self, input_labels: list[str]) -> bool: + """ + Check if the record contains at least one of the specified labels. + + @param input_labels: A list of labels to check for. + @return: A boolean indicating whether the record contains any of the specified labels. + """ + for lbl in self.labels: + if lbl in input_labels: + return True + + return False + + def contain_all_labels(self, input_labels: list[str]) -> bool: + """ + Check if the record contains all the specified labels. + + @param input_labels: A list of labels to check for. + @return: A boolean indicating whether the record contains all the specified + """ + if len(self.labels) != len(input_labels): + return False + + for lbl in self.labels: + if lbl not in input_labels: + return False + + return True + + def _get_row_format_values(self, row_format: str) -> dict: + """ + Create dictionary and fill by user row format defined values. + NoteL some values are API call intensive. + + @param row_format: User defined row format. + @return: The dictionary with supported values required by user row format. + """ + format_values = {} + + if "{assignee}" in row_format: + assignee = self.assignees + format_values["assignee"] = f"assigned to @{assignee}" if assignee is not None else "" + if "{assignees}" in row_format: + assignees = self.assignees + format_values["assignees"] = f"assigned to @{assignees}" if assignees is not None else "" + if "{author}" in row_format: + developers = self.developers + format_values["author"] = f"developed by {developers}" if developers is not None else "" + if "{developed-by}" in row_format: + developers = self.developers + format_values["developed-by"] = f"developed by {developers}" if developers is not None else "" + if "{co-authored-by}" in row_format: + contributors = self.contributors + format_values["co-authored-by"] = f"co-authored by {contributors}" if contributors is not None else "" + + return format_values diff --git a/release_notes_generator/model/commit_record.py b/release_notes_generator/model/commit_record.py new file mode 100644 index 00000000..9b540f3e --- /dev/null +++ b/release_notes_generator/model/commit_record.py @@ -0,0 +1,155 @@ +# +# 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 BaseChapters class which is responsible for representing the base chapters. +""" + +import logging +from typing import Optional + +from github.Issue import Issue +from github.PullRequest import PullRequest +from github.Repository import Repository +from github.Commit import Commit + +from release_notes_generator.action_inputs import ActionInputs +from release_notes_generator.model.base_record import Record +from release_notes_generator.utils.constants import ( + RELEASE_NOTE_DETECTION_PATTERN, + RELEASE_NOTE_LINE_MARK, +) + +logger = logging.getLogger(__name__) + + +class CommitRecord(Record): + """ + A class used to represent a record in the release notes. + The record represent an isolated commit without link to Issue or Pull request. Direct commit to master. + """ + + def __init__(self, repo: Repository, safe_call): + super().__init__(repo, safe_call) + + self.__commit: Optional[Commit] = None + + @property + def issue(self) -> Optional[Issue]: + """Get the issue of the record.""" + return None + + @property + def pull_requests(self) -> list[PullRequest]: + """Get the pull requests of the record.""" + return [] + + @property + def commits(self) -> list[Commit]: + """Get the commits of the record.""" + return [self.__commit] if self.__commit is not None else [] + + @property + def id(self) -> Optional[int | str]: + """Get the id of the record.""" + return self.__commit.sha if self.__commit is not None else None + + @property + def labels(self) -> list[str]: + """Get the labels of the record.""" + return [] + + @property + def assignee(self) -> Optional[str]: + """Get the assignee of the record.""" + return f"{self.commits[0].author.login}" if self.commits else None + + @property + def assignees(self) -> Optional[str]: + """Get the assignees of the record.""" + return f"{self.commits[0].author.login}" if self.commits else None + + @property + def developers(self) -> Optional[str]: + """Get the developers of the record.""" + return f"@{self.commits[0].author.login}" if self.commits else None + + @property + def contributors(self) -> Optional[str]: + """Get the contributors of the record.""" + logins = self.get_contributors_for_commit(self.__commit) + return ", ".join(logins) if len(logins) > 0 else None + + def is_state(self, state: str) -> bool: + """Check if the record is in the given state.""" + return False + + def get_rls_notes(self, detection_pattern=RELEASE_NOTE_DETECTION_PATTERN, line_mark=RELEASE_NOTE_LINE_MARK) -> str: + """Get the release notes of the record.""" + return "" + + def pr_contains_issue_mentions(self) -> bool: + """Check if the record's pull request contains issue mentions.""" + return False + + def pr_links(self) -> Optional[str]: + """Get the pull request links of the record.""" + return None + + def register_pull_request(self, pr: PullRequest) -> None: + """Register a pull request with the record.""" + pass + + def register_commit(self, commit: Commit) -> bool: + """ + Registers a commit with the record. + + @param commit: The Commit object to register. + @return: Always return True. + """ + self.__commit = commit + logger.debug("Registering commit 'type: Isolated' sha: %s", commit.sha) + return True + + def to_chapter_row(self) -> str: + """ + Converts the record to a string row usable in a chapter. + + @return: The record as a row string. + """ + self.increment_present_in_chapters() + row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters > 1 else "" + format_values = { + "sha": self.__commit.sha if self.__commit is not None else None, + } + + format_values.update(self._get_row_format_values(ActionInputs.get_row_format_commit())) + + prefix = "Commit: " if ActionInputs.get_row_format_link_commit() else "" + row = f"{row_prefix}{prefix}" + ActionInputs.get_row_format_commit().format(**format_values) + row = row.replace(" ", " ") + if self.contains_release_notes(): + row = f"{row}\n{self.get_rls_notes()}" + + return row + + def fetch_pr_commits(self) -> None: + """Fetch the pull request commits of the record.""" + pass + + def get_sha_of_all_commits(self) -> set[str]: + """Get the set of all commit shas of the record.""" + pass diff --git a/release_notes_generator/model/custom_chapters.py b/release_notes_generator/model/custom_chapters.py index a2582141..5c008b51 100644 --- a/release_notes_generator/model/custom_chapters.py +++ b/release_notes_generator/model/custom_chapters.py @@ -24,7 +24,7 @@ from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.base_chapters import BaseChapters from release_notes_generator.model.chapter import Chapter -from release_notes_generator.model.record import Record +from release_notes_generator.model.base_record import Record from release_notes_generator.utils.enums import DuplicityScopeEnum @@ -41,6 +41,9 @@ def populate(self, records: dict[int, Record]) -> None: @return: None """ for nr in records: # iterate all records + if isinstance(nr, str): + continue + for ch in self.chapters.values(): # iterate all chapters if nr in self.populated_record_numbers_list and ActionInputs.get_duplicity_scope() not in ( DuplicityScopeEnum.CUSTOM, @@ -49,7 +52,7 @@ def populate(self, records: dict[int, Record]) -> None: continue for record_label in records[nr].labels: # iterate all labels of the record (issue, or 1st PR) - if record_label in ch.labels and records[nr].pulls_count > 0: + if record_label in ch.labels and len(records[nr].pull_requests) > 0: if not records[nr].is_present_in_chapters: ch.add_row(nr, records[nr].to_chapter_row()) self.populated_record_numbers_list.append(nr) diff --git a/release_notes_generator/model/issue_record.py b/release_notes_generator/model/issue_record.py new file mode 100644 index 00000000..c91cd4d8 --- /dev/null +++ b/release_notes_generator/model/issue_record.py @@ -0,0 +1,203 @@ +# +# 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 BaseChapters class which is responsible for representing the base chapters. +""" + +import logging +from typing import Optional + +from github.Issue import Issue +from github.PullRequest import PullRequest +from github.Repository import Repository +from github.Commit import Commit + +from release_notes_generator.action_inputs import ActionInputs +from release_notes_generator.model.base_record import Record +from release_notes_generator.utils.pull_request_utils import extract_issue_numbers_from_body + +logger = logging.getLogger(__name__) + + +class IssueRecord(Record): + """ + A class used to represent a record in the release notes. + The record represents an issue with its Pull requests and commits. + """ + + def __init__(self, repo: Repository, safe_call, issue: Optional[Issue] = None): + super().__init__(repo, safe_call) + + self.__issue: Issue = issue + self.__pulls_requests: list[PullRequest] = [] + self.__pr_commits: dict[int, list[Commit]] = {} + + @property + def issue(self) -> Optional[Issue]: + """Get the issue of the record.""" + return self.__issue + + @property + def pull_requests(self) -> list[PullRequest]: + """Get the pull requests of the record.""" + return self.__pulls_requests + + @property + def commits(self) -> list[Commit]: + """Get the commits of the record.""" + all_commits = [] + for commits in self.__pr_commits.values(): + all_commits.extend(commits) + return all_commits + + @property + def id(self) -> Optional[int | str]: + """Get the id of the record.""" + return self.__issue if self.__issue is not None else None + + @property + def labels(self) -> list[str]: + """Getter for the labels of the record.""" + return [label.name for label in self.__issue.labels] if self.__issue is not None else [] + + @property + def assignee(self) -> Optional[str]: + """Get record's assignee.""" + return self.issue.assignee.login if self.issue.assignee is not None else None + + @property + def assignees(self) -> Optional[str]: + """Get record's assignees.""" + logins = [a.login for a in self.issue.assignees] + return ", ".join(logins) if len(logins) > 0 else None + + @property + def developers(self) -> Optional[str]: + """Get record's developers.""" + logins = set() + for commits in self.__pr_commits.values(): + for commit in commits: + if commit.author is not None: + logins.add(f"@{commit.author.login}") + + if not logins: + logger.warning( + "Found issue record %s with %d pull requests and no commits", self.id, len(self.__pulls_requests) + ) + + return ", ".join(logins) if len(logins) > 0 else None + + @property + def contributors(self) -> Optional[str]: + """Get record's contributors.""" + logins = [] + for c in self.commits: + for line in c.commit.message.split("\n"): + if "Co-authored-by:" in line: + name = line.split("Co-authored-by:")[1].strip() + if name not in logins: + logins.append(name) + + return ", ".join(logins) if len(logins) > 0 else None + + def is_state(self, state: str) -> bool: + """Check if the record is in a specific state.""" + return self.__issue.state == state if self.__issue is not None else False + + def pr_contains_issue_mentions(self) -> bool: + """Checks if the record's pull request contains issue mentions.""" + return len(extract_issue_numbers_from_body(self.__pulls_requests[0])) > 0 + + def pr_links(self) -> Optional[str]: + """Get links to record's pull requests.""" + if len(self.__pulls_requests) == 0: + return None + + res = [ + self.LINK_TO_PR_TEMPLATE.format(number=pull.number, full_name=self._repo.full_name) + for pull in self.__pulls_requests + ] + return ", ".join(res) + + def register_pull_request(self, pr: PullRequest) -> None: + """ + Registers a pull request with the record. + + @param pr: The PullRequest object to register. + @return: None + """ + self.__pulls_requests.append(pr) + + def register_commit(self, commit: Commit) -> bool: + """ + Registers a commit with the record. + + @param commit: The Commit object to register. + @return: True if record is registered and valid for one of record's pull requests, False otherwise. + """ + sha = commit.sha + for pull in self.__pulls_requests: + if sha == pull.merge_commit_sha or sha == pull.head.sha: + if self.__pr_commits.get(pull.number) is None: + self.__pr_commits[pull.number] = [] + self.__pr_commits[pull.number].append(commit) + logger.debug("Commit %s registered using sha in PR %s of record %s", commit.sha, pull.number, self.id) + return True + + return False + + def to_chapter_row(self) -> str: + """ + Converts the record to a string row usable in a chapter. + + @return: The record as a row string. + """ + self.increment_present_in_chapters() + row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters > 1 else "" + format_values = { + "number": self.__issue.number, + "title": self.__issue.title, + "pull-requests": f"in {self.pr_links()}" if len(self.__pulls_requests) > 0 else "", + } + + format_values.update(self._get_row_format_values(ActionInputs.get_row_format_issue())) + + row = f"{row_prefix}" + ActionInputs.get_row_format_issue().format(**format_values) + row = row.replace(" ", " ") + if self.contains_release_notes(): + row = f"{row}\n{self.get_rls_notes()}" + + return row + + def fetch_pr_commits(self) -> None: + """Fetch commits of the record's pull requests.""" + for pull in self.__pulls_requests: + self.__pr_commits[pull.number] = list(self._safe_call(pull.get_commits)()) + + def get_sha_of_all_commits(self) -> set[str]: + """Get the set of all commit shas of the record.""" + set_of_commit_shas = set() + + for commits in self.__pr_commits.values(): + for c in commits: + set_of_commit_shas.add(c.sha) + set_of_commit_shas.add(c.commit.sha) + + for pull in self.__pulls_requests: + set_of_commit_shas.add(pull.merge_commit_sha) + + return set_of_commit_shas diff --git a/release_notes_generator/model/pull_request_record.py b/release_notes_generator/model/pull_request_record.py new file mode 100644 index 00000000..dbaa636c --- /dev/null +++ b/release_notes_generator/model/pull_request_record.py @@ -0,0 +1,177 @@ +# +# 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 BaseChapters class which is responsible for representing the base chapters. +""" + +import logging +from typing import Optional + +from github.Issue import Issue +from github.PullRequest import PullRequest +from github.Repository import Repository +from github.Commit import Commit + +from release_notes_generator.action_inputs import ActionInputs +from release_notes_generator.model.base_record import Record +from release_notes_generator.utils.pull_request_utils import extract_issue_numbers_from_body + +logger = logging.getLogger(__name__) + + +class PullRequestRecord(Record): + """ + A class used to represent a record in the release notes. + The record represents a pull request and its commits. + """ + + def __init__(self, repo: Repository, safe_call, pull_request: PullRequest): + super().__init__(repo, safe_call) + + self.__pull_request: PullRequest = pull_request + self.__commits: list[Commit] = [] + + @property + def issue(self) -> Optional[Issue]: + """Get the issue of the record.""" + return None + + @property + def pull_requests(self) -> list[PullRequest]: + """Get the pull requests of the record.""" + return [self.__pull_request] + + @property + def commits(self) -> list[Commit]: + """Get the commits of the record.""" + return self.__commits + + # TODO - remove this method after unit test fix + # def pull_request_by_number(self) -> PullRequest: + # """Getter for the pull request of the record.""" + # return self.__pull_request + + @property + def id(self) -> Optional[int | str]: + """Get the id of the record.""" + return self.__pull_request.number if self.__pull_request is not None else None + + @property + def labels(self) -> list[str]: + """Get the labels of the record.""" + return [label.name for label in self.__pull_request.labels] if self.__pull_request is not None else [] + + @property + def assignee(self) -> Optional[str]: + """Get record assignee.""" + return self.__pull_request.assignee.login if self.__pull_request.assignee is not None else None + + @property + def assignees(self) -> Optional[str]: + """Get record assignees.""" + logins = [a.login for a in self.__pull_request.assignees] + return ", ".join(logins) if len(logins) > 0 else None + + @property + def developers(self) -> Optional[str]: + """Get record developers.""" + logins = {f"@{c.author.login}" for c in self.__commits} + if not logins: + logger.warning("Found pull request record '%s' with no commits", self.__pull_request.number) + return ", ".join(logins) if len(logins) > 0 else None + + @property + def contributors(self) -> Optional[str]: + """Get record contributors.""" + logins = [] + for c in self.__commits: + logins.extend(self.get_contributors_for_commit(c)) + + return ", ".join(logins) if len(logins) > 0 else None + + def is_state(self, state: str) -> bool: + """Check if the record is in a specific state.""" + return self.__pull_request.state == state if self.__pull_request is not None else False + + def pr_contains_issue_mentions(self) -> bool: + """Checks if the record's pull request contains issue mentions.""" + return len(extract_issue_numbers_from_body(self.__pull_request)) > 0 + + def pr_links(self) -> Optional[str]: + """Get links to record's pull requests.""" + return self.LINK_TO_PR_TEMPLATE.format(number=self.__pull_request.number, full_name=self._repo.full_name) + + def register_pull_request(self, pr: PullRequest) -> None: + """ + Registers a pull request with the record. + + @param pr: The PullRequest object to register. + @return: None + """ + self.__pull_request = pr + + def register_commit(self, commit: Commit) -> bool: + """ + Registers a commit with the record. + + @param commit: The Commit object to register. + @return: None + """ + sha = commit.sha + if sha == self.__pull_request.merge_commit_sha or sha == self.__pull_request.head.sha: + self.__commits.append(commit) + logger.debug( + "Commit %s registered using sha in PR %s of record %s", commit.sha, self.__pull_request.number, self.id + ) + return True + + return False + + def to_chapter_row(self) -> str: + """ + Converts the record to a string row usable in a chapter. + + @return: The record as a row string. + """ + self.increment_present_in_chapters() + row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters > 1 else "" + format_values = {"number": self.__pull_request.number, "title": self.__pull_request.title} + + format_values.update(self._get_row_format_values(ActionInputs.get_row_format_pr())) + + pr_prefix = "PR: " if ActionInputs.get_row_format_link_pr() else "" + row = f"{row_prefix}{pr_prefix}" + ActionInputs.get_row_format_pr().format(**format_values) + row = row.replace(" ", " ") + if self.contains_release_notes(): + row = f"{row}\n{self.get_rls_notes()}" + + return row + + def fetch_pr_commits(self) -> None: + """Fetches the commits of the record.""" + self.__commits = list(self._safe_call(self.__pull_request.get_commits)()) + + def get_sha_of_all_commits(self) -> set[str]: + """Get the set of all commit shas of the record.""" + set_of_commit_shas = set() + + for c in self.__commits: + set_of_commit_shas.add(c.sha) + set_of_commit_shas.add(c.commit.sha) + + set_of_commit_shas.add(self.__pull_request.merge_commit_sha) + return set_of_commit_shas diff --git a/release_notes_generator/model/record.py b/release_notes_generator/model/record.py deleted file mode 100644 index c14f4b90..00000000 --- a/release_notes_generator/model/record.py +++ /dev/null @@ -1,365 +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 BaseChapters class which is responsible for representing the base chapters. -""" - -import logging -from typing import Optional - -from github.Issue import Issue -from github.PullRequest import PullRequest -from github.Repository import Repository -from github.Commit import Commit - -from release_notes_generator.action_inputs import ActionInputs -from release_notes_generator.utils.constants import ( - PR_STATE_CLOSED, - ISSUE_STATE_CLOSED, - ISSUE_STATE_OPEN, - RELEASE_NOTE_DETECTION_PATTERN, - RELEASE_NOTE_LINE_MARK, -) -from release_notes_generator.utils.pull_reuqest_utils import extract_issue_numbers_from_body - -logger = logging.getLogger(__name__) - - -# TODO - recheck the size of class, is there a way to reduce or split it? -# pylint: disable=too-many-instance-attributes, too-many-public-methods -class Record: - """ - A class used to represent a record in the release notes. - """ - - def __init__(self, repo: Repository, issue: Optional[Issue] = None): - self.__repo: Repository = repo - self.__gh_issue: Issue = issue - self.__pulls: list[PullRequest] = [] - self.__pull_commits: dict = {} - - self.__is_release_note_detected: bool = False - self.__present_in_chapters = 0 - - @property - def number(self) -> int: - """Getter for the number of the record.""" - if self.__gh_issue is None: - return self.__pulls[0].number - return self.__gh_issue.number - - @property - def issue(self) -> Optional[Issue]: - """Getter for the issue of the record.""" - return self.__gh_issue - - @property - def pulls(self) -> list[PullRequest]: - """Getter for the pull requests of the record.""" - return self.__pulls - - @property - def commits(self) -> dict: - """Getter for the commits of the record.""" - return self.__pull_commits - - @property - def is_present_in_chapters(self) -> bool: - """Check if the record is present in chapters.""" - return self.__present_in_chapters > 0 - - @property - def is_pr(self) -> bool: - """Check if the record is a pull request.""" - return self.__gh_issue is None and len(self.__pulls) == 1 - - @property - def is_issue(self) -> bool: - """Check if the record is an issue.""" - return self.__gh_issue is not None - - @property - def is_closed(self) -> bool: - """Check if the record is closed.""" - if self.__gh_issue is None: - # no issue ==> stand-alone PR - return self.__pulls[0].state == PR_STATE_CLOSED - - return self.__gh_issue.state == ISSUE_STATE_CLOSED - - @property - def is_closed_issue(self) -> bool: - """Check if the record is a closed issue.""" - return self.is_issue and self.__gh_issue.state == ISSUE_STATE_CLOSED - - @property - def is_open_issue(self) -> bool: - """Check if the record is an open issue.""" - return self.is_issue and self.__gh_issue.state == ISSUE_STATE_OPEN - - @property - def is_merged_pr(self) -> bool: - """Check if the record is a merged pull request.""" - if self.__gh_issue is None: - return self.is_pull_request_merged(self.__pulls[0]) - return False - - @property - def labels(self) -> list[str]: - """Getter for the labels of the record.""" - if self.__gh_issue is None: - return [label.name for label in self.__pulls[0].labels] - - return [label.name for label in self.__gh_issue.labels] - - # TODO in Issue named 'Configurable regex-based Release note detection in the PR body' - # - 'Release notest:' as detection pattern default - can be defined by user - # - '-' as leading line mark for each release note to be used - def get_rls_notes(self, detection_pattern=RELEASE_NOTE_DETECTION_PATTERN, line_mark=RELEASE_NOTE_LINE_MARK) -> str: - """ - Gets the release notes of the record. - - @param detection_pattern: The detection pattern to use. - @param line_mark: The line mark to use. - @return: The release notes of the record as a string. - """ - release_notes = "" - - # Iterate over all PRs - for pull in self.__pulls: - body_lines = pull.body.split("\n") if pull.body is not None else [] - inside_release_notes = False - - for line in body_lines: - if detection_pattern in line: - inside_release_notes = True - continue - - if inside_release_notes: - if line.startswith(line_mark): - release_notes += f" {line.strip()}\n" - else: - break - - # Return the concatenated release notes - return release_notes.rstrip() - - @property - def contains_release_notes(self) -> bool: - """Checks if the record contains release notes.""" - if self.__is_release_note_detected: - return self.__is_release_note_detected - - if RELEASE_NOTE_LINE_MARK in self.get_rls_notes(): - self.__is_release_note_detected = True - - return self.__is_release_note_detected - - @property - def pulls_count(self) -> int: - """Getter for the count of pull requests of the record.""" - return len(self.__pulls) - - @property - def pr_contains_issue_mentions(self) -> bool: - """Checks if the pull request contains issue mentions.""" - return len(extract_issue_numbers_from_body(self.__pulls[0])) > 0 - - @property - def authors(self) -> Optional[str]: - """Getter for the authors of the record.""" - return None - # TODO in Issue named 'Chapter line formatting - authors' - # authors: list[str] = [] - # - # for pull in self.__pulls: - # if pull.author is not None: - # authors.append(f"@{pull.author}") - # - # if len(authors) > 0: - # return None - # - # res = ", ".join(authors) - # return res - - @property - def contributors(self) -> Optional[str]: - """Getter for the contributors of the record.""" - return None - - @property - def pr_links(self) -> Optional[str]: - """Getter for the pull request links of the record.""" - if len(self.__pulls) == 0: - return None - - template = "[#{number}](https://github.com/{full_name}/pull/{number})" - res = [template.format(number=pull.number, full_name=self.__repo.full_name) for pull in self.__pulls] - - return ", ".join(res) - - def pull_request_commit_count(self, pull_number: int = 0) -> int: - """ - Get count of commits in all record pull requests. - - @param pull_number: The number of the pull request. - @return: The count of commits in the pull request. - """ - for pull in self.__pulls: - if pull.number == pull_number: - if pull.number in self.__pull_commits: - return len(self.__pull_commits.get(pull.number)) - - return 0 - - return 0 - - def pull_request(self, index: int = 0) -> Optional[PullRequest]: - """ - Gets a pull request associated with the record. - - @param index: The index of the pull request. - @return: The PullRequest instance. - """ - if index < 0 or index >= len(self.__pulls): - return None - return self.__pulls[index] - - def register_pull_request(self, pull) -> None: - """ - Registers a pull request with the record. - - @param pull: The PullRequest object to register. - @return: None - """ - self.__pulls.append(pull) - - def register_commit(self, commit: Commit) -> None: - """ - Registers a commit with the record. - - @param commit: The Commit object to register. - @return: None - """ - for pull in self.__pulls: - if commit.sha == pull.merge_commit_sha: - if self.__pull_commits.get(pull.number) is None: - self.__pull_commits[pull.number] = [] - self.__pull_commits[pull.number].append(commit) - return - - logger.error("Commit %s not registered in any PR of record %s", commit.sha, self.number) - - def to_chapter_row(self) -> str: - """ - Converts the record to a string row in a chapter. - - @return: The record as a row string. - """ - self.increment_present_in_chapters() - row_prefix = f"{ActionInputs.get_duplicity_icon()} " if self.present_in_chapters() > 1 else "" - format_values = {} - - if self.__gh_issue is None: - p = self.__pulls[0] - format_values["number"] = p.number - format_values["title"] = p.title - format_values["authors"] = self.authors if self.authors is not None else "" - format_values["contributors"] = self.contributors if self.contributors is not None else "" - - pr_prefix = "PR: " if ActionInputs.get_row_format_link_pr() else "" - row = f"{row_prefix}{pr_prefix}" + ActionInputs.get_row_format_pr().format(**format_values) - - else: - format_values["number"] = self.__gh_issue.number - format_values["title"] = self.__gh_issue.title - format_values["pull-requests"] = self.pr_links if len(self.__pulls) > 0 else "" - format_values["authors"] = self.authors if self.authors is not None else "" - format_values["contributors"] = self.contributors if self.contributors is not None else "" - - row = f"{row_prefix}" + ActionInputs.get_row_format_issue().format(**format_values) - - if self.contains_release_notes: - row = f"{row}\n{self.get_rls_notes()}" - - return row - - def contains_min_one_label(self, labels: list[str]) -> bool: - """ - Check if the record contains at least one of the specified labels. - - @param labels: A list of labels to check for. - @return: A boolean indicating whether the record contains any of the specified labels. - """ - for lbl in self.labels: - if lbl in labels: - return True - return False - - def contain_all_labels(self, labels: list[str]) -> bool: - """ - Check if the record contains all of the specified labels. - - @param labels: A list of labels to check for. - @return: A boolean indicating whether the record contains all of the specified - """ - if len(self.labels) != len(labels): - return False - - for lbl in self.labels: - if lbl not in labels: - return False - return True - - def increment_present_in_chapters(self) -> None: - """ - Increments the count of chapters in which the record is present. - - @return: None - """ - self.__present_in_chapters += 1 - - def present_in_chapters(self) -> int: - """ - Gets the count of chapters in which the record is present. - - @return: The count of chapters in which the record is present. - """ - return self.__present_in_chapters - - def is_commit_sha_present(self, sha: str) -> bool: - """ - Checks if the specified commit SHA is present in the record. - - @param sha: The commit SHA to check for. - @return: A boolean indicating whether the specified commit SHA is present in the record. - """ - for pull in self.__pulls: - if pull.merge_commit_sha == sha: - return True - - return False - - @staticmethod - def is_pull_request_merged(pull: PullRequest) -> bool: - """ - Checks if the pull request is merged. - - @param pull: The pull request to check. - @return: A boolean indicating whether the pull request is merged. - """ - return pull.state == PR_STATE_CLOSED and pull.merged_at is not None and pull.closed_at is not None diff --git a/release_notes_generator/model/service_chapters.py b/release_notes_generator/model/service_chapters.py index 7eaf3d09..363c46bd 100644 --- a/release_notes_generator/model/service_chapters.py +++ b/release_notes_generator/model/service_chapters.py @@ -18,10 +18,14 @@ This module contains the ServiceChapters class which is responsible for representing the service chapters in the release notes. """ +from typing import Union + from release_notes_generator.action_inputs import ActionInputs from release_notes_generator.model.base_chapters import BaseChapters from release_notes_generator.model.chapter import Chapter -from release_notes_generator.model.record import Record +from release_notes_generator.model.base_record import Record +from release_notes_generator.model.issue_record import IssueRecord +from release_notes_generator.model.pull_request_record import PullRequestRecord from release_notes_generator.utils.constants import ( CLOSED_ISSUES_WITHOUT_PULL_REQUESTS, CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS, @@ -29,6 +33,10 @@ CLOSED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS, MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES, OTHERS_NO_TOPIC, + ISOLATED_COMMITS, + ISSUE_STATE_CLOSED, + ISSUE_STATE_OPEN, + PR_STATE_CLOSED, ) from release_notes_generator.utils.enums import DuplicityScopeEnum @@ -44,7 +52,7 @@ def __init__( sort_ascending: bool = True, print_empty_chapters: bool = True, user_defined_labels: list[str] = None, - used_record_numbers: list[int] = None, + used_record_numbers: list[Union[int, str]] = None, ): super().__init__(sort_ascending, print_empty_chapters) @@ -52,7 +60,7 @@ def __init__( self.sort_ascending = sort_ascending if used_record_numbers is None: - self.used_record_numbers = [] + self.used_record_numbers: list[Union[int, str]] = [] else: self.used_record_numbers = used_record_numbers @@ -76,6 +84,9 @@ def __init__( title=MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES, empty_message="All merged PRs are linked to Closed issues.", ), + ISOLATED_COMMITS: Chapter( + title=ISOLATED_COMMITS, empty_message="All commits are linked to an Issues or a Pull Request." + ), OTHERS_NO_TOPIC: Chapter( title=OTHERS_NO_TOPIC, empty_message="Previous filters caught all Issues or Pull Requests." ), @@ -87,7 +98,7 @@ def __init__( self.show_chapter_merged_prs_linked_to_open_issues = True - def populate(self, records: dict[int, Record]) -> None: + def populate(self, records: dict[int | str, Record]) -> None: """ Populates the service chapters with records. @@ -100,48 +111,63 @@ def populate(self, records: dict[int, Record]) -> None: if self.__is_row_present(nr) and not self.duplicity_allowed(): continue - if records[nr].is_closed_issue: + if isinstance(nr, str): + self.__populate_isolated_commits(records[nr], nr) + + elif isinstance(records[nr], IssueRecord) and records[nr].is_state(ISSUE_STATE_CLOSED): self.__populate_closed_issues(records[nr], nr) - elif records[nr].is_pr: + elif isinstance(records[nr], PullRequestRecord): self.__populate_pr(records[nr], nr) else: - if records[nr].is_open_issue and records[nr].pulls_count == 0: + if ( + isinstance(records[nr], IssueRecord) + and records[nr].is_state(ISSUE_STATE_OPEN) + and len(records[nr].pull_requests) == 0 + ): pass - elif records[nr].is_open_issue and records[nr].pulls_count > 0: + elif ( + isinstance(records[nr], IssueRecord) + and records[nr].is_state(ISSUE_STATE_OPEN) + and len(records[nr].pull_requests) > 0 + ): self.chapters[MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES].add_row(nr, records[nr].to_chapter_row()) self.used_record_numbers.append(nr) else: self.chapters[OTHERS_NO_TOPIC].add_row(nr, records[nr].to_chapter_row()) self.used_record_numbers.append(nr) - def __populate_closed_issues(self, record: Record, nr: int) -> None: + def __populate_isolated_commits(self, r: Record, nr: str) -> None: + self.chapters[ISOLATED_COMMITS].add_row(nr, r.to_chapter_row()) + self.used_record_numbers.append(nr) + + def __populate_closed_issues(self, r: Record, nr: int) -> None: """ Populates the service chapters with closed issues. - @param record: The Record object representing the closed issue. + @param r: The Record object representing the closed issue. @param nr: The number of the record. @return: None """ # check record properties if it fits to a chapter: CLOSED_ISSUES_WITHOUT_PULL_REQUESTS populated = False - if record.pulls_count == 0: - self.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].add_row(nr, record.to_chapter_row()) + if len(r.pull_requests) == 0: + self.chapters[CLOSED_ISSUES_WITHOUT_PULL_REQUESTS].add_row(nr, r.to_chapter_row()) self.used_record_numbers.append(nr) populated = True # check record properties if it fits to a chapter: CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS - if not record.contains_min_one_label(self.user_defined_labels): + if not r.contains_min_one_label(self.user_defined_labels): # check if the record is already present among the chapters if self.__is_row_present(nr) and not self.duplicity_allowed(): return - self.chapters[CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS].add_row(nr, record.to_chapter_row()) + self.chapters[CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS].add_row(nr, r.to_chapter_row()) self.used_record_numbers.append(nr) populated = True - if record.pulls_count > 0: + if len(r.pull_requests) > 0: # the record looks to be valid closed issue with 1+ pull requests return @@ -149,51 +175,51 @@ def __populate_closed_issues(self, record: Record, nr: int) -> None: if self.__is_row_present(nr) and not self.duplicity_allowed(): return - self.chapters[OTHERS_NO_TOPIC].add_row(nr, record.to_chapter_row()) + self.chapters[OTHERS_NO_TOPIC].add_row(nr, r.to_chapter_row()) self.used_record_numbers.append(nr) - def __populate_pr(self, record: Record, nr: int) -> None: + def __populate_pr(self, r: Record, nr: int) -> None: """ Populates the service chapters with pull requests. - @param record: The Record object representing the pull request. + @param r: The Record object representing the pull request. @param nr: The number of the record. @return: None """ - if record.is_merged_pr: + if Record.is_pull_request_merged(r.pull_requests[0]): # check record properties if it fits to a chapter: MERGED_PRS_WITHOUT_ISSUE - if not record.pr_contains_issue_mentions and not record.contains_min_one_label(self.user_defined_labels): + if not r.pr_contains_issue_mentions and not r.contains_min_one_label(self.user_defined_labels): if self.__is_row_present(nr) and not self.duplicity_allowed(): return - self.chapters[MERGED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS].add_row(nr, record.to_chapter_row()) + self.chapters[MERGED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS].add_row(nr, r.to_chapter_row()) self.used_record_numbers.append(nr) # check record properties if it fits to a chapter: MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES - if record.pr_contains_issue_mentions: + if r.pr_contains_issue_mentions: if self.__is_row_present(nr) and not self.duplicity_allowed(): return - self.chapters[MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES].add_row(nr, record.to_chapter_row()) + self.chapters[MERGED_PRS_LINKED_TO_NOT_CLOSED_ISSUES].add_row(nr, r.to_chapter_row()) self.used_record_numbers.append(nr) - if not record.is_present_in_chapters: + if not r.is_present_in_chapters: if self.__is_row_present(nr) and not self.duplicity_allowed(): return - self.chapters[OTHERS_NO_TOPIC].add_row(nr, record.to_chapter_row()) + self.chapters[OTHERS_NO_TOPIC].add_row(nr, r.to_chapter_row()) self.used_record_numbers.append(nr) # check record properties if it fits to a chapter: CLOSED_PRS_WITHOUT_ISSUE elif ( - record.is_closed - and not record.pr_contains_issue_mentions - and not record.contains_min_one_label(self.user_defined_labels) + r.is_state(PR_STATE_CLOSED) + and not r.pr_contains_issue_mentions + and not r.contains_min_one_label(self.user_defined_labels) ): if self.__is_row_present(nr) and not self.duplicity_allowed(): return - self.chapters[CLOSED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS].add_row(nr, record.to_chapter_row()) + self.chapters[CLOSED_PRS_WITHOUT_ISSUE_AND_USER_DEFINED_LABELS].add_row(nr, r.to_chapter_row()) self.used_record_numbers.append(nr) else: @@ -201,10 +227,10 @@ def __populate_pr(self, record: Record, nr: int) -> None: return # not record.is_present_in_chapters: - self.chapters[OTHERS_NO_TOPIC].add_row(nr, record.to_chapter_row()) + self.chapters[OTHERS_NO_TOPIC].add_row(nr, r.to_chapter_row()) self.used_record_numbers.append(nr) - def __is_row_present(self, nr: int) -> bool: + def __is_row_present(self, nr: int | str) -> bool: return nr in self.used_record_numbers @staticmethod diff --git a/release_notes_generator/record/record_factory.py b/release_notes_generator/record/record_factory.py index 92e6b0d9..74f7a5cf 100644 --- a/release_notes_generator/record/record_factory.py +++ b/release_notes_generator/record/record_factory.py @@ -26,14 +26,19 @@ from github.Repository import Repository from github.Commit import Commit -from release_notes_generator.model.record import Record +from release_notes_generator.model.commit_record import CommitRecord +from release_notes_generator.model.base_record import Record +from release_notes_generator.model.issue_record import IssueRecord +from release_notes_generator.model.pull_request_record import PullRequestRecord from release_notes_generator.utils.decorators import safe_call_decorator from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter -from release_notes_generator.utils.pull_reuqest_utils import extract_issue_numbers_from_body +from release_notes_generator.utils.pull_request_utils import extract_issue_numbers_from_body logger = logging.getLogger(__name__) +# TODO - make record types a classes + # pylint: disable=too-few-public-methods class RecordFactory: @@ -44,7 +49,7 @@ class RecordFactory: @staticmethod def generate( github: Github, repo: Repository, issues: list[Issue], pulls: list[PullRequest], commits: list[Commit] - ) -> dict[int, Record]: + ) -> dict[int | str, Record]: """ Generate records for release notes. @@ -52,14 +57,14 @@ def generate( @param repo: The repository. @param issues: The list of issues. @param pulls: The list of pull requests. - @param commits: The list of commits. + @param commits: The list of commits with no links to PRs! @return: A dictionary of records. """ - records = {} - pull_numbers = [pull.number for pull in pulls] + rate_limiter = GithubRateLimiter(github) + safe_call = safe_call_decorator(rate_limiter) - def create_record_for_issue(r: Repository, i: Issue): - records[i.number] = Record(r, i) + def create_record_for_issue(i: Issue): + records[i.number] = IssueRecord(repo, safe_call, i) logger.debug("Created record for issue %d: %s", i.number, i.title) def register_pull_request(pull: PullRequest): @@ -73,14 +78,13 @@ def register_pull_request(pull: PullRequest): ) parent_issue = safe_call(repo.get_issue)(parent_issue_number) if parent_issue is not None: - create_record_for_issue(repo, parent_issue) + create_record_for_issue(parent_issue) if parent_issue_number in records: 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: - records[pull.number] = Record(repo) - records[pull.number].register_pull_request(pull) + records[pull.number] = PullRequestRecord(repo, safe_call, pull) logger.debug( "Registering stand-alone PR %d: %s as mentioned Issue %d not found.", pull.number, @@ -88,41 +92,56 @@ def register_pull_request(pull: PullRequest): parent_issue_number, ) - def register_commit_to_record(commit: Commit) -> bool: - """ - Register a commit to a record if the commit is linked to an issue or a PR. - - @param commit: The commit to register. - @return: True if the commit was registered to a record, False otherwise - """ - for record in records.values(): - if record.is_commit_sha_present(commit.sha): - record.register_commit(commit) - return True - return False - - rate_limiter = GithubRateLimiter(github) - safe_call = safe_call_decorator(rate_limiter) + records: dict[int | str, Record] = {} + records_for_isolated_commits: dict[int | str, Record] = {} + pull_numbers = [pull.number for pull in pulls] + logger.debug("Creating records from issue.") + real_issue_counts = len(issues) # issues could contain PRs too - known behaviour from API for issue in issues: if issue.number not in pull_numbers: - create_record_for_issue(repo, issue) + logger.debug("Calling create issue for number %s", issue.number) + create_record_for_issue(issue) + else: + logger.debug("Detected pr number %s among issues", issue.number) + real_issue_counts -= 1 + logger.debug("Creating records from Pull Requests.") for pull in pulls: if not extract_issue_numbers_from_body(pull): - records[pull.number] = Record(repo) - records[pull.number].register_pull_request(pull) + records[pull.number] = PullRequestRecord(repo, safe_call, pull) logger.debug("Created record for PR %d: %s", pull.number, pull.title) else: register_pull_request(pull) - detected_prs_count = sum(register_commit_to_record(commit) for commit in commits) - + logger.debug("Registering commits to Pull Requests.") + """Why are commits needed: + - identify developers - as commit authors + - identify contributors - as co-authors in commit message + - identify direct commits (no PRs related) + """ + # cycle across all record's PRs & ask for their commits + for record in records.values(): + record.fetch_pr_commits() + + # cycle across all record's PR's commits & identify direct commits + linked_commits_by_sha: set[str] = set() + for r in records.values(): + linked_commits_by_sha.update(r.get_sha_of_all_commits()) + + for c in commits: + if c.sha not in linked_commits_by_sha: + isolated_record = CommitRecord(repo, safe_call) + isolated_record.register_commit(c) + records_for_isolated_commits[c.sha] = isolated_record + + records.update(records_for_isolated_commits) logger.info( - "Generated %d records from %d issues and %d PRs, with %d commits detected.", + "Generated %d records from %d issues and %d PRs, with %d commits detected. %d of commits are isolated.", len(records), - len(issues), + real_issue_counts, len(pulls), - detected_prs_count, + len(commits), + len(records_for_isolated_commits), ) return records diff --git a/release_notes_generator/utils/constants.py b/release_notes_generator/utils/constants.py index 5d2bcdef..4a9afa88 100644 --- a/release_notes_generator/utils/constants.py +++ b/release_notes_generator/utils/constants.py @@ -31,13 +31,24 @@ RUNNER_DEBUG = "RUNNER_DEBUG" ROW_FORMAT_ISSUE = "row-format-issue" ROW_FORMAT_PR = "row-format-pr" +ROW_FORMAT_COMMIT = "row-format-commit" ROW_FORMAT_LINK_PR = "row-format-link-pr" -SUPPORTED_ROW_FORMAT_KEYS = ["number", "title", "pull-requests"] +ROW_FORMAT_LINK_COMMIT = "row-format-link-commit" +SUPPORTED_ROW_ISSUE_FORMAT_KEYS = [ + "number", + "title", + "pull-requests", + "assignee", + "assignees", + "developed-by", + "co-authored-by", +] +SUPPORTED_ROW_PR_FORMAT_KEYS = ["number", "title", "assignee", "assignees", "developed-by", "co-authored-by"] +SUPPORTED_ROW_COMMIT_FORMAT_KEYS = ["sha", "author", "co-authored-by"] # Features WARNINGS = "warnings" PRINT_EMPTY_CHAPTERS = "print-empty-chapters" -CHAPTERS_TO_PR_WITHOUT_ISSUE = "chapters-to-pr-without-issue" # Pull Request states PR_STATE_CLOSED = "closed" @@ -53,6 +64,8 @@ RELEASE_NOTE_LINE_MARK = "-" # Service chapters titles +ISOLATED_COMMITS: str = "Isolated commits without Issue or PR ⚠️" + CLOSED_ISSUES_WITHOUT_PULL_REQUESTS: str = "Closed Issues without Pull Request ⚠️" CLOSED_ISSUES_WITHOUT_USER_DEFINED_LABELS: str = "Closed Issues without User Defined Labels ⚠️" diff --git a/release_notes_generator/utils/decorators.py b/release_notes_generator/utils/decorators.py index 5cd94eea..9f7428cb 100644 --- a/release_notes_generator/utils/decorators.py +++ b/release_notes_generator/utils/decorators.py @@ -23,7 +23,7 @@ from functools import wraps from typing import Callable, Optional, Any from github import GithubException -from requests.exceptions import Timeout, RequestException, ConnectionError as RequestsConnectionError +from requests.exceptions import Timeout, RequestException, ConnectionError from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter logger = logging.getLogger(__name__) @@ -64,7 +64,7 @@ def decorator(method: Callable) -> Callable: def wrapped(*args, **kwargs) -> Optional[Any]: try: return method(*args, **kwargs) - except (RequestsConnectionError, Timeout) as e: + except (ConnectionError, Timeout) as e: logger.error("Network error calling %s: %s", method.__name__, e, exc_info=True) return None except GithubException as e: @@ -74,7 +74,7 @@ def wrapped(*args, **kwargs) -> Optional[Any]: logger.error("HTTP error calling %s: %s", method.__name__, e, exc_info=True) return None except Exception as e: - logger.error("Unexpected error calling %s: %s", method.__name__, e, exc_info=True) + logger.error("%s by calling %s: %s.", type(e).__name__, method.__name__, e, exc_info=True) return None return wrapped diff --git a/release_notes_generator/utils/exceptions.py b/release_notes_generator/utils/exceptions.py new file mode 100644 index 00000000..9d01dd7a --- /dev/null +++ b/release_notes_generator/utils/exceptions.py @@ -0,0 +1,2 @@ +class NotSupportedException(Exception): + pass diff --git a/release_notes_generator/utils/github_rate_limiter.py b/release_notes_generator/utils/github_rate_limiter.py index 9d7dc67b..c0b07c7b 100644 --- a/release_notes_generator/utils/github_rate_limiter.py +++ b/release_notes_generator/utils/github_rate_limiter.py @@ -49,6 +49,8 @@ def wrapped_method(*args, **kwargs) -> Optional: remaining_calls = self.github_client.get_rate_limit().core.remaining reset_time = self.github_client.get_rate_limit().core.reset.timestamp() + logger.debug("Remaining calls: %s", remaining_calls) + if remaining_calls < 5: logger.info("Rate limit almost reached. Sleeping until reset time.") sleep_time = reset_time - (now := time.time()) diff --git a/release_notes_generator/utils/pull_reuqest_utils.py b/release_notes_generator/utils/pull_request_utils.py similarity index 100% rename from release_notes_generator/utils/pull_reuqest_utils.py rename to release_notes_generator/utils/pull_request_utils.py diff --git a/release_notes_generator/utils/utils.py b/release_notes_generator/utils/utils.py index f1a38695..1562316f 100644 --- a/release_notes_generator/utils/utils.py +++ b/release_notes_generator/utils/utils.py @@ -26,7 +26,12 @@ from github.GitRelease import GitRelease from github.Repository import Repository -from release_notes_generator.utils.constants import SUPPORTED_ROW_FORMAT_KEYS +from release_notes_generator.utils.constants import ( + SUPPORTED_ROW_ISSUE_FORMAT_KEYS, + SUPPORTED_ROW_PR_FORMAT_KEYS, + SUPPORTED_ROW_COMMIT_FORMAT_KEYS, +) +from release_notes_generator.utils.exceptions import NotSupportedException logger = logging.getLogger(__name__) @@ -69,7 +74,19 @@ def detect_row_format_invalid_keywords(row_format: str, row_type: str = "Issue") """ errors = [] 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] + match row_type: + case "Issue": + supported_keys = SUPPORTED_ROW_ISSUE_FORMAT_KEYS + case "PR": + supported_keys = SUPPORTED_ROW_PR_FORMAT_KEYS + case "Commit": + supported_keys = SUPPORTED_ROW_COMMIT_FORMAT_KEYS + case _: + raise NotSupportedException(f"Row type '{row_type}' is not supported.") + + invalid_keywords = [keyword for keyword in keywords_in_braces if keyword not in supported_keys] if invalid_keywords: - errors.append(f"Invalid {row_type} row format keyword(s) found: {', '.join(invalid_keywords)}") + errors.append( + f"Invalid {row_type} row format '{row_format}'. Invalid keyword(s) found: {', '.join(invalid_keywords)}" + ) return errors diff --git a/tests/conftest.py b/tests/conftest.py index cdd09f7e..f6e54394 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,7 +28,7 @@ from github.Repository import Repository from release_notes_generator.model.service_chapters import ServiceChapters -from release_notes_generator.model.record import Record +from release_notes_generator.model.base_record import Record from release_notes_generator.model.chapter import Chapter from release_notes_generator.model.custom_chapters import CustomChapters from release_notes_generator.utils.constants import ISSUE_STATE_OPEN, ISSUE_STATE_CLOSED, PR_STATE_CLOSED, PR_STATE_OPEN @@ -120,7 +120,7 @@ def mock_issue_open(mocker): label2 = mocker.Mock(spec=MockLabel) label2.name = "label2" issue.labels = [label1, label2] - issue.number = 122 + issue.id = 122 issue.title = "I1 open" issue.state_reason = None return issue @@ -135,7 +135,7 @@ def mock_issue_open_2(mocker): label2 = mocker.Mock(spec=MockLabel) label2.name = "label2" issue.labels = [label1, label2] - issue.number = 123 + issue.id = 123 issue.title = "I2 open" issue.state_reason = None return issue @@ -151,7 +151,7 @@ def mock_issue_closed(mocker): label2.name = "label2" issue.labels = [label1, label2] issue.title = "Fix the bug" - issue.number = 121 + issue.id = 121 return issue @@ -165,7 +165,7 @@ def mock_issue_closed_i1_bug(mocker): label2.name = "bug" issue.labels = [label1, label2] issue.title = "I1+bug" - issue.number = 122 + issue.id = 122 return issue @@ -179,7 +179,7 @@ def mock_pull_closed(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.labels = [label1] - pull.number = 123 + pull.id = 123 pull.merge_commit_sha = "merge_commit_sha" pull.title = "Fixed bug" pull.created_at = datetime.now() @@ -198,7 +198,7 @@ def mock_pull_closed_with_rls_notes_101(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.labels = [label1] - pull.number = 101 + pull.id = 101 pull.merge_commit_sha = "merge_commit_sha" pull.title = "Fixed bug" pull.created_at = datetime.now() @@ -217,7 +217,7 @@ def mock_pull_closed_with_rls_notes_102(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.labels = [label1] - pull.number = 102 + pull.id = 102 pull.merge_commit_sha = "merge_commit_sha" pull.title = "Fixed bug" pull.created_at = datetime.now() @@ -236,7 +236,7 @@ def mock_pull_merged_with_rls_notes_101(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.labels = [label1] - pull.number = 101 + pull.id = 101 pull.merge_commit_sha = "merge_commit_sha" pull.title = "Fixed bug" pull.created_at = datetime.now() @@ -255,7 +255,7 @@ def mock_pull_merged_with_rls_notes_102(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.labels = [label1] - pull.number = 102 + pull.id = 102 pull.merge_commit_sha = "merge_commit_sha" pull.title = "Fixed bug" pull.created_at = datetime.now() @@ -274,7 +274,7 @@ def mock_pull_merged(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.labels = [label1] - pull.number = 123 + pull.id = 123 pull.merge_commit_sha = "merge_commit_sha" pull.title = "Fixed bug" pull.created_at = datetime.now() @@ -293,7 +293,7 @@ def mock_pull_open(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.labels = [label1] - pull.number = 123 + pull.id = 123 pull.merge_commit_sha = None pull.title = "Fix bug" pull.created_at = datetime.now() @@ -311,7 +311,7 @@ def mock_pull_no_rls_notes(mocker): label1 = mocker.Mock(spec=MockLabel) label1.name = "label1" pull.labels = [label1] - pull.number = 123 + pull.id = 123 pull.title = "Fixed bug" return pull @@ -409,8 +409,8 @@ def record_with_two_issue_open_two_pulls_closed(request): mock_repo_fixture.full_name = "org/repo" records = {} - records[rec1.number] = rec1 - records[rec2.number] = rec2 + records[rec1.id] = rec1 + records[rec2.id] = rec2 return records diff --git a/tests/release_notes/__init__.py b/tests/release_notes_generator/__init__.py similarity index 100% rename from tests/release_notes/__init__.py rename to tests/release_notes_generator/__init__.py diff --git a/tests/release_notes/model/__init__.py b/tests/release_notes_generator/model/__init__.py similarity index 100% rename from tests/release_notes/model/__init__.py rename to tests/release_notes_generator/model/__init__.py diff --git a/tests/release_notes/model/test_base_chapters.py b/tests/release_notes_generator/model/test_base_chapters.py similarity index 100% rename from tests/release_notes/model/test_base_chapters.py rename to tests/release_notes_generator/model/test_base_chapters.py diff --git a/tests/release_notes/model/test_chapter.py b/tests/release_notes_generator/model/test_chapter.py similarity index 100% rename from tests/release_notes/model/test_chapter.py rename to tests/release_notes_generator/model/test_chapter.py diff --git a/tests/release_notes/model/test_custom_chapters.py b/tests/release_notes_generator/model/test_custom_chapters.py similarity index 100% rename from tests/release_notes/model/test_custom_chapters.py rename to tests/release_notes_generator/model/test_custom_chapters.py diff --git a/tests/release_notes/model/test_record.py b/tests/release_notes_generator/model/test_record.py similarity index 99% rename from tests/release_notes/model/test_record.py rename to tests/release_notes_generator/model/test_record.py index 7c46fcc3..a3bad912 100644 --- a/tests/release_notes/model/test_record.py +++ b/tests/release_notes_generator/model/test_record.py @@ -58,7 +58,7 @@ def test_record_properties_with_pull(mock_pull_closed, record_with_no_issue_one_ # authors & contributors - not supported now by code def test_record_properties_authors_contributors(record_with_no_issue_one_pull_closed): - assert record_with_no_issue_one_pull_closed.authors is None + assert record_with_no_issue_one_pull_closed.assignee is None assert record_with_no_issue_one_pull_closed.contributors is None diff --git a/tests/release_notes/model/test_service_chapters.py b/tests/release_notes_generator/model/test_service_chapters.py similarity index 100% rename from tests/release_notes/model/test_service_chapters.py rename to tests/release_notes_generator/model/test_service_chapters.py diff --git a/tests/release_notes_generator/record/__init__.py b/tests/release_notes_generator/record/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/release_notes/test_record_factory.py b/tests/release_notes_generator/record/test_record_factory.py similarity index 100% rename from tests/release_notes/test_record_factory.py rename to tests/release_notes_generator/record/test_record_factory.py diff --git a/tests/test_action_inputs.py b/tests/release_notes_generator/test_action_inputs.py similarity index 100% rename from tests/test_action_inputs.py rename to tests/release_notes_generator/test_action_inputs.py diff --git a/tests/release_notes/test_release_notes_builder.py b/tests/release_notes_generator/test_release_notes_builder.py similarity index 99% rename from tests/release_notes/test_release_notes_builder.py rename to tests/release_notes_generator/test_release_notes_builder.py index 9002b719..599617d6 100644 --- a/tests/release_notes/test_release_notes_builder.py +++ b/tests/release_notes_generator/test_release_notes_builder.py @@ -173,10 +173,10 @@ def __init__(self, name): """ RELEASE_NOTES_DATA_SERVICE_CHAPTERS_CLOSED_ISSUE_NO_PR_NO_USER_LABELS = """### Closed Issues without Pull Request ⚠️ -- #121 _Fix the bug_ in +- #121 _Fix the bug_ ### Closed Issues without User Defined Labels ⚠️ -- 🔔 #121 _Fix the bug_ in +- 🔔 #121 _Fix the bug_ #### Full Changelog http://example.com/changelog @@ -224,7 +224,7 @@ def __init__(self, name): """ RELEASE_NOTES_DATA_CLOSED_ISSUE_NO_PR_WITH_USER_LABELS = """### Closed Issues without Pull Request ⚠️ -- #121 _Fix the bug_ in +- #121 _Fix the bug_ #### Full Changelog http://example.com/changelog diff --git a/tests/test_release_notes_generator.py b/tests/release_notes_generator/test_release_notes_generator.py similarity index 100% rename from tests/test_release_notes_generator.py rename to tests/release_notes_generator/test_release_notes_generator.py diff --git a/tests/utils/__init__.py b/tests/release_notes_generator/utils/__init__.py similarity index 100% rename from tests/utils/__init__.py rename to tests/release_notes_generator/utils/__init__.py diff --git a/tests/release_notes_generator/utils/test_decorators.py b/tests/release_notes_generator/utils/test_decorators.py new file mode 100644 index 00000000..65dab39e --- /dev/null +++ b/tests/release_notes_generator/utils/test_decorators.py @@ -0,0 +1,142 @@ +# +# 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 github import GithubException + +from release_notes_generator.utils.decorators import debug_log_decorator, safe_call_decorator +from requests.exceptions import Timeout, RequestException, ConnectionError + +# sample function to be decorated +def sample_function(x, y): + return x + y + + +# debug_log_decorator + + +def test_debug_log_decorator(mocker): + # Mock logging + mock_log_debug = mocker.patch("release_notes_generator.utils.decorators.logger.debug") + + decorated_function = debug_log_decorator(sample_function) + expected_call = [ + mocker.call("Calling method %s with args: %s and kwargs: %s", "sample_function", (3, 4), {}), + mocker.call("Method %s returned %s", "sample_function", 7), + ] + + result = decorated_function(3, 4) + + assert 7 == result + assert expected_call == mock_log_debug.call_args_list + + +# safe_call_decorator + + +def test_safe_call_decorator_success(rate_limiter): + @safe_call_decorator(rate_limiter) + def sample_method(x, y): + return x + y + + actual = sample_method(2, 3) + assert 5 == actual + + +def test_safe_call_decorator_network_error(rate_limiter, mocker): + mock_log_error = mocker.patch("release_notes_generator.utils.decorators.logger.error") + + @safe_call_decorator(rate_limiter) + def sample_method(): + raise ConnectionError("Test connection error") + + actual = sample_method() + + assert actual is None + assert mock_log_error.call_count == 1 + + args, kwargs = mock_log_error.call_args + assert "Network error calling %s: %s" == args[0] + assert "sample_method" == args[1] + assert isinstance(args[2], ConnectionError) + assert "Test connection error" ==str(args[2]) + assert kwargs['exc_info'] + + +def test_safe_call_decorator_github_api_error(rate_limiter, mocker): + mock_log_error = mocker.patch("release_notes_generator.utils.decorators.logger.error") + + @safe_call_decorator(rate_limiter) + def sample_method(): + status_code = 404 + error_data = { + "message": "Not Found", + "documentation_url": "https://developer.github.com/v3" + } + response_headers = { + "X-RateLimit-Limit": "60", + "X-RateLimit-Remaining": "0", + } + raise GithubException(status_code, error_data, response_headers) + + actual = sample_method() + + assert actual is None + assert mock_log_error.call_count == 1 + + args, kwargs = mock_log_error.call_args + assert 'GitHub API error calling %s: %s' == args[0] + assert 'sample_method' == args[1] + assert isinstance(args[2], GithubException) + assert '404 {"message": "Not Found", "documentation_url": "https://developer.github.com/v3"}' == str(args[2]) + assert kwargs['exc_info'] + + +def test_safe_call_decorator_http_error(mocker, rate_limiter): + mock_log_error = mocker.patch("release_notes_generator.utils.decorators.logger.error") + + @safe_call_decorator(rate_limiter) + def sample_method(): + raise RequestException("Test HTTP error") + + actual = sample_method() + + assert actual is None + assert mock_log_error.call_count == 1 + + args, kwargs = mock_log_error.call_args + assert "HTTP error calling %s: %s" == args[0] + assert "sample_method" == args[1] + assert isinstance(args[2], RequestException) + assert "Test HTTP error" == str(args[2]) + assert kwargs['exc_info'] + + +def test_safe_call_decorator_exception(rate_limiter, mocker): + mock_log_error = mocker.patch("release_notes_generator.utils.decorators.logger.error") + + @safe_call_decorator(rate_limiter) + def sample_method(x, y): + return x / y + + actual = sample_method(2, 0) + + assert actual is None + mock_log_error.assert_called_once() + exception_message = mock_log_error.call_args[0][0] + assert "%s by calling %s: %s" in exception_message + exception_type = mock_log_error.call_args[0][1] + assert "ZeroDivisionError" in exception_type + method_name = mock_log_error.call_args[0][2] + assert "sample_method" in method_name diff --git a/tests/utils/test_gh_action.py b/tests/release_notes_generator/utils/test_gh_action.py similarity index 100% rename from tests/utils/test_gh_action.py rename to tests/release_notes_generator/utils/test_gh_action.py diff --git a/tests/utils/test_github_rate_limiter.py b/tests/release_notes_generator/utils/test_github_rate_limiter.py similarity index 100% rename from tests/utils/test_github_rate_limiter.py rename to tests/release_notes_generator/utils/test_github_rate_limiter.py diff --git a/tests/utils/test_logging_config.py b/tests/release_notes_generator/utils/test_logging_config.py similarity index 100% rename from tests/utils/test_logging_config.py rename to tests/release_notes_generator/utils/test_logging_config.py diff --git a/tests/utils/test_pull_reuqest_utils.py b/tests/release_notes_generator/utils/test_pull_request_utils.py similarity index 97% rename from tests/utils/test_pull_reuqest_utils.py rename to tests/release_notes_generator/utils/test_pull_request_utils.py index c2352f43..5b39ec92 100644 --- a/tests/utils/test_pull_reuqest_utils.py +++ b/tests/release_notes_generator/utils/test_pull_request_utils.py @@ -16,7 +16,7 @@ from github.PullRequest import PullRequest -from release_notes_generator.utils.pull_reuqest_utils import extract_issue_numbers_from_body +from release_notes_generator.utils.pull_request_utils import extract_issue_numbers_from_body # extract_issue_numbers_from_body diff --git a/tests/utils/test_utils.py b/tests/release_notes_generator/utils/test_utils.py similarity index 56% rename from tests/utils/test_utils.py rename to tests/release_notes_generator/utils/test_utils.py index c7d3f15f..aae1d262 100644 --- a/tests/utils/test_utils.py +++ b/tests/release_notes_generator/utils/test_utils.py @@ -14,6 +14,9 @@ # limitations under the License. # +from pytest import raises + +from release_notes_generator.utils.exceptions import NotSupportedException from release_notes_generator.utils.utils import get_change_url, detect_row_format_invalid_keywords @@ -38,14 +41,31 @@ def test_get_change_url_with_git_release(mock_repo, mock_git_release): # detect_row_format_invalid_keywords -def test_valid_row_format(): - row_format = "{number} - {title} in {pull-requests}" +def test_valid_row_format_issue(): + row_format = "{number} - {title} in {pull-requests} {assignee} {assignees} {developed-by} {co-authored-by}" errors = detect_row_format_invalid_keywords(row_format) assert not errors, "Expected no errors for valid keywords" +def test_valid_row_format_pr(): + row_format = "{number} - {title} {assignee} {assignees} {developed-by} {co-authored-by}" + errors = detect_row_format_invalid_keywords(row_format, row_type="PR") + assert not errors, "Expected no errors for valid keywords" + + +def test_valid_row_format_commit(): + row_format = "{sha} - {author} {co-authored-by}" + errors = detect_row_format_invalid_keywords(row_format, row_type="Commit") + assert not errors, "Expected no errors for valid keywords" + + +def test_valid_row_format_another(): + with raises(NotSupportedException, match="Row type 'another' is not supported."): + detect_row_format_invalid_keywords("_", row_type="another") + + def test_multiple_invalid_keywords(): row_format = "{number} - {link} - {Title} and {Pull-requests}" - errors = detect_row_format_invalid_keywords(row_format) + errors = detect_row_format_invalid_keywords(row_format, row_type="Issue") assert len(errors) == 1 - assert "Invalid Issue row format keyword(s) found: link, Title, Pull-requests" in errors[0] + assert "Invalid Issue row format '{number} - {link} - {Title} and {Pull-requests}'. Invalid keyword(s) found: link, Title, Pull-requests" in errors[0] diff --git a/tests/utils/test_decorators.py b/tests/utils/test_decorators.py deleted file mode 100644 index a2f124e1..00000000 --- a/tests/utils/test_decorators.py +++ /dev/null @@ -1,67 +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.utils.decorators import debug_log_decorator, safe_call_decorator - - -# sample function to be decorated -def sample_function(x, y): - return x + y - - -# debug_log_decorator - - -def test_debug_log_decorator(mocker): - # Mock logging - mock_log_debug = mocker.patch("release_notes_generator.utils.decorators.logger.debug") - - decorated_function = debug_log_decorator(sample_function) - expected_call = [ - mocker.call("Calling method %s with args: %s and kwargs: %s", "sample_function", (3, 4), {}), - mocker.call("Method %s returned %s", "sample_function", 7), - ] - - result = decorated_function(3, 4) - - assert 7 == result - assert expected_call == mock_log_debug.call_args_list - - -# safe_call_decorator - - -def test_safe_call_decorator_success(rate_limiter): - @safe_call_decorator(rate_limiter) - def sample_method(x, y): - return x + y - - result = sample_method(2, 3) - assert 5 == result - - -def test_safe_call_decorator_exception(rate_limiter, mocker): - mock_log_error = mocker.patch("release_notes_generator.utils.decorators.logger.error") - - @safe_call_decorator(rate_limiter) - def sample_method(x, y): - return x / y - - result = sample_method(2, 0) - assert result is None - mock_log_error.assert_called_once() - assert "Unexpected error calling %s:" in mock_log_error.call_args[0][0] - assert "sample_method" in mock_log_error.call_args[0][1]