mirror of
https://gitee.com/mirrors_PX4/PX4-Autopilot.git
synced 2026-10-23 06:53:34 +08:00
ci(workflows): add commit message and PR title quality checks
Add CI enforcement of conventional commit format for PR titles and commit messages. Includes three Python scripts under Tools/ci/: - conventional_commits.py: shared parsing/validation library - check_pr_title.py: validates PR title format, suggests fixes - check_commit_messages.py: checks commits for blocking errors (fixup/squash/WIP leftovers) and advisory warnings (review-response, formatter-only commits) The workflow (.github/workflows/commit_checks.yml) posts concise GitHub PR comments with actionable suggestions and auto-removes them once issues are resolved. Also updates CONTRIBUTING.md and docs with the conventional commits convention. Signed-off-by: Ramon Roche <mrpollo@gmail.com>
This commit is contained in:
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate that a PR title follows conventional commits format.
|
||||
|
||||
Format: type(scope): description
|
||||
|
||||
Can output plain text for CI logs or markdown for PR comments.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
from conventional_commits import (
|
||||
CONVENTIONAL_TYPES,
|
||||
EXEMPT_PREFIXES,
|
||||
parse_header,
|
||||
suggest_scope,
|
||||
suggest_type,
|
||||
)
|
||||
|
||||
|
||||
def suggest_title(title: str) -> str | None:
|
||||
"""Try to suggest a corrected title in conventional commits format."""
|
||||
stripped = title.strip()
|
||||
|
||||
# Remove common bracket prefixes like [docs], [CI], etc.
|
||||
bracket_match = re.match(r'^\[([^\]]+)\]\s*(.+)', stripped)
|
||||
if bracket_match:
|
||||
prefix = bracket_match.group(1).strip().lower()
|
||||
rest = bracket_match.group(2).strip()
|
||||
rest = re.sub(r'^[\-:]\s*', '', rest).strip()
|
||||
if len(rest) >= 5:
|
||||
# Try to map bracket content to a type
|
||||
commit_type = prefix if prefix in CONVENTIONAL_TYPES else suggest_type(rest)
|
||||
scope = suggest_scope(rest)
|
||||
if scope:
|
||||
return f"{commit_type}({scope}): {rest}"
|
||||
|
||||
# Already has old-style "subsystem: description" format - convert it
|
||||
colon_match = re.match(r'^([a-zA-Z][a-zA-Z0-9_/\-\. ]*): (.+)$', stripped)
|
||||
if colon_match:
|
||||
old_subsystem = colon_match.group(1).strip()
|
||||
desc = colon_match.group(2).strip()
|
||||
if len(desc) >= 5:
|
||||
commit_type = suggest_type(desc)
|
||||
# Use the old subsystem as scope (clean it up)
|
||||
scope = old_subsystem.lower().replace(' ', '_')
|
||||
return f"{commit_type}({scope}): {desc}"
|
||||
|
||||
# No format at all - try to guess both type and scope
|
||||
commit_type = suggest_type(stripped)
|
||||
scope = suggest_scope(stripped)
|
||||
if scope:
|
||||
desc = stripped[0].lower() + stripped[1:] if stripped else stripped
|
||||
return f"{commit_type}({scope}): {desc}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_title(title: str) -> bool:
|
||||
title = title.strip()
|
||||
|
||||
if not title:
|
||||
print("PR title is empty.", file=sys.stderr)
|
||||
return False
|
||||
|
||||
for prefix in EXEMPT_PREFIXES:
|
||||
if title.startswith(prefix):
|
||||
return True
|
||||
|
||||
if parse_header(title):
|
||||
return True
|
||||
|
||||
types_str = ', '.join(f'`{t}`' for t in CONVENTIONAL_TYPES.keys())
|
||||
print(
|
||||
f"PR title does not match conventional commits format.\n"
|
||||
f"\n"
|
||||
f" Title: {title}\n"
|
||||
f"\n"
|
||||
f"Expected format: type(scope): description\n"
|
||||
f"\n"
|
||||
f"Valid types: {types_str}\n"
|
||||
f"\n"
|
||||
f"Good examples:\n"
|
||||
f" feat(ekf2): add height fusion timeout\n"
|
||||
f" fix(mavlink): correct BATTERY_STATUS_V2 parsing\n"
|
||||
f" ci(workflows): migrate to reusable workflows\n"
|
||||
f" feat(boards/px4_fmu-v6x)!: remove deprecated driver API\n"
|
||||
f"\n"
|
||||
f"Bad examples:\n"
|
||||
f" fix stuff\n"
|
||||
f" Update file\n"
|
||||
f" ekf2: fix something (missing type prefix)\n"
|
||||
f"\n"
|
||||
f"See the contributing guide for details:\n"
|
||||
f" https://github.com/PX4/PX4-Autopilot/blob/main/CONTRIBUTING.md#commit-message-convention\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def format_markdown(title: str) -> str:
|
||||
"""Format a markdown PR comment body for a bad title."""
|
||||
lines = [
|
||||
"## \u274c PR title needs conventional commit format",
|
||||
"",
|
||||
"Expected format: `type(scope): description` "
|
||||
"([conventional commits](https://www.conventionalcommits.org/)).",
|
||||
"",
|
||||
"**Your title:**",
|
||||
f"> {title}",
|
||||
"",
|
||||
]
|
||||
|
||||
suggestion = suggest_title(title)
|
||||
if suggestion:
|
||||
lines.extend([
|
||||
"**Suggested fix:**",
|
||||
f"> {suggestion}",
|
||||
"",
|
||||
])
|
||||
|
||||
lines.extend([
|
||||
"**To fix this:** click the ✏️ next to the PR title at the top "
|
||||
"of this page and update it.",
|
||||
"",
|
||||
"See [CONTRIBUTING.md](https://github.com/PX4/PX4-Autopilot/blob/main/CONTRIBUTING.md#commit-message-convention) "
|
||||
"for details.",
|
||||
"",
|
||||
"---",
|
||||
"*This comment will be automatically removed once the issue is resolved.*",
|
||||
])
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description='Check PR title format')
|
||||
parser.add_argument('title', help='The PR title to validate')
|
||||
parser.add_argument('--markdown', action='store_true',
|
||||
help='Output markdown to stdout on failure')
|
||||
parser.add_argument('--markdown-file', metavar='FILE',
|
||||
help='Write markdown to FILE on failure')
|
||||
args = parser.parse_args()
|
||||
|
||||
passed = check_title(args.title)
|
||||
|
||||
if not passed:
|
||||
md = format_markdown(args.title)
|
||||
if args.markdown:
|
||||
print(md)
|
||||
if args.markdown_file:
|
||||
with open(args.markdown_file, 'w') as f:
|
||||
f.write(md + '\n')
|
||||
elif args.markdown_file:
|
||||
with open(args.markdown_file, 'w') as f:
|
||||
pass
|
||||
|
||||
sys.exit(0 if passed else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user