Path: blob/master/Tools/scripts/check_branch_conventions.py
13808 views
#!/usr/bin/env python312'''3Check PR branch commit conventions and markdown linting.45Validates:6- no merge commits7- no fixup! commits8- commit messages have a well-formed subsystem prefix before ':'9- commit subject lines are <= 160 characters10- changed markdown files pass markdownlint-cli21112AP_FLAKE8_CLEAN13'''1415from __future__ import annotations1617import argparse18import os19import re20import subprocess21import sys2223import build_script_base2425DOCS_URL = "https://ardupilot.org/dev/docs/submitting-patches-back-to-master.html"26MAX_SUBJECT_LEN = 16027BLACKLISTED_PREFIXES = {28"DEBUG",29"DRAFT",30"TEMP",31"TMP",32"WIP",33}34# spaces and quotes allowed to support Revert commits e.g. 'Revert "AP_Periph: ...'35PREFIX_RE = re.compile(r'^[-A-Za-z0-9._/" ]+$')3637# Enable colour when attached to a terminal or running under GitHub Actions38_colour = sys.stdout.isatty() or os.environ.get('GITHUB_ACTIONS') == 'true'39_GREEN = '\033[32m' if _colour else ''40_RED = '\033[31m' if _colour else ''41_YELLOW = '\033[33m' if _colour else ''42_RESET = '\033[0m' if _colour else ''4344PASS = f"{_GREEN}✓{_RESET}"45FAIL = f"{_RED}✗{_RESET}"46SKIP = f"{_YELLOW}~{_RESET}"474849class CheckBranchConventions(build_script_base.BuildScriptBase):5051DEFAULT_UPSTREAM = "origin/master"5253def __init__(self, base_branch: str | None = None) -> None:54super().__init__()55self.base_branch = base_branch5657def progress_prefix(self) -> str:58return "CBC"5960def run_git(self, args, show_output=True, source_dir=None):61cmd_list = ["git"] + list(args)62return self.run_program(63"SCB-GIT", cmd_list,64show_output=show_output, show_command=False, cwd=source_dir,65)6667def check_merge_commits(self) -> bool:68merge_commits = self.run_git(69["log", f"{self.base_branch}..HEAD", "--merges", "--oneline"],70show_output=False,71).strip()72if merge_commits:73print(f"{FAIL} Merge commits are not allowed:")74for line in merge_commits.splitlines():75print(f" {line}")76print(f" See: {DOCS_URL}")77return False78print(f"{PASS} No merge commits.")79return True8081def check_fixup_commits(self, commits: str) -> bool:82bad = [line for line in commits.splitlines() if "fixup!" in line]83if bad:84print(f"{FAIL} fixup! commits are not allowed:")85for line in bad:86print(f" {line}")87print(f" See: {DOCS_URL}")88return False89print(f"{PASS} No fixup! commits.")90return True9192def check_commit_messages(self, commits: str) -> bool:93ok = True94for line in commits.splitlines():95if not line.strip():96continue97# strip leading hash from --oneline format98subject = line.split(" ", 1)[1] if " " in line else line99if ":" not in subject:100print(f"{FAIL} Missing subsystem prefix: {line}")101print(f" Reword to e.g. 'AP_Compass: {subject}'")102print(f" See: {DOCS_URL}")103ok = False104continue105prefix = subject.split(":")[0]106if prefix.strip().upper() in BLACKLISTED_PREFIXES:107print(f"{FAIL} Bad subsystem prefix '{prefix}': {line}")108print(f" See: {DOCS_URL}")109ok = False110if not PREFIX_RE.match(prefix):111print(f"{FAIL} Malformed subsystem prefix '{prefix}': {line}")112print(" Prefix must contain only letters, digits, '.', '_', '/', '-', spaces, quotes.")113print(f" See: {DOCS_URL}")114ok = False115if ok:116print(f"{PASS} All commit messages have well-formed subsystem tags.")117return ok118119def check_commit_lengths(self, commits: str) -> bool:120ok = True121for line in commits.splitlines():122if not line.strip():123continue124if len(line) > MAX_SUBJECT_LEN:125print(f"{FAIL} Subject too long ({len(line)} chars, limit {MAX_SUBJECT_LEN}): {line}")126ok = False127if ok:128print(f"{PASS} All commit subject lines within {MAX_SUBJECT_LEN} characters.")129return ok130131def check_author_emails(self) -> bool:132emails = self.run_git(133["log", f"{self.base_branch}..HEAD", "--format=%ae"],134show_output=False,135).strip()136bad = []137for email in emails.splitlines():138if "example.com" in email:139bad.append(email)140if bad:141print(f"{FAIL} Author email(s) with example.com are not allowed:")142for email in bad:143print(f" {email}")144return False145print(f"{PASS} No unacceptable author emails.")146return True147148def check_markdown(self) -> bool:149changed_md = self.run_git(150["diff", "--name-only", "--diff-filter=AM",151f"{self.base_branch}...HEAD", "--", "*.md"],152show_output=False,153).strip()154if not changed_md:155print(f"{PASS} No markdown files changed.")156return True157158try:159result = subprocess.run(["markdownlint-cli2"] + changed_md.splitlines())160except FileNotFoundError:161print(f"{SKIP} markdownlint-cli2 not installed.")162return True163if result.returncode != 0:164print(f"{FAIL} Markdown linting errors found (see above).")165return False166167print(f"{PASS} Markdown files pass linting.")168return True169170def run(self) -> None:171if self.base_branch is None:172current = self.find_current_git_branch_or_sha1()173self.base_branch = self.find_git_branch_merge_base(current, self.DEFAULT_UPSTREAM)174self.progress(f"Using merge base with {self.DEFAULT_UPSTREAM}: {self.base_branch}")175176commits = self.run_git(177["log", f"{self.base_branch}..HEAD", "--oneline"],178show_output=False,179).strip()180181n = len(commits.splitlines()) if commits else 0182print(f"\nChecking {n} commit(s) since {self.base_branch}...\n")183184results = [185self.check_merge_commits(),186self.check_fixup_commits(commits),187self.check_commit_messages(commits),188self.check_commit_lengths(commits),189self.check_author_emails(),190self.check_markdown(),191]192193failures = results.count(False)194print(f"\n{'All checks passed.' if not failures else f'{failures} check(s) failed.'}")195sys.exit(0 if all(results) else 1)196197198if __name__ == "__main__":199parser = argparse.ArgumentParser(200description="Check PR branch commit conventions and markdown linting",201)202parser.add_argument(203"--base-branch",204default=None,205help="Upstream base branch or commit to compare against "206"(default: merge base of HEAD with origin/master)",207)208args = parser.parse_args()209CheckBranchConventions(args.base_branch).run()210211212