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 | 5 | pull_request: |
| 6 | 6 | |
| 7 | 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 | 19 | lint: |
| 9 | 20 | runs-on: utilities |
| 10 | 21 | steps: |