mirror of
https://github.com/Dasharo/edk2.git
synced 2026-06-13 10:16:24 -07:00
edb5331f78
This workflow runs when GitHub PRs are modified (edited, opened, reopened, and synchronized) to perform basic validation of the PR title and description. Right now, this includes: - Checking that the PR title is not empty - Checking that the PR body is not empty - Checking that the PR body meets the minimum text length. Where the minimum text length is defined to be the number of characters in the PR template with empty sections - Checking that PR template placeholder do not remain in the PR description If a check fails, a GitHub comment will be left on the PR and a PR status check failure will be present on the PR until the issue is resolved. Upon future runs of the workflow, existing PR validation message contents are hashed and compared to a new message that may potentially be posted. If the same comment is already posted in the PR, it is not posted again. Signed-off-by: Michael Kubacki <michael.kubacki@microsoft.com>
349 lines
13 KiB
Python
349 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
#
|
|
# Pull Request Formatting Validator
|
|
#
|
|
# This script validates basic pull request formatting requirements and can
|
|
# optionally post validation comments.
|
|
#
|
|
# Validation Checks Performed:
|
|
#
|
|
# 1. PR Title Validation:
|
|
# - Ensures the PR title is not empty or whitespace-only
|
|
# - Failure comment: "⚠️ Pull request title cannot be empty."
|
|
#
|
|
# 2. PR Body Validation:
|
|
# - Ensures the PR body is not empty, null, or whitespace-only
|
|
# - Ensures the PR body meets minimum length requirement (147 characters)
|
|
# - The minimum length is based on the PR template with empty sections
|
|
# - Empty body failure comment: "⚠️ Add a meaningful pull request
|
|
# description using the PR template."
|
|
# - Short body failure comment: "⚠️ Provide a more detailed pull request
|
|
# description using the PR template (current: X characters)."
|
|
#
|
|
# 3. PR Template Line Validation:
|
|
# - Checks that template placeholder lines are removed from the PR
|
|
# description
|
|
# - Based on present template lines in the PR template file
|
|
# - Failure comment: "⚠️ Remove the following template lines from your
|
|
# PR description:" followed by list of remaining template lines
|
|
#
|
|
# The same comment (based on a body content hash) will not be posted multiple
|
|
# times to avoid spamming the PR with duplicate messages.
|
|
#
|
|
# Copyright (c) Microsoft Corporation.
|
|
# SPDX-License-Identifier: BSD-2-Clause-Patent
|
|
#
|
|
|
|
import base64
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import sys
|
|
from typing import List, Optional
|
|
|
|
import GitHub
|
|
|
|
|
|
class PRValidator:
|
|
"""Logic for validating pull request formatting."""
|
|
|
|
VALIDATION_HEADER = "Pull Request Formatting Issues"
|
|
MIN_BODY_LENGTH = 147
|
|
TEMPLATE_LINE_PATTERN = r'^<_.*_>$'
|
|
GITHUB_PR_TEMPLATE_PATH = ".github/pull_request_template.md"
|
|
|
|
@property
|
|
def GITHUB_PR_TEMPLATE_URL(self) -> str:
|
|
"""The PR template URL path for the given PR validator instance."""
|
|
return (f"https://github.com/{self.owner}/{self.repo}/blob/master/"
|
|
f"{self.GITHUB_PR_TEMPLATE_PATH}")
|
|
|
|
def __init__(self, github_token: str, owner: str, repo: str,
|
|
pr_number: int):
|
|
"""Initialize the validator with GitHub context."""
|
|
self.github_token = github_token
|
|
self.owner = owner
|
|
self.repo = repo
|
|
self.pr_number = pr_number
|
|
self.validation_messages: List[str] = []
|
|
self.pr = None
|
|
|
|
def fetch_pr_data(self) -> tuple[str, str]:
|
|
"""Fetch PR title and body."""
|
|
self.pr = GitHub._get_pr(
|
|
self.github_token, self.owner, self.repo, self.pr_number
|
|
)
|
|
|
|
if not self.pr:
|
|
print("::error title=Failed to get PR data!::"
|
|
"Could not fetch PR information")
|
|
sys.exit(1)
|
|
|
|
title = self.pr.title or ''
|
|
body = self.pr.body or ''
|
|
|
|
return title, body
|
|
|
|
def fetch_template_content(self) -> Optional[str]:
|
|
"""Fetch the PR template content if it exists."""
|
|
try:
|
|
gh = GitHub._authenticate(self.github_token)
|
|
repo = gh.get_repo(f"{self.owner}/{self.repo}")
|
|
template_file = repo.get_contents(self.GITHUB_PR_TEMPLATE_PATH)
|
|
|
|
# Decode from base64
|
|
if hasattr(template_file, 'content'):
|
|
return base64.b64decode(template_file.content).decode('utf-8')
|
|
|
|
except Exception:
|
|
# Template doesn't exist, so there is nothing to return
|
|
pass
|
|
|
|
return None
|
|
|
|
def validate_title(self, title: str) -> bool:
|
|
"""Validate that the PR title is not empty."""
|
|
if not title or not title.strip():
|
|
self.validation_messages.append(
|
|
"⚠️ Pull request title cannot be empty.")
|
|
return False
|
|
return True
|
|
|
|
def validate_body(self, body: str) -> bool:
|
|
"""Validate PR body meets minimum requirements."""
|
|
is_valid = True
|
|
|
|
# Check if body is empty or just whitespace
|
|
if not body or body == "null" or not body.strip():
|
|
self.validation_messages.append(
|
|
"⚠️ Add a meaningful pull request description using the "
|
|
f"[PR template]({self.GITHUB_PR_TEMPLATE_URL})."
|
|
)
|
|
is_valid = False
|
|
elif len(body) < self.MIN_BODY_LENGTH:
|
|
self.validation_messages.append(
|
|
"⚠️ Provide a more detailed pull request description "
|
|
f"using the [PR template]({self.GITHUB_PR_TEMPLATE_URL}) "
|
|
f"(current: {len(body)} characters)."
|
|
)
|
|
is_valid = False
|
|
|
|
return is_valid
|
|
|
|
def validate_template_lines(self, body: str,
|
|
template_content: str) -> bool:
|
|
"""Check for leftover template lines in the PR body."""
|
|
if not template_content:
|
|
return True
|
|
|
|
# Find template lines that match the pattern
|
|
template_lines = []
|
|
for line in template_content.splitlines():
|
|
if re.match(self.TEMPLATE_LINE_PATTERN, line):
|
|
template_lines.append(line)
|
|
|
|
if not template_lines:
|
|
return True
|
|
|
|
# Check if any template lines are still in the PR body
|
|
found_template_lines = []
|
|
body_lines = body.splitlines()
|
|
|
|
for template_line in template_lines:
|
|
if template_line in body_lines:
|
|
found_template_lines.append(f"`{template_line}`")
|
|
|
|
if found_template_lines:
|
|
self.validation_messages.append(
|
|
"⚠️ Remove the following template lines from your PR "
|
|
"description:\n" + "\n".join(found_template_lines)
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
def validate_pr(self) -> bool:
|
|
"""Run all validation checks and return whether PR is valid."""
|
|
print("Fetching PR data...")
|
|
title, body = self.fetch_pr_data()
|
|
|
|
print("Fetching template content...")
|
|
template_content = self.fetch_template_content()
|
|
|
|
print("Running validation checks...")
|
|
title_valid = self.validate_title(title)
|
|
body_valid = self.validate_body(body)
|
|
template_valid = self.validate_template_lines(
|
|
body, template_content or '')
|
|
|
|
return title_valid and body_valid and template_valid
|
|
|
|
def post_validation_comment_with_dedup(self) -> bool:
|
|
"""Post a validation comment if there are errors, avoiding duplicates.
|
|
|
|
This function uses the existing GitHub.py infrastructure and adds
|
|
duplicate detection based on validation hash.
|
|
|
|
Returns:
|
|
bool: True if comment was posted, False if skipped or failed
|
|
"""
|
|
if not self.validation_messages:
|
|
return False
|
|
|
|
# Create comment body
|
|
messages_text = "\n".join(self.validation_messages)
|
|
comment_body = (
|
|
f"## {self.VALIDATION_HEADER}\n\n"
|
|
f"{messages_text}\n\n"
|
|
f"Address these issues and the validation will automatically "
|
|
f"re-run when you update your pull request."
|
|
)
|
|
|
|
# Create an issue comment validation hash to detect duplicate messages
|
|
validation_hash = hashlib.sha256(messages_text.encode()).hexdigest()
|
|
comment_body_with_hash = (
|
|
f"{comment_body}\n\n"
|
|
f"<!-- validation-hash: {validation_hash} -->"
|
|
)
|
|
|
|
# Check if any existing comments have the same hash
|
|
try:
|
|
if not self.pr:
|
|
self.pr = GitHub._get_pr(
|
|
self.github_token, self.owner, self.repo, self.pr_number
|
|
)
|
|
|
|
if not self.pr:
|
|
print("::error title=Failed to get PR!::"
|
|
"Could not fetch PR for comment checking")
|
|
return False
|
|
|
|
for comment in self.pr.get_issue_comments():
|
|
if (self.VALIDATION_HEADER in comment.body and
|
|
f"validation-hash: {validation_hash}" in comment.body):
|
|
print(f"::notice title=Skipped Duplicate Comment::"
|
|
f"Validation comment already exists (hash: "
|
|
f"{validation_hash[:8]}...)")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"::warning title=Comment Check Failed::"
|
|
f"Could not check for existing comments: {e}. "
|
|
f"Proceeding to post comment.")
|
|
|
|
try:
|
|
GitHub.leave_pr_comment(
|
|
self.github_token, self.owner, self.repo, self.pr_number,
|
|
comment_body_with_hash
|
|
)
|
|
print(f"::notice title=Posted Validation Comment::"
|
|
f"Comment posted with hash: {validation_hash[:8]}...")
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"::error title=Failed to Post Comment::"
|
|
f"Error posting validation comment: {e}")
|
|
return False
|
|
|
|
|
|
def post_validation_comment_standalone():
|
|
"""Standalone function for posting validation comments from env vars.
|
|
|
|
This function can be called independently to post validation comments
|
|
when validation has already been performed and environment variables
|
|
are set with the validation results.
|
|
"""
|
|
# Get required environment variables
|
|
github_token = os.environ.get('GITHUB_TOKEN')
|
|
pr_number_str = os.environ.get('PR_NUMBER')
|
|
validation_header = os.environ.get('VALIDATION_HEADER')
|
|
validation_messages = os.environ.get('VALIDATION_MESSAGES')
|
|
repo_full_name = os.environ.get('GITHUB_REPOSITORY')
|
|
|
|
if not all([github_token, pr_number_str, validation_header,
|
|
validation_messages, repo_full_name]):
|
|
print("::error title=Missing Environment Variables!::"
|
|
"Required: GITHUB_TOKEN, PR_NUMBER, VALIDATION_HEADER, "
|
|
"VALIDATION_MESSAGES, GITHUB_REPOSITORY")
|
|
return False
|
|
|
|
try:
|
|
pr_number = int(pr_number_str)
|
|
except ValueError:
|
|
print(f"::error title=Invalid PR Number!::"
|
|
f"PR_NUMBER is invalid, got '{pr_number_str}'")
|
|
return False
|
|
|
|
# Get the current owner/repo from GITHUB_REPOSITORY
|
|
try:
|
|
owner, repo = repo_full_name.split('/', 1)
|
|
except ValueError:
|
|
print(f"::error title=Invalid Repository Format!::"
|
|
f"GITHUB_REPOSITORY format is invalid: '{repo_full_name}'")
|
|
return False
|
|
|
|
print(f"::debug title=Posting Validation Comment::"
|
|
f"PR #{pr_number} in {owner}/{repo}")
|
|
|
|
validator = PRValidator(github_token, owner, repo, pr_number)
|
|
validator.validation_messages = validation_messages.split('\n')
|
|
|
|
return validator.post_validation_comment_with_dedup()
|
|
|
|
|
|
def main():
|
|
"""Main entry point for the PR validation script."""
|
|
# Check if we're being called to just post a comment
|
|
if len(sys.argv) > 1 and sys.argv[1] == '--post-comment':
|
|
success = post_validation_comment_standalone()
|
|
return 0 if success else 1
|
|
|
|
# Get required environment variables for validation
|
|
github_token = os.environ.get('GITHUB_TOKEN')
|
|
owner = os.environ.get('OWNER')
|
|
repo = os.environ.get('REPO')
|
|
pr_number_str = os.environ.get('PR_NUMBER')
|
|
|
|
if not all([github_token, owner, repo, pr_number_str]):
|
|
print("::error title=Missing Environment Variables!::"
|
|
"Required: GITHUB_TOKEN, OWNER, REPO, PR_NUMBER")
|
|
sys.exit(1)
|
|
|
|
try:
|
|
pr_number = int(pr_number_str)
|
|
except ValueError:
|
|
print(f"::error title=Invalid PR Number!::"
|
|
f"PR_NUMBER must be an integer, got '{pr_number_str}'")
|
|
sys.exit(1)
|
|
|
|
validator = PRValidator(github_token, owner, repo, pr_number)
|
|
|
|
print(f"Validating PR #{pr_number} in {owner}/{repo}")
|
|
|
|
is_valid = validator.validate_pr()
|
|
|
|
GitHub.set_github_output(
|
|
'validation_error', 'false' if is_valid else 'true'
|
|
)
|
|
|
|
if not is_valid:
|
|
messages_text = "\n".join(validator.validation_messages)
|
|
GitHub.set_github_env('VALIDATION_MESSAGES', messages_text)
|
|
GitHub.set_github_env(
|
|
'VALIDATION_HEADER', validator.VALIDATION_HEADER
|
|
)
|
|
|
|
print("\nPR formatting validation failed with the following issues:")
|
|
for message in validator.validation_messages:
|
|
print(f" {message}")
|
|
else:
|
|
print("\nPR formatting validation passed!")
|
|
|
|
# Always return success (0) so outputs are set properly
|
|
# The workflow will check the validation_error output to determine failure
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|