Based on mpv's script, under LGPL. https://github.com/mpv-player/mpv
Unified · Split
| @@ -0,0 +1,138 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | import json | ||
| 3 | import os | ||
| 4 | import re | ||
| 5 | import subprocess | ||
| 6 | import sys | ||
| 7 | import urllib.request as request | ||
| 8 | from collections.abc import Callable | ||
| 9 | |||
| 10 | LintBody = list[str] | ||
| 11 | LintFunction = Callable[[LintBody], bool] | ||
| 12 | LintRule = tuple[LintFunction, str] | ||
| 13 | |||
| 14 | def call(cmd: list[str]) -> str: | ||
| 15 | sys.stdout.flush() | ||
| 16 | ret = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, encoding="utf-8", text=True) | ||
| 17 | return ret.stdout | ||
| 18 | |||
| 19 | lint_rules: dict[str, LintRule] = {} | ||
| 20 | |||
| 21 | # A lint rule should return True if everything is okay | ||
| 22 | def lint_rule(description: str) -> Callable[[LintFunction], None]: | ||
| 23 | def f(func: LintFunction) -> None: | ||
| 24 | assert func.__name__ not in lint_rules | ||
| 25 | lint_rules[func.__name__] = (func, description) | ||
| 26 | return f | ||
| 27 | |||
| 28 | def get_pr_commits() -> list[tuple[str, list[str]]]: | ||
| 29 | pr_number = os.environ["GITHUB_REF"].split("/")[2] | ||
| 30 | api_url = os.environ["GITHUB_API_URL"] | ||
| 31 | repo = os.environ["GITHUB_REPOSITORY"] | ||
| 32 | |||
| 33 | url = f"{api_url}/repos/{repo}/pulls/{pr_number}/commits" | ||
| 34 | req = request.Request(url) | ||
| 35 | req.add_header("Accept", "application/vnd.github+json") | ||
| 36 | if "GITHUB_TOKEN" in os.environ: | ||
| 37 | req.add_header("Authorization", f"token {os.environ['GITHUB_TOKEN']}") | ||
| 38 | |||
| 39 | with request.urlopen(req) as response: | ||
| 40 | commits = json.load(response) | ||
| 41 | |||
| 42 | res = [] | ||
| 43 | for commit in commits: | ||
| 44 | sha = commit["sha"] | ||
| 45 | message = commit["commit"]["message"] | ||
| 46 | res.append((sha, message.splitlines())) | ||
| 47 | |||
| 48 | return res | ||
| 49 | |||
| 50 | def get_commits() -> list[tuple[str, list[str]]]: | ||
| 51 | if os.environ.get("GITHUB_EVENT_NAME") == "pull_request": | ||
| 52 | return get_pr_commits() | ||
| 53 | |||
| 54 | commit_range = sys.argv[1] if len(sys.argv) > 1 else None | ||
| 55 | if not commit_range: | ||
| 56 | return [] | ||
| 57 | |||
| 58 | commits = call(["git", "log", "-z", "--pretty=format:%H%n%B", commit_range]).split("\x00") | ||
| 59 | res = [] | ||
| 60 | for commit in commits: | ||
| 61 | if not commit.strip(): | ||
| 62 | continue | ||
| 63 | sha, message = commit.split("\n", 1) | ||
| 64 | res.append((sha, message.splitlines())) | ||
| 65 | |||
| 66 | return res | ||
| 67 | |||
| 68 | def lint_commit_message(body: list[str]) -> list[str]: | ||
| 69 | failed = [] | ||
| 70 | assert len(body) > 0, "Commit message must not be empty" | ||
| 71 | for k, v in lint_rules.items(): | ||
| 72 | if not v[0](body): | ||
| 73 | failed.append(f"* {v[1]} [{k}]") | ||
| 74 | |||
| 75 | return failed | ||
| 76 | |||
| 77 | def lint(commits: list[tuple[str, list[str]]]) -> bool: | ||
| 78 | print(f"Linting {len(commits)} commit(s):") | ||
| 79 | any_failed = False | ||
| 80 | for sha, body in commits: | ||
| 81 | if failed := lint_commit_message(body): | ||
| 82 | any_failed = True | ||
| 83 | print("-" * 40) | ||
| 84 | if os.environ.get("GITHUB_EVENT_NAME") == "pull_request": | ||
| 85 | print(f"Commit {sha[:8]}: {body[0] if body else "(empty)"}") | ||
| 86 | else: | ||
| 87 | sys.stdout.flush() | ||
| 88 | subprocess.run(["git", "-P", "show", "-s", sha]) | ||
| 89 | print("\nhas the following issues:") | ||
| 90 | print("\n".join(failed)) | ||
| 91 | print("-" * 40) | ||
| 92 | |||
| 93 | return not any_failed | ||
| 94 | |||
| 95 | |||
| 96 | NO_PREFIX_WHITELIST = \ | ||
| 97 | r"^Revert \"(.*)\"|^Reapply \"(.*)\"" | ||
| 98 | |||
| 99 | @lint_rule("Subject line must contain a prefix identifying the sub system") | ||
| 100 | def subsystem_prefix(body: LintBody) -> bool: | ||
| 101 | return bool(re.search(NO_PREFIX_WHITELIST, body[0]) or | ||
| 102 | re.search(r"^[\w/\.{},-]+: ", body[0])) | ||
| 103 | |||
| 104 | @lint_rule("First word after : must be lower case") | ||
| 105 | def description_lowercase(body: LintBody) -> bool: | ||
| 106 | # Allow all caps for acronyms and options with -- | ||
| 107 | return bool(re.search(NO_PREFIX_WHITELIST, body[0]) or | ||
| 108 | re.search(r": (?:[A-Z]{2,} |--[a-z]|[a-z0-9])", body[0])) | ||
| 109 | |||
| 110 | @lint_rule("Subject line must not end with a full stop") | ||
| 111 | def no_dot(body: LintBody) -> bool: | ||
| 112 | return not body[0].rstrip().endswith(".") | ||
| 113 | |||
| 114 | @lint_rule("There must be an empty line between subject and extended description") | ||
| 115 | def empty_line(body: LintBody) -> bool: | ||
| 116 | return len(body) == 1 or body[1].strip() == "" | ||
| 117 | |||
| 118 | @lint_rule("Do not use 'conventional commits' style") | ||
| 119 | def no_cc(body: LintBody) -> bool: | ||
| 120 | return not re.search(r"(?i)^(feat|fix|chore|refactor)[!:(]", body[0]) | ||
| 121 | |||
| 122 | @lint_rule("Subject line should be shorter than 120 characters") | ||
| 123 | def line_too_long(body: LintBody) -> bool: | ||
| 124 | revert = re.search(r"^Revert \"(.*)\"|^Reapply \"(.*)\"", body[0]) | ||
| 125 | return bool(revert or len(body[0]) <= 120) | ||
| 126 | |||
| 127 | @lint_rule("Prefix should not include file extension") | ||
| 128 | def no_file_exts(body: LintBody) -> bool: | ||
| 129 | return not re.search(r"[a-z0-9]\.([chm]|texi): ", body[0]) | ||
| 130 | |||
| 131 | |||
| 132 | if __name__ == "__main__": | ||
| 133 | commits = get_commits() | ||
| 134 | if not commits: | ||
| 135 | print("Usage: ./lint_commits.py <commit-range>") | ||
| 136 | exit(1) | ||
| 137 | if not lint(commits): | ||
| 138 | exit(2) | ||
| @@ -5,6 +5,17 @@ on: · 4 unmodified lines | |||
| 5 | pull_request: | 5 | pull_request: |
| 6 | 6 | ||
| 7 | jobs: | 7 | jobs: |
| 8 | commit_msg: | ||
| 9 | runs-on: utilities | ||
| 10 | if: ${{ forge.event_name == 'pull_request' }} | ||
| 11 | steps: | ||
| 12 | - name: Checkout | ||
| 13 | uses: actions/checkout@v4 | ||
| 14 | with: | ||
| 15 | sparse-checkout: .forgejo/pre-commit | ||
| 16 | - name: Lint | ||
| 17 | run: .forgejo/pre-commit/lint_commit_msg.py | ||
| 18 | |||
| 8 | lint: | 19 | lint: |
| 9 | runs-on: utilities | 20 | runs-on: utilities |
| 10 | steps: | 21 | steps: |