diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000000..02327ec43b2 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ + +## Change list + +- + +## Author(s) + + +## Fixes Issue(s) + + +## Updates PR(s) + + +## Additional Notes + \ No newline at end of file diff --git a/.github/workflows/changelog-file-generation.yaml b/.github/workflows/changelog-file-generation.yaml new file mode 100644 index 00000000000..699dec3bdda --- /dev/null +++ b/.github/workflows/changelog-file-generation.yaml @@ -0,0 +1,62 @@ +name: Generate Changelog +on: + pull_request: + types: [closed] + + workflow_dispatch: + inputs: + pr-number: + description: 'PR number to process' + required: true + type: number + +env: + SCRIPTS_PATH: .github/workflows/scripts/changelog_file_generation + CONFIG: pr_changelog_config.yaml + +jobs: + process-pr: + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.pull_request.merged) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r ${{ env.SCRIPTS_PATH }}/requirements.txt + + - name: Determine PR number + id: pr-number + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "pr_number=${{ github.event.inputs.pr-number }}" >> $GITHUB_OUTPUT + else + echo "pr_number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + fi + + - name: Run changelog generator + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPOSITORY: ${{ github.repository }} + run: | + python ${{ env.SCRIPTS_PATH}}/src/generate_pr_yaml.py --pr-number ${{ steps.pr-number.outputs.pr_number }} --config ${{ env.CONFIG }} --force + + - name: Commit generated files + run: | + git config --global user.name "GitHub Actions" + git config --global user.email "actions@github.com" + git add changelogs/unreleased/* + git commit -m "Add changelog for PR #${{ steps.pr-number.outputs.pr_number }}" || echo "No changes to commit" + git push \ No newline at end of file diff --git a/.github/workflows/pr-linter.yml b/.github/workflows/pr-linter.yml new file mode 100644 index 00000000000..f0113039a5f --- /dev/null +++ b/.github/workflows/pr-linter.yml @@ -0,0 +1,47 @@ +name: PR Linter +on: + pull_request: + types: [opened, edited, synchronize, reopened] + workflow_dispatch: + inputs: + pr-number: + description: 'PR number to process' + required: true + type: number + +env: + SCRIPTS_PATH: .github/workflows/scripts/changelog_file_generation + CONFIG: pr_changelog_config.yaml + +jobs: + validate-pr: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r ${{ env.SCRIPTS_PATH }}/requirements.txt + + - name: Determine PR number + id: pr-number + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "pr_number=${{ github.event.inputs.pr-number }}" >> $GITHUB_OUTPUT + else + echo "pr_number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT + fi + + - name: Run PR Linter + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPOSITORY: ${{ github.repository }} + run: | + python ${{ env.SCRIPTS_PATH}}/src/pr_linter.py --pr-number ${{ steps.pr-number.outputs.pr_number }} \ No newline at end of file diff --git a/.github/workflows/scripts/changelog_file_generation/pr_changelog_config.yaml b/.github/workflows/scripts/changelog_file_generation/pr_changelog_config.yaml new file mode 100644 index 00000000000..4a2ef3829a2 --- /dev/null +++ b/.github/workflows/scripts/changelog_file_generation/pr_changelog_config.yaml @@ -0,0 +1,86 @@ +# PR Changelog Generator Configuration +# ------------------------------------ +# Defines how to parse PR content and structure output YAML + +# Section Processing Configuration +sections: + # Change List Section + changes: + header: "Change list" + pattern: "^(\\w+): (.+)" + yaml_key: "changes" + processor: "key_value" + example: "- fix: Description of change" + required: true + allowed_keywords: + - fix + - feat + - breaking + - build + - ci + - balance + - performance + - docs + - test + + # Authors Section + authors: + header: "Author\\(s\\)" + pattern: "@?(\\w+)" + yaml_key: "authors" + default: "$pr_creator" + example: "@username" + merge_with: ["$pr_creator"] + processor: "flat_list" + + # Labels Section + fixes_issues: + header: "Fixes Issue\\(s\\)" + pattern: "([Cc]loses|[Ff]ixes|[Rr]esolves) (#?\\d+)" + yaml_key: "fixes_issues" + example: "- closes #123 or - fixes https://github.com/.../issues/456" + processor: "structured_links" + link_type: "issue" + + # Linked PRs Section + updates_prs: + header: "Updates PR\\(s\\)" + pattern: "([Uu]pdates|[Cc]ontinues) (#?\\d+)" + yaml_key: "updates_prs" + example: "- Updates #2" + processor: "structured_links" + link_type: "pr" + +# Title Processing Configuration +title_processing: + scope_pattern: "^\\[([\\w\/]+)\\]" # Pattern to extract scopes from title + scope_key: "affects" # YAML key for extracted scopes + clean_title: true # Whether to remove scopes from final title + example: "[GEN] short description, or [GEN/ZH] short description" # Example title format + allowed_scopes: # Validate title prefixes + - GEN + - ZH + - GITHUB + - BUILD + - AI + - ART + - AUDIO + +# Output Configuration +output: + required_keys: # Mandatory keys in final YAML + - "date" + - "title" + - "changes" + filename_template: "$pr_number_$clean_title.yaml" # Output filename pattern + directory: "changelogs/unreleased" # Output directory for YAML files + order: # Order of keys in final YAML + - date + - title + - affects + - changes + - labels + - links + - authors + - fixes_issues + - updates_prs \ No newline at end of file diff --git a/.github/workflows/scripts/changelog_file_generation/requirements.txt b/.github/workflows/scripts/changelog_file_generation/requirements.txt new file mode 100644 index 00000000000..4df84e3144f Binary files /dev/null and b/.github/workflows/scripts/changelog_file_generation/requirements.txt differ diff --git a/.github/workflows/scripts/changelog_file_generation/src/generate_pr_yaml.py b/.github/workflows/scripts/changelog_file_generation/src/generate_pr_yaml.py new file mode 100644 index 00000000000..c0e042da85c --- /dev/null +++ b/.github/workflows/scripts/changelog_file_generation/src/generate_pr_yaml.py @@ -0,0 +1,411 @@ +import argparse +import re +import yaml +from pathlib import Path +from github import Github, Auth, PullRequest, Repository +from datetime import datetime +from ruamel.yaml import YAML, CommentedMap +from typing import Any, Dict, List, Pattern, Tuple, Union + +import os +from dotenv import load_dotenv + +load_dotenv() + +# Type aliases for better readability +ConfigDict = Dict[str, Any] +SectionConfig = Dict[str, Union[str, List[str], bool]] + + +class PRProcessor: + """Main processor class for converting PR data to structured YAML""" + + def __init__(self, config_path: str) -> None: + """ + Initialize processor with configuration file + + Args: + config_path: Path to YAML configuration file + """ + with open(config_path) as f: + self.config: ConfigDict = yaml.safe_load(f) + + self.parsed_data: Dict[str, Any] = {} + self.repo_context: Dict[str, Union[str, int]] = {} + + def process_pr( + self, pr: PullRequest.PullRequest, repo: Repository.Repository + ) -> Dict[str, Any]: + """ + Process a GitHub PR and generate structured data + + Args: + pr: GitHub PullRequest object + repo: GitHub Repository object + + Returns: + Dictionary containing processed data and output filename + """ + self._setup_repo_context(pr, repo) + self._process_title(pr.title) + self._process_body(pr.body, repo.html_url) + self._process_github_metadata(pr) + return self._prepare_output() + + def _setup_repo_context(self, pr: Any, repo: Any) -> None: + """Store repository context including API object""" + self.repo_context = { + "repo_url": repo.html_url, + "pr_creator": pr.user.login, + "pr_number": pr.number, + "repo_object": repo, # Store actual repo object for API access + } + + def _process_title(self, title: str) -> None: + """ + Extract scopes and clean title based on configuration + + Args: + title: Original PR title from GitHub + """ + title_config = self.config.get("title_processing", {}) + scope_pattern: str = title_config.get("scope_pattern", r"^\[([\w\/]+)\]") + scopes: List[str] = [] + remaining_title = title + + # Extract scopes using configured pattern + while True: + match = re.match(scope_pattern, remaining_title) + if not match: + break + scopes.extend(match.group(1).upper().split("/")) + remaining_title = remaining_title[match.end() :].strip() + + # Validate allowed scopes + allowed_scopes = self.config["title_processing"].get("allowed_scopes", []) + if allowed_scopes: + invalid_scopes = [s for s in scopes if s.upper() not in allowed_scopes] + if invalid_scopes: + print( + f"Warning: Disallowed scopes detected - {', '.join(invalid_scopes)}" + ) + scopes = [s for s in scopes if s.upper() in allowed_scopes] + + # Store processed data + if scope_key := title_config.get("scope_key"): + self.parsed_data[scope_key] = scopes + + self.parsed_data["title"] = ( + remaining_title if title_config.get("clean_title", True) else title + ) + + def _process_body(self, pr_body: Union[str, None], repo_url: str) -> None: + """ + Process PR body content using configured section definitions + + Args: + pr_body: Raw PR body content from GitHub + repo_url: Base URL of the repository + """ + sections = self._parse_markdown_sections(pr_body or "") + + for section_name, config in self.config.get("sections", {}).items(): + if processed := self._process_section(sections, config, repo_url): + self._merge_data( + yaml_key=config["yaml_key"], + new_data=processed, + merge_keys=config.get("merge_with", []), + ) + + def _parse_markdown_sections(self, pr_body: str) -> Dict[str, List[str]]: + """ + Parse PR body into markdown sections + + Args: + pr_body: Raw PR body content + + Returns: + Dictionary of section names to their content lines + """ + sections: Dict[str, List[str]] = {} + current_section = None + cleaned_body = re.sub(r"", "", pr_body, flags=re.DOTALL) + + for line in cleaned_body.split("\n"): + line = line.strip() + if line.startswith("## "): + current_section = line[3:].split("", "", body, flags=re.DOTALL) + + # Build header patterns from config + header_patterns = { + name: re.compile(config["header"], re.IGNORECASE) + for name, config in self.config["sections"].items() + } + + for line in body.split("\n"): + line = line.strip() + if line.startswith("## "): + header_text = line[3:].split("" + ) -> None: + """Create or update existing linter comment on PR""" + existing_comment = None + for comment in pr.get_issue_comments(): + if bot_marker in comment.body: + existing_comment = comment + break + + full_message = f"{message}\n{bot_marker}" + + if existing_comment: + existing_comment.edit(full_message) + else: + pr.create_issue_comment(full_message) + +def format_comment(errors: List[str], pr_number: int) -> str: + """Format validation results as a GitHub comment""" + if not errors: + return f"## ✅ PR #{pr_number} Format Validation\nNo linting errors found!" + + error_list = "\n".join(f"❌ {error}\n" for error in errors) + return f"## ❌ PR #{pr_number} Format Issues\nThe following formatting issues were found:\n\n{error_list}\nPlease check the PR template guidelines." + +def main(): + """CLI for validating PRs using GitHub PR number.""" + parser = argparse.ArgumentParser( + description="Validate PR format and report via GitHub comments", + formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "--pr-number", + type=int, + required=True, + help="Pull request number to validate" + ) + parser.add_argument( + "--repo", + default=os.getenv("GH_REPOSITORY"), + type=str, + help="Repository in owner/repo format" + ) + parser.add_argument( + "--token", + default=os.getenv("GH_TOKEN"), + type=str, + help="GitHub token (default: GITHUB_TOKEN env var)" + ) + parser.add_argument( + "--config", + default="pr_changelog_config.yaml", + help="Path to validation config YAML" + ) + parser.add_argument( + "-q", "--quiet", + action="store_true", + help="Suppress output except exit status" + ) + + args = parser.parse_args() + + try: + # GitHub connection setup + g = Github(args.token) + repo = g.get_repo(args.repo) + pr = repo.get_pull(args.pr_number) + + # Run validation + linter = PRLinter(Path(__file__).parent.parent / args.config) + is_valid = linter.lint(pr.title, pr.body or "") + + # Format and post comment + comment_body = format_comment(linter.errors, args.pr_number) + create_or_update_comment(pr, comment_body) + + # CLI output unless quiet + if not args.quiet: + linter.print_errors() + print(f"\nPosted validation results to PR #{args.pr_number}") + + sys.exit(0 if is_valid else 1) + + except GithubException as e: + print(f"GitHub API Error: {str(e)}", file=sys.stderr) + sys.exit(2) + except Exception as e: + print(f"Runtime Error: {str(e)}", file=sys.stderr) + sys.exit(3) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Documentation/how_to_write_a_pull_request.md b/Documentation/how_to_write_a_pull_request.md new file mode 100644 index 00000000000..b71ea51ad3b --- /dev/null +++ b/Documentation/how_to_write_a_pull_request.md @@ -0,0 +1,148 @@ +# How to Write a Pull Request +When submitting a pull request, it is required that the pull request templated is followed. To do this requires some knowledge of markdown, and the format in which the different fields should be filled out. + +This document will provide a brief overview of the different fields, and what keywords should be used to fill them out. + +## Table of Contents +- [How to Write a Pull Request](#how-to-write-a-pull-request) + - [Table of Contents](#table-of-contents) + - [Required Fields](#required-fields) + - [Title](#title) + - [Change list](#change-list) + - [Optional Fields](#optional-fields) + - [Author(s)](#authors) + - [Fixes Issue(s)](#fixes-issues) + - [Updates PR(s)](#updates-prs) + - [Additional Notes](#additional-notes) + + +## Required Fields +The following fields are required to be filled out. This is to help the maintainers and reviews understand what the pull request is about, what changes are being made and what the pull request affects. + + +### Title +The title needs to follow the folling format: +``` +[] +``` +Where `` is the area the pull request is affecting, and the `` is a title for the pull request. The following ``'s are available: +- [GEN] = `Generals` +- [ZH] = `Zero Hour` +- [GITHUB] = `GitHub` +- [DOCS] = `Documentation` +- [BUILD] = `Build` + +Multiple types can be used if the pull request affects multiple areas. Types need to be separated by `/`. + +**Example Title:** +``` +[GEN/ZH] Improving AI pathfinding +``` +``` +[BUILD] Updated Cmake to version 3.20 +``` +``` +[GITHUB/DOCS] Updated pull request template +``` + + +### Change list +Changes made in the pull request should be in list form, and follow the following format: +``` +- : +``` +Where `` is the type of change being made, and `` is a short description of the change. The following ``'s are available: +- fix = `Fixes a bug` +- feat = `Adds a new feature` +- breaking = `Introduces a breaking change, breaks backwards compatibility` +- build = `Changes to the build system` +- ci = `Changes to the CI/CD pipeline` +- docs = `Changes to the documentation` +- performance = `Improves performance` +- test = `Adds or modifies tests` + +If there are multiple changes, they should all be listed, each in their own bullet point. + +**Exmaple** +``` +Change list +- fix: Fixed a bug where the game would crash when loading a map +- test: Added related tests for the bug fix +``` +``` +Change list +- breaking: Updated mismatch CRC calculation +- performance: Reduced GPU calls by 20% +- test: Added tests for the new CRC calculation +``` + +## Optional Fields +These next fields are optional, and can be removed if not needed. They are used to provide additional information about the pull request, and if one is applicable, it should be filled out. + +### Author(s) +If there are multiple authors for the pull request, they should be listed here. The format is as follows: +``` +- @ +``` +Where `` is the GitHub username of the author. If there are multiple authors, they should be listed in their own bullet point. + +**Example** +``` +* Removed because no additional authors +``` +``` +Author(s) +- @ +``` +``` +Author(s) +- @ +- @ +``` + +### Fixes Issue(s) +If the pull request fixes an issue, it should be listed here. The format is as follows: +``` +- # +``` +Where `` is the GitHub keyword for closing an issue. The following keywords are available: +- `Closes` = `Closes an issue` +- `Fixes` = `Fixes an issue` +- `Resolves` = `Resolves an issue` + +And `` is the number of the issue that is being fixed. If there are multiple issues, they should be listed in their own bullet point. + +**Example** +``` +Fixes Issue(s) +- Fixes #123 +``` +``` +Fixes Issue(s) +- Closes #123 +- Closes #124 +``` + +### Updates PR(s) +If a pull request is updating another pull request, or is a direct continuation of another, it should be listed here. The format is as follows: +``` +- # +``` +Where `` is the GitHub keyword for updating a PR. The following keywords are available: +- `Updates` = `Updates an issue` +- `Continues` = `Continues an issue` + +And `` is the number of the PR that is being updated. If there are multiple PRs, they should be listed in their own bullet point. + +**Example** +``` +Updates PR(s) +- Updates #123 +``` +``` +Updates PR(s) +- Continues #123 +``` + +### Additional Notes +Lastly in the additional notes section, any additional information that is not related to the changelog can be added. This can be anything from additional information about the pull request, to notes about the changes made. \ No newline at end of file