diff --git a/.github/workflows/CheckPRTemplate.yml b/.github/workflows/CheckPRTemplate.yml new file mode 100644 index 0000000000..91ba5dc310 --- /dev/null +++ b/.github/workflows/CheckPRTemplate.yml @@ -0,0 +1,54 @@ +name: Check PR Template + +on: + pull_request: + branches: + - develop + - 'release/*' + +jobs: + check: + name: Check PR Template + if: ${{ github.repository_owner == 'PaddlePaddle' }} + runs-on: ubuntu-latest + env: + PR_ID: ${{ github.event.pull_request.number }} + BASE_BRANCH: ${{ github.event.pull_request.base.ref }} + AUTHOR: ${{ github.event.pull_request.user.login }} + + steps: + - name: Cleanup + run: | + rm -rf * .[^.]* + + - name: Checkout base branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 1000 + + - name: Merge PR to test branch + run: | + git fetch origin pull/${PR_ID}/merge + git checkout -b test FETCH_HEAD + + - name: Setup Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install httpx + + - name: Check PR Template + env: + AGILE_PULL_ID: ${{ env.PR_ID }} + AGILE_COMPILE_BRANCH: ${{ env.BASE_BRANCH }} + AGILE_CHECKIN_AUTHOR: ${{ env.AUTHOR }} + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python scripts/CheckPRTemplate.py; EXCODE=$? + exit $EXCODE diff --git a/scripts/CheckPRTemplate.py b/scripts/CheckPRTemplate.py new file mode 100644 index 0000000000..c51d64b9bb --- /dev/null +++ b/scripts/CheckPRTemplate.py @@ -0,0 +1,182 @@ +# Copyright (c) 2025 PaddlePaddle Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import re +import sys + +import httpx + +# ============================== +# PR Template Definition +# ============================== +REPO_TEMPLATE = { + "FastDeploy": { + "sections": [ + "## Motivation", + "## Modifications", + "## Usage or Command", + "## Accuracy Tests", + "## Checklist", + ] + } +} + + +# ============================== +# Utility Functions +# ============================== +def remove_comments(body): + """Remove HTML-style comments () from Markdown.""" + if not body: + return "" + comment_pattern = re.compile(r"", re.DOTALL) + return comment_pattern.sub("", body).strip() + + +def check_section_content(body, section_titles): + """Extract content between section headers.""" + results = {} + valid_titles = [t for t in section_titles if t] + + for i, title in enumerate(valid_titles): + next_title = valid_titles[i + 1] if i + 1 < len(valid_titles) else None + + if next_title: + pattern = r"{}(.*?)(?={}|$)".format(re.escape(title), re.escape(next_title)) + else: + pattern = r"{}(.*)".format(re.escape(title)) # Match until the end + + match = re.search(pattern, body, re.DOTALL | re.IGNORECASE) + content = match.group(1).strip() if match else "" + results[title] = content + + return results + + +def parse_checklist(section_content): + """ + Parse a checklist section and return dict of items with checked status. + Example return: + { + 'Add at least a tag in the PR title.': False, + 'Format your code, run `pre-commit` before commit.': True, + ... + } + """ + items = {} + lines = section_content.splitlines() + for line in lines: + match = re.match(r"- \[( |x|X)\] (.+)", line) + if match: + checked = match.group(1).lower() == "x" + item_text = match.group(2).strip() + items[item_text] = checked + return items + + +def check_pr_template(repo, body): + """Check whether a PR description follows the expected template.""" + body = remove_comments(body) + template_info = REPO_TEMPLATE.get(repo) + + if not template_info: + print("[INFO] Repo '{}' not in REPO_TEMPLATE. Skipping check.".format(repo)) + return True, "" + + section_titles = template_info["sections"] + results = check_section_content(body, section_titles) + + # Check missing sections + missing = [sec for sec, content in results.items() if not content] + messages = [] + + if missing: + if len(missing) == 1: + messages.append("āŒ Missing section: {}. Please complete it.".format(missing[0])) + else: + messages.append("āŒ Missing sections: {}. Please complete them.".format(", ".join(missing))) + + # Check Checklist items if present + checklist_content = results.get("## Checklist", "") + if checklist_content: + checklist_items = parse_checklist(checklist_content) + unchecked = [item for item, checked in checklist_items.items() if not checked] + if unchecked: + messages.append("āŒ The following checklist items are not completed:") + for item in unchecked: + messages.append(f" - [ ] {item}") + + if messages: + messages.append( + "\nšŸ’” **Tips for fixing:**\n" + "1. Each PR must follow the standard FastDeploy PR template.\n" + "2. Ensure every section (Motivation, Modifications, Usage, Accuracy Tests, Checklist) " + "is clearly filled with relevant details.\n" + "3. You can refer to the official PR example: " + "https://github.com/PaddlePaddle/FastDeploy/blob/develop/.github/pull_request_template.md\n" + "4. For missing parts, please describe briefly what was changed or verified.\n\n" + "šŸ“© If you have any questions, please contact **@yubaoku(EmmonsCurse)**" + ) + return False, "\n".join(messages) + + return True, "āœ… PR description template check passed." + + +def get_pull_request(org, repo, pull_id, token): + """Fetch PR information from GitHub API.""" + url = f"https://api.github.com/repos/{org}/{repo}/pulls/{pull_id}" + headers = { + "Authorization": f"token {token}", + "Accept": "application/vnd.github+json", + } + + response = httpx.get(url, headers=headers, follow_redirects=True, timeout=30) + response.raise_for_status() + return response.json() + + +# ============================== +# Main Entry +# ============================== +def main(): + org = os.getenv("AGILE_ORG", "PaddlePaddle") + repo = os.getenv("AGILE_REPO", "FastDeploy") + pull_id = os.getenv("AGILE_PULL_ID") + token = os.getenv("GITHUB_API_TOKEN") + + if not pull_id or not token: + print("āŒ Environment variables AGILE_PULL_ID and GITHUB_API_TOKEN are required.") + sys.exit(1) + + try: + pr_info = get_pull_request(org, repo, pull_id, token) + except Exception as e: + print("āŒ Failed to fetch PR info: {}".format(e)) + sys.exit(2) + + body = pr_info.get("body", "") + title = pr_info.get("title", "") + user = pr_info.get("user", {}).get("login", "unknown") + + print("šŸ” Checking PR #{} by {}: {}".format(pull_id, user, title)) + + ok, message = check_pr_template(repo, body) + print(message) + + sys.exit(0 if ok else 7) + + +if __name__ == "__main__": + main()