diff --git a/doc-changelog/action.yml b/doc-changelog/action.yml index 2173c398c..a99aa3f96 100644 --- a/doc-changelog/action.yml +++ b/doc-changelog/action.yml @@ -56,6 +56,13 @@ inputs: required: false type: string + toml-version: + description: > + Toml version used for retrieving the towncrier directory. + default: '0.10.2' + required: false + type: string + use-python-cache: description: > Whether to use the Python cache for installing previously downloaded @@ -65,6 +72,20 @@ inputs: default: true type: boolean + use-conventional-commits: + description: > + Use conventional commits to cateogrize towncrier fragments. + required: false + default: false + type: boolean + + use-default-towncrier-config: + description: > + Use the default towncrier configuration in the pyproject.toml file. + required: false + default: false + type: boolean + runs: using: "composite" steps: @@ -89,9 +110,51 @@ runs: - name: "Install towncrier" shell: bash run: | - python -m pip install --upgrade pip towncrier==${{ inputs.towncrier-version }} + python -m pip install --upgrade pip towncrier==${{ inputs.towncrier-version }} toml==${{ inputs.toml-version }} + + - name: "Get first letter of conventional commit type" + if: ${{ inputs.use-conventional-commits == 'true' }} + env: + PR_TITLE: ${{ github.event.pull_request.title }} + shell: python + run: | + import sys + sys.path.insert(1, '${{ github.action_path }}/../doc-changelog/') + + from parse_pr_title import get_first_letter_case + + pr_title = """${{ env.PR_TITLE }}""" + get_first_letter_case(pr_title) + + - name: "Check pull-request title follows conventional commits style" + if: ${{ (inputs.use-conventional-commits == 'true') && (env.FIRST_LETTER == 'lowercase') }} + uses: ansys/actions/commit-style@v6 + with: + token: ${{ inputs.token }} + + - name: "Check pull-request title follows conventional commits style with upper case" + if: ${{ (inputs.use-conventional-commits == 'true') && (env.FIRST_LETTER == 'uppercase') }} + uses: ansys/actions/commit-style@v6 + with: + token: ${{ inputs.token }} + use-upper-case: true + + - name: "Get conventional commit type from title" + if: ${{ inputs.use-conventional-commits == 'true' }} + env: + PR_TITLE: ${{ github.event.pull_request.title }} + shell: python + run: | + import sys + sys.path.insert(1, '${{ github.action_path }}/../doc-changelog/') + + from parse_pr_title import get_conventional_commit_type + + pr_title = """${{ env.PR_TITLE }}""" + get_conventional_commit_type(pr_title) - name: "Get labels in the pull request" + if: ${{ inputs.use-conventional-commits == 'false' }} env: OWNER: ${{ github.repository_owner }} REPO_NAME: ${{ github.event.repository.name }} @@ -106,44 +169,29 @@ runs: # For example, LABELS="enhancement maintenance" echo LABELS='"'$pr_labels'"' >> $GITHUB_ENV + - name: "Set CHANGELOG category based on conventional commit type" + if: ${{ inputs.use-conventional-commits == 'true' }} + shell: python + run: | + import sys + sys.path.insert(1, '${{ github.action_path }}/../doc-changelog/') + + from parse_pr_title import changelog_category_cc + + cc_type = ${{ env.CC_TYPE }} + changelog_category_cc(cc_type) + - name: "Set PR label environment variable" + if: ${{ inputs.use-conventional-commits == 'false' }} shell: python run: | - import os - - # Create a list of labels found in the pull request - # For example, "enhancement maintenance".split() -> ["enhancement", "maintenance"] - existing_labels = ${{ env.LABELS }}.split() - - # Dictionary with the key as a label from .github/workflows/label.yml and - # value as the corresponding section in the changelog - pr_labels = { - "enhancement": "added", - "bug": "fixed", - "dependencies": "dependencies", - "maintenance": "changed" - } - - def get_changelog_section(pr_labels, existing_labels): - """Find the changelog section corresponding to the label in the PR.""" - label_type = "" - - for key, value in pr_labels.items(): - if key in existing_labels: - label_type = value - return label_type - - # If no labels are in the PR, it goes into the miscellaneous category - label_type = "miscellaneous" - return label_type - - # Get the GITHUB_ENV variable - github_env = os.getenv('GITHUB_ENV') - - # Append the PR_LABEL with its value to GITHUB_ENV - # For example, PR_LABEL="added" if the PR had an "enhancement" label - with open(github_env, "a") as f: - f.write(f"PR_LABEL={get_changelog_section(pr_labels, existing_labels)}") + import sys + sys.path.insert(1, '${{ github.action_path }}/../doc-changelog/') + + from parse_pr_title import changelog_cateogry_labels + + labels = ${{ env.LABELS }} + changelog_cateogry_labels(labels) - name: "Remove PR fragment file if it already exists" env: @@ -165,23 +213,30 @@ runs: PR_TITLE: ${{ github.event.pull_request.title }} shell: python run: | - import os + import sys + sys.path.insert(1, '${{ github.action_path }}/../doc-changelog/') - # Retrieve title - clean_title = os.getenv('PR_TITLE') + from parse_pr_title import clean_pr_title + + pr_title = """${{ env.PR_TITLE }}""" + use_cc = True if "${{ inputs.use-conventional-commits }}" == "true" else False + + clean_pr_title(pr_title, use_cc) + + - name: "Append towncrier categories to pyproject.toml" + shell: python + run: | + import sys + sys.path.insert(1, '${{ github.action_path }}/../doc-changelog/') - # Remove extra whitespace - clean_title = clean_title.strip() + from parse_pr_title import add_towncrier_config - # Add backslash in front of backtick and double quote - clean_title = clean_title.replace("`", "\`").replace('"', '\\"') + repo_name = "${{ github.event.repository.name }}" + org_name = "${{ github.repository_owner }}" - # Get the GITHUB_ENV variable - github_env = os.getenv('GITHUB_ENV') + default_config = True if "${{ inputs.use-default-towncrier-config }}" == "true" else False - # Append the CLEAN_TITLE with its value to GITHUB_ENV - with open(github_env, "a") as f: - f.write(f"CLEAN_TITLE={clean_title}") + add_towncrier_config(org_name, repo_name, default_config) - name: "Create and commit towncrier fragment" env: @@ -191,7 +246,7 @@ runs: run: | # Changelog fragment file in the following format # For example, 20.added.md - fragment="${{ env.PR_NUMBER }}.${{ env.PR_LABEL }}.md" + fragment="${{ env.PR_NUMBER }}.${{ env.CHANGELOG_SECTION }}.md" # Create changelog fragment with towncrier # Fragment file contains the title of the PR diff --git a/doc-changelog/parse_pr_title.py b/doc-changelog/parse_pr_title.py new file mode 100644 index 000000000..a6d1b28d8 --- /dev/null +++ b/doc-changelog/parse_pr_title.py @@ -0,0 +1,350 @@ +import os + +import toml + + +def save_env_variable(env_var_name: str, env_var_value: str): + """Save environment variable to the GITHUB_ENV file. + + Parameters + ---------- + env_var_name: str + The name of the environment variable. + env_var_value: str + The value of the environment variable. + """ + # Get the GITHUB_ENV variable + github_env = os.getenv("GITHUB_ENV") + + # Save environment variable with its value + with open(github_env, "a") as file: + file.write(f"{env_var_name}={env_var_value}") + + +def get_first_letter_case(pr_title: str): + """Get the first letter of the pull request title and determine if it is uppercase or not. + + Parameters + ---------- + pr_title: str + The pull request title. + """ + index = 0 + # Get the pull request title + pr_title = f"""{pr_title}""" + # Get the first letter of the pull request title + first_letter = pr_title[index] + + # If the pull request title starts with a blank space, move to the next index + # until it finds a letter + while first_letter == " ": + index += 1 + try: + # Set the first letter + first_letter = pr_title[index] + except IndexError: + # If the pull request title never finds a letter, the pull request title + # is blank + print("Pull request title is blank") + exit(1) + + # If the first letter is lowercase, save the FIRST_LETTER environment variable + # as lowercase. Otherwise, save it as uppercase + if first_letter.islower(): + save_env_variable("FIRST_LETTER", "lowercase") + else: + save_env_variable("FIRST_LETTER", "uppercase") + + +def get_conventional_commit_type(pr_title: str): + """Get the conventional commit type from the pull request title. + + Parameters + ---------- + pr_title: str + The pull request title. + """ + # Get the pull request title + pr_title = f"""{pr_title}""" + # Get the index where the first colon is found in the pull request title + colon_index = pr_title.index(":") + # Get the conventional commit type from the pull request title (everything before the colon) + cc_type = '"' + pr_title[:colon_index] + '"' + # Save the conventional commit type as an environment variable, CC_TYPE + save_env_variable("CC_TYPE", cc_type) + + +def changelog_category_cc(cc_type: str): + """Get the changelog category based on the conventional commit type. + + Parameters + ---------- + cc_type: str + The conventional commit type from the pull request title. + """ + # Get conventional commit type from the environment variable + cc_type = cc_type.lower() + + # Dictionary whose keys are the conventional commit type and values are + # the changelog section + cc_type_changelog_dict = { + "feat": "added", + "fix": "fixed", + "docs": "documentation", + "build": "dependencies", + "revert": "miscellaneous", + "style": "miscellaneous", + "refactor": "miscellaneous", + "perf": "miscellaneous", + "test": "test", + "chore": "maintenance", + "ci": "maintenance", + } + + for key, value in cc_type_changelog_dict.items(): + if key in cc_type: + # Get the changelog section based on the conventional commit type + changelog_section = value + break + + # Save the changelog section to the CHANGELOG_SECTION environment variable + save_env_variable("CHANGELOG_SECTION", changelog_section) + + +def changelog_cateogry_labels(labels: str): + """Get the changelog category based on the labels in the pull request. + + Parameters + ---------- + labels: str + String containing the labels in the pull request. + """ + # Create a list of labels found in the pull request + # For example, "enhancement maintenance".split() -> ["enhancement", "maintenance"] + existing_labels = labels.split() + + # Dictionary with the key as a label from .github/workflows/label.yml and + # value as the corresponding section in the changelog + pr_labels = { + "enhancement": "added", + "bug": "fixed", + "documentation": "documentation", + "testing": "test", + "dependencies": "dependencies", + "CI/CD": "maintenance", + "maintenance": "maintenance", + } + + # Save the changelog section to the CHANGELOG_SECTION environment variable + save_env_variable( + "CHANGELOG_SECTION", get_changelog_section(pr_labels, existing_labels) + ) + + +def get_changelog_section(pr_labels: dict, existing_labels: list) -> str: + """Find the changelog section corresponding to the label in the pull request. + + Parameters + ---------- + pr_labels: dict + Dictionary containing pull request labels and their corresponding changelog sections. + existing_labels: list + List of the labels that are in the pull request. + + Returns + ------- + str + The changelog section. + """ + changelog_section = "" + + # For each label key and changelog section value + for key, value in pr_labels.items(): + # If the label is in the existing_labels list + if key in existing_labels: + # Save the changelog section based on the label + changelog_section = value + return changelog_section + + # If no labels are in the PR, it goes into the miscellaneous category + changelog_section = "miscellaneous" + return changelog_section + + +def clean_pr_title(pr_title: str, use_cc: str): + """Clean the pull request title. + + Parameters + ---------- + pr_title: str + The pull request title. + use_cc: str + Whether or not to use conventional commits to get the changelog section. + """ + # Retrieve title + clean_title = pr_title + + # If using conventional commits, remove it from title + if use_cc: + colon_index = clean_title.index(":") + clean_title = clean_title[colon_index + 1 :] + + # Remove extra whitespace + clean_title = clean_title.strip() + + # Add backslash in front of backtick and double quote + clean_title = clean_title.replace("`", "\\`").replace('"', '\\"') + + # Save the clean pull request title as the CLEAN_TITLE environment variable + save_env_variable("CLEAN_TITLE", clean_title) + + +def add_towncrier_config(org_name: str, repo_name: str, default_config: bool): + """Append the missing towncrier information to the pyproject.toml file. + + Parameters + ---------- + org_name: str + The name of the organization. + repo_name: str + The name of the repository. + default_config: bool + Whether or not to use the default towncrier configuration for the pyproject.toml file. + """ + # Load pyproject.toml file + with open("pyproject.toml", "a+") as file: + config = toml.load("pyproject.toml") + tool = config.get("tool", "DNE") + towncrier = tool.get("towncrier", "DNE") + + # List containing changelog sections under each release + changelog_sections = [ + "added", + "dependencies", + "documentation", + "fixed", + "maintenance", + "miscellaneous", + "test", + ] + + # Dictionary containing [tool.towncrier] keys and values + towncrier_config_sections = { + "directory": '"doc/changelog.d"', + "template": '"doc/changelog.d/changelog_template.jinja"', + "filename": {"web": '"doc/source/changelog.rst"', "repo": '"CHANGELOG.md"'}, + "start_string": { + "web": '".. towncrier release notes start\\n"', + "repo": '"\\n"', + }, + "title_format": { + "web": f'"`{{version}} `_ - {{project_date}}"', + "repo": f'"## [{{version}}](https://github.com/{org_name}/{repo_name}/releases/tag/v{{version}}) - {{project_date}}"', + }, + "issue_format": { + "web": f'"`#{{issue}} `_"', + "repo": f'"[#{{issue}}](https://github.com/{org_name}/{repo_name}/pull/{{issue}})"', + }, + } + + # Get the package name from [tool.flit.module] + flit = tool.get("flit", "DNE") + module = name = "" + if flit != "DNE": + module = flit.get("module", "DNE") + if module != ("DNE" or ""): + name = module.get("name", "DNE") + # If [tool.flit.module] name exists, create the package string + if name != ("DNE" and ""): + towncrier_config_sections["package"] = f'"{name}"' + + if default_config: + # If there is no towncrier configuration information or if [[tool.towncrier.type]] + # is the only towncrier information in the pyproject.toml file + if towncrier == "DNE" or len(towncrier) == 1: + # Write the [tool.towncrier] section + write_towncrier_config_section(file, towncrier_config_sections, True) + + if towncrier != "DNE": + # Get the existing [[tool.towncrier.type]] sections + types = towncrier.get("type", "DNE") + if types != "DNE": + remove_existing_types(types, changelog_sections) + + # Add missing [[tool.towncrier.type]] sections + write_missing_types(changelog_sections, file) + + +def write_towncrier_config_section( + file, towncrier_config_sections: dict, web_release_notes: bool +): + """Write the information in the [tool.towncrier] section. + + Parameters + ---------- + file: _io.TextIOWrapper + File to write to. + towncrier_config_sections: dict + Dictionary containing the [tool.towncrier] keys and values. + web_release_notes: bool + Whether or not the release notes are in the online documentation or the repository. + """ + # Append the tool.towncrier section + file.write("\n[tool.towncrier]\n") + + # For each key and value in the towncrier_config_sections dictionary + for key, value in towncrier_config_sections.items(): + # If the key has values that depend on the web_release_notes boolean + if ( + key == "filename" + or key == "start_string" + or key == "title_format" + or key == "issue_format" + ): + # Select the value based on the web_release_notes boolean + if web_release_notes: + file.write(f'{key} = {value["web"]}\n') + else: + file.write(f'{key} = {value["repo"]}\n') + else: + # Write the key and value from the towncrier_config_sections dictionary + file.write(f"{key} = {value}\n") + + +def remove_existing_types(types: list, changelog_sections: list): + """Remove the existing [[tool.towncrier.types]] from the changelog_sections list. + + Parameters + ---------- + types: list + List of dictionaries containing information under the [[tool.towncrier.types]] sections. + changelog_sections: list + List containing changelog sections under each release. + """ + for group in types: + # Remove changelog section if it exists under [[tool.towncrier.type]] so that + # only missing sections are appended to the pyproject.toml file + section = group.get("directory") + if section in changelog_sections: + changelog_sections.remove(section) + + +def write_missing_types(changelog_sections: list, file): + """Write the missing types in [[tool.towncrier.types]] + + Parameters + ---------- + changelog_sections: list + List containing changelog sections under each release. + file: _io.TextIOWrapper + File to write to. + """ + # Write each missing section to the pyproject.toml file + for section in changelog_sections: + file.write( + f""" +[[tool.towncrier.type]] +directory = "{section}" +name = "{section.title()}" +showcontent = true\n""" + ) diff --git a/doc/source/doc-actions/examples/doc-changelog-basic.yml b/doc/source/doc-actions/examples/doc-changelog-basic.yml index db1db3166..9475ec142 100644 --- a/doc/source/doc-actions/examples/doc-changelog-basic.yml +++ b/doc/source/doc-actions/examples/doc-changelog-basic.yml @@ -20,3 +20,7 @@ changelog-fragment: - uses: ansys/actions/doc-changelog@{{ version }} with: token: ${{ '{{ secrets.PYANSYS_CI_BOT_TOKEN }}' }} + # uncomment this line to use conventional commits instead of labels + # use-conventional-commits: true + # uncomment this if you don't have any towncrier configuration in your pyproject.toml file + # use-default-towncrier-config: true diff --git a/doc/source/migrations/docs-changelog-setup.rst b/doc/source/migrations/docs-changelog-setup.rst index d0be79216..740d86c9c 100644 --- a/doc/source/migrations/docs-changelog-setup.rst +++ b/doc/source/migrations/docs-changelog-setup.rst @@ -247,8 +247,13 @@ Also, replace ``ansys..`` with the name under ``tool.flit.modu showcontent = true [[tool.towncrier.type]] - directory = "changed" - name = "Changed" + directory = "dependencies" + name = "Dependencies" + showcontent = true + + [[tool.towncrier.type]] + directory = "documentation" + name = "Documentation" showcontent = true [[tool.towncrier.type]] @@ -257,8 +262,8 @@ Also, replace ``ansys..`` with the name under ``tool.flit.modu showcontent = true [[tool.towncrier.type]] - directory = "dependencies" - name = "Dependencies" + directory = "maintenance" + name = "Maintenance" showcontent = true [[tool.towncrier.type]] @@ -266,6 +271,11 @@ Also, replace ``ansys..`` with the name under ``tool.flit.modu name = "Miscellaneous" showcontent = true + [[tool.towncrier.type]] + directory = "test" + name = "Test" + showcontent = true + A reference pull request for these changes can be found in the `PyAnsys Geometry #1023 `_ pull request.