mirror of
https://github.com/PX4/PX4-Autopilot.git
synced 2026-03-27 01:12:18 +08:00
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>
164 lines
5.0 KiB
Python
Executable File
164 lines
5.0 KiB
Python
Executable File
#!/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()
|