diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..984cec091 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ +# Description + +Please include a summary of the changes and the related issue. + +Related Issue Link (if applicable) + +## Type of change + +Only Select one of the change type + +- [ ] Enhancements (Adds functionality) +- [ ] Fixes (Fixes an issue, please reference the issue) +- [ ] Internal Changes (Documentation, Tests, etc.) +- [ ] Deprecations (Signals removal of a feature) +- [ ] Removals (Removes a feature) +- [ ] Security (Fixes a vulnerability) +- [ ] No Changelog (Release PR, Changelog PR, this PR will not be added to the changelog) + +# How Has This Been Tested? + +- [ ] Unit Test +- [ ] E2E Test +- [ ] Manual Test +- [ ] Not Applicable diff --git a/.github/workflows/check-pr-description.yml b/.github/workflows/check-pr-description.yml new file mode 100644 index 000000000..239c84bbc --- /dev/null +++ b/.github/workflows/check-pr-description.yml @@ -0,0 +1,45 @@ +name: "check-pr-description" + +on: + pull_request: + branches: [ master ] + +jobs: + check-description: + name: Check PR description to see if everything has been filled in + runs-on: ubuntu-latest + steps: + - uses: 8BitJonny/gh-get-current-pr@2.2.0 + id: PR + with: + # Authetication token to access GitHub APIs. (Can be omitted by default.) + github-token: ${{ github.token }} + # Verbose setting SHA when using Pull_Request event trigger to fix #16. (For push even trigger this is not necessary.) + sha: ${{ github.event.pull_request.head.sha }} + # Only return if PR is still open. (By default it returns PRs in any state.) + filterOutClosed: true + # Only return if PR is not in draft state. (By default it returns PRs in any state.) + filterOutDraft: true + + - name: Get PR description and save it to a file + if: steps.PR.outputs.pr_found == 'true' + run: | + echo "PR description: ${{ steps.PR.outputs.pr_body }}" + echo "${{ steps.PR.outputs.pr_body }}" > pr_description.txt + + - name: Get python script to check PR description + if: steps.PR.outputs.pr_found == 'true' + run: | + curl -o pr_utility.py https://raw.githubusercontent.com/Vincent056/compliance-operator/test-pr/utils/pr_utility.py + chmod +x pr_utility.py + + - name: Install Python + if: steps.PR.outputs.pr_found == 'true' + run: | + sudo apt-get update + sudo apt-get install python3-pip + + - name: Check PR description to see if everything has been filled in correctly + if: steps.PR.outputs.pr_found == 'true' + run: | + python3 pr_utility.py -i pr_description.txt -p \ No newline at end of file diff --git a/.github/workflows/merge-pr-description.yml b/.github/workflows/merge-pr-description.yml new file mode 100644 index 000000000..e0cc9e3f1 --- /dev/null +++ b/.github/workflows/merge-pr-description.yml @@ -0,0 +1,68 @@ +name: "merge-pr-description" + +on: + pull_request: + types: [ closed ] + +jobs: + generate-changelog: + name: Check PR description to see if everything has been filled in + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: 8BitJonny/gh-get-current-pr@2.2.0 + id: PR + with: + # Authetication token to access GitHub APIs. (Can be omitted by default.) + github-token: ${{ github.token }} + # Verbose setting SHA when using Pull_Request event trigger to fix #16. (For push even trigger this is not necessary.) + sha: ${{ github.event.pull_request.head.sha }} + # Only return if PR is still open. (By default it returns PRs in any state.) + filterOutClosed: false + # Only return if PR is not in draft state. (By default it returns PRs in any state.) + filterOutDraft: true + + - name: Get PR description and save it to a file + if: steps.PR.outputs.pr_found == 'true' + run: | + echo "PR description: ${{ steps.PR.outputs.pr_body }}" + echo "${{ steps.PR.outputs.pr_body }}" > pr_description.txt + + - name: Get python script to check PR description + if: steps.PR.outputs.pr_found == 'true' + run: | + curl -o pr_utility.py https://raw.githubusercontent.com/Vincent056/compliance-operator/test-pr/utils/pr_utility.py + chmod +x pr_utility.py + + - name: Install Python + if: steps.PR.outputs.pr_found == 'true' + run: | + sudo apt-get update + sudo apt-get install python3-pip + + - name: Save PR description to a file + if: steps.PR.outputs.pr_found == 'true' + run: | + python3 pr_utility.py -i pr_description.txt -p > pr_output.json + + - name: Check if PR is needed to be added to CHANGELOG.md + if: steps.PR.outputs.pr_found == 'true' + id: needs_changelog + run: | + NO_CHANGELOG=$(cat pr_output.json | jq '."change_type"."not_applicable"') + echo "NO_CHANGELOG=$NO_CHANGELOG" >> $GITHUB_OUTPUT + + - name: Update Changelog + if: steps.needs_changelog.outputs.NO_CHANGELOG == 'false' + run: | + python3 pr_utility.py -u -c ./CHANGELOG.md -i pr_output.json -pr ${{ github.event.pull_request.number }} + git config user.name github-actions[bot] + git config user.email github-actions[bot]@users.noreply.github.com + git add CHANGELOG.md + git commit -m "Update CHANGELOG.md for PR ${{ github.event.pull_request.number }}" + git push diff --git a/utils/pr_utility.py b/utils/pr_utility.py new file mode 100644 index 000000000..a6f54fa89 --- /dev/null +++ b/utils/pr_utility.py @@ -0,0 +1,203 @@ +import argparse +import json +import os, fileinput + +def parse_description(description_file): + # Read the description file + with open(description_file, 'r') as f: + description = f.read() + + # Initialize the JSON object + json_data = { + 'description': '', + 'change_type': { + 'enhancements': False, + 'fixes': False, + 'internal_changes': False, + 'deprecations': False, + 'removals': False, + 'security': False, + 'not_applicable': False + }, + 'testing': { + 'unit_test': False, + 'e2e_test': False, + 'manual_test': False, + 'not_applicable': False + } + } + + # Parse the description from the input + for line in description.split('\n'): + if line.startswith('## Description'): + continue + elif line.startswith('## Type of change'): + break + else: + json_data['description'] += line + + # Check description is templated text "Please include a summary of the changes and the related issue." or empty + if json_data['description'] == 'Please include a summary of the changes and the related issue.' or json_data['description'] == '': + raise ValueError("Description is empty or templated text.") + + # Initialize the change types + change_types = { + 'Enhancements (Adds functionality)': 'enhancements', + 'Fixes (Fixes an issue, please reference the issue)': 'fixes', + 'Internal Changes (Documentation, Tests, etc.)': 'internal_changes', + 'Deprecations (Signals removal of a feature)': 'deprecations', + 'Removals (Removes a feature)': 'removals', + 'Security (Fixes a vulnerability)': 'security', + 'No Changelog(Release PR, Changelog PR, this PR will not be added to the changelog)': 'not_applicable' + } + + # Parse change types from the input + for line in description.split('\n'): + for key, value in change_types.items(): + if line.startswith(f'- [x] {key}'): + json_data['change_type'][value] = True + + # Perform input validation for change types + # Only one can be selected + if not any(json_data['change_type'].values()): + raise ValueError("No change type selected.") + elif sum(json_data['change_type'].values()) > 1: + raise ValueError("Only one change type can be selected.") + + # Initialize the testing types + testing_types = { + 'Unit Test': 'unit_test', + 'E2E Test': 'e2e_test', + 'Manual Test': 'manual_test', + 'Not Applicable': 'not_applicable' + } + + # Parse testing types from the input + for line in description.split('\n'): + for key, value in testing_types.items(): + if line.startswith(f'- [x] {key}'): + json_data['testing'][value] = True + + # Perform input validation for testing types + if not any(json_data['testing'].values()): + raise ValueError("No testing type selected.") + elif json_data['testing']['not_applicable'] and any(json_data['testing'].values()) and sum(json_data['testing'].values()) > 1: + raise ValueError("Not Applicable cannot be selected with other testing types.") + + # Output the JSON object + return json.dumps(json_data, indent=4) + +def match_pr_type_to_changelog_section(pr_type): + # Initialize the change types + change_types = { + 'enhancements': 'Enhancements', + 'fixes': 'Fixes', + 'internal_changes': 'Internal Changes', + 'deprecations': 'Deprecations', + 'removals': 'Removals', + 'security': 'Security' + } + # Return the matching changelog section + return change_types[pr_type] + +def insert_changelog_entry(description_json_file, changelog_file, pr_number): + # Parse the json description from the input file + with open(description_json_file, 'r') as f: + json_data = json.load(f) + + + # Get the change type + change_type = '' + for key, value in json_data['change_type'].items(): + if value: + change_type = key + + # Get the description + description = json_data['description'] + + # Insert the new entry under the section for the given change type + with open(changelog_file, 'r+') as f: + # Read the file + changelog = f.read() + + # Find the Unreleased section + unreleased_index = changelog.find(f"\n## Unreleased\n") + + if unreleased_index == -1: + raise ValueError("Changelog Unreleased section not found.") + + nextrelease_index = changelog.find(f"\n## [") + + if nextrelease_index == -1: + nextrelease_index = len(changelog) + + changelog_type = match_pr_type_to_changelog_section(change_type) + + # Find the section for the given change type + section_index = changelog.find(f"\n### {changelog_type}\n", unreleased_index, nextrelease_index) + + if section_index == -1: + raise ValueError("Changelog section not found.") + + + nextpond_index = changelog.find(f"\n##", section_index+1) + + if nextpond_index == -1: + nextpond_index = len(changelog) + + new_entry = f"\n- {description} (#{pr_number})\n\n" + + next_entry_index = changelog.find(f"\n- ", section_index-1, nextpond_index) + + if next_entry_index == -1: + if nextpond_index == nextrelease_index: + secondhalf_index = nextpond_index + else: + secondhalf_index = nextpond_index + 1 + else: + secondhalf_index = next_entry_index + 1 + + first_half = changelog[:section_index + len(f"\n### {changelog_type}\n")] + + second_half = changelog[secondhalf_index:] + + changelog = first_half + new_entry + second_half + + # Write the file + f.seek(0) + f.write(changelog) + + + + + + + + + + + +if __name__ == '__main__': + + parser = argparse.ArgumentParser(description='PR changelog utility') + parser.add_argument('-i', '--input', type=str, help='Description file') + parser.add_argument('-p', '--parse', action='store_true', help='Parse the description from the input file') + parser.add_argument('-u', '--update', action='store_true', help='Update the changelog file with the entry from the input file') + parser.add_argument('-c', '--changelog', type=str, help='Changelog file') + parser.add_argument('-pr', '--number', type=str, help='PR number') + + + args = parser.parse_args() + + if args.parse: + if args.input: + print(parse_description(args.input)) + else: + print("Input file not specified.") + elif args.update: + if args.input and args.changelog and args.number: + insert_changelog_entry(args.input, args.changelog, args.number) + else: + print("Input file, changelog file, or PR number not specified.") + else: + parser.print_help()