You've already forked FFmpeg
mirror of
https://github.com/FFmpeg/FFmpeg.git
synced 2025-08-04 22:03:09 +02:00
forgejo/lint_commit_msg: add script for commit message linting
Based on mpv's script, under LGPL. https://github.com/mpv-player/mpv Co-authored-by: Timo Rothenpieler <timo@rothenpieler.org> Signed-off-by: Kacper Michajłow <kasper93@gmail.com>
This commit is contained in:
committed by
Timo Rothenpieler
parent
d5f7a428b1
commit
cc6ad703b4
138
.forgejo/pre-commit/lint_commit_msg.py
Executable file
138
.forgejo/pre-commit/lint_commit_msg.py
Executable file
@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request as request
|
||||
from collections.abc import Callable
|
||||
|
||||
LintBody = list[str]
|
||||
LintFunction = Callable[[LintBody], bool]
|
||||
LintRule = tuple[LintFunction, str]
|
||||
|
||||
def call(cmd: list[str]) -> str:
|
||||
sys.stdout.flush()
|
||||
ret = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, encoding="utf-8", text=True)
|
||||
return ret.stdout
|
||||
|
||||
lint_rules: dict[str, LintRule] = {}
|
||||
|
||||
# A lint rule should return True if everything is okay
|
||||
def lint_rule(description: str) -> Callable[[LintFunction], None]:
|
||||
def f(func: LintFunction) -> None:
|
||||
assert func.__name__ not in lint_rules
|
||||
lint_rules[func.__name__] = (func, description)
|
||||
return f
|
||||
|
||||
def get_pr_commits() -> list[tuple[str, list[str]]]:
|
||||
pr_number = os.environ["GITHUB_REF"].split("/")[2]
|
||||
api_url = os.environ["GITHUB_API_URL"]
|
||||
repo = os.environ["GITHUB_REPOSITORY"]
|
||||
|
||||
url = f"{api_url}/repos/{repo}/pulls/{pr_number}/commits"
|
||||
req = request.Request(url)
|
||||
req.add_header("Accept", "application/vnd.github+json")
|
||||
if "GITHUB_TOKEN" in os.environ:
|
||||
req.add_header("Authorization", f"token {os.environ['GITHUB_TOKEN']}")
|
||||
|
||||
with request.urlopen(req) as response:
|
||||
commits = json.load(response)
|
||||
|
||||
res = []
|
||||
for commit in commits:
|
||||
sha = commit["sha"]
|
||||
message = commit["commit"]["message"]
|
||||
res.append((sha, message.splitlines()))
|
||||
|
||||
return res
|
||||
|
||||
def get_commits() -> list[tuple[str, list[str]]]:
|
||||
if os.environ.get("GITHUB_EVENT_NAME") == "pull_request":
|
||||
return get_pr_commits()
|
||||
|
||||
commit_range = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
if not commit_range:
|
||||
return []
|
||||
|
||||
commits = call(["git", "log", "-z", "--pretty=format:%H%n%B", commit_range]).split("\x00")
|
||||
res = []
|
||||
for commit in commits:
|
||||
if not commit.strip():
|
||||
continue
|
||||
sha, message = commit.split("\n", 1)
|
||||
res.append((sha, message.splitlines()))
|
||||
|
||||
return res
|
||||
|
||||
def lint_commit_message(body: list[str]) -> list[str]:
|
||||
failed = []
|
||||
assert len(body) > 0, "Commit message must not be empty"
|
||||
for k, v in lint_rules.items():
|
||||
if not v[0](body):
|
||||
failed.append(f"* {v[1]} [{k}]")
|
||||
|
||||
return failed
|
||||
|
||||
def lint(commits: list[tuple[str, list[str]]]) -> bool:
|
||||
print(f"Linting {len(commits)} commit(s):")
|
||||
any_failed = False
|
||||
for sha, body in commits:
|
||||
if failed := lint_commit_message(body):
|
||||
any_failed = True
|
||||
print("-" * 40)
|
||||
if os.environ.get("GITHUB_EVENT_NAME") == "pull_request":
|
||||
print(f"Commit {sha[:8]}: {body[0] if body else "(empty)"}")
|
||||
else:
|
||||
sys.stdout.flush()
|
||||
subprocess.run(["git", "-P", "show", "-s", sha])
|
||||
print("\nhas the following issues:")
|
||||
print("\n".join(failed))
|
||||
print("-" * 40)
|
||||
|
||||
return not any_failed
|
||||
|
||||
|
||||
NO_PREFIX_WHITELIST = \
|
||||
r"^Revert \"(.*)\"|^Reapply \"(.*)\""
|
||||
|
||||
@lint_rule("Subject line must contain a prefix identifying the sub system")
|
||||
def subsystem_prefix(body: LintBody) -> bool:
|
||||
return bool(re.search(NO_PREFIX_WHITELIST, body[0]) or
|
||||
re.search(r"^[\w/\.{},-]+: ", body[0]))
|
||||
|
||||
@lint_rule("First word after : must be lower case")
|
||||
def description_lowercase(body: LintBody) -> bool:
|
||||
# Allow all caps for acronyms and options with --
|
||||
return bool(re.search(NO_PREFIX_WHITELIST, body[0]) or
|
||||
re.search(r": (?:[A-Z]{2,} |--[a-z]|[a-z0-9])", body[0]))
|
||||
|
||||
@lint_rule("Subject line must not end with a full stop")
|
||||
def no_dot(body: LintBody) -> bool:
|
||||
return not body[0].rstrip().endswith(".")
|
||||
|
||||
@lint_rule("There must be an empty line between subject and extended description")
|
||||
def empty_line(body: LintBody) -> bool:
|
||||
return len(body) == 1 or body[1].strip() == ""
|
||||
|
||||
@lint_rule("Do not use 'conventional commits' style")
|
||||
def no_cc(body: LintBody) -> bool:
|
||||
return not re.search(r"(?i)^(feat|fix|chore|refactor)[!:(]", body[0])
|
||||
|
||||
@lint_rule("Subject line should be shorter than 120 characters")
|
||||
def line_too_long(body: LintBody) -> bool:
|
||||
revert = re.search(r"^Revert \"(.*)\"|^Reapply \"(.*)\"", body[0])
|
||||
return bool(revert or len(body[0]) <= 120)
|
||||
|
||||
@lint_rule("Prefix should not include file extension")
|
||||
def no_file_exts(body: LintBody) -> bool:
|
||||
return not re.search(r"[a-z0-9]\.([chm]|texi): ", body[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
commits = get_commits()
|
||||
if not commits:
|
||||
print("Usage: ./lint_commits.py <commit-range>")
|
||||
exit(1)
|
||||
if not lint(commits):
|
||||
exit(2)
|
@ -5,6 +5,17 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
commit_msg:
|
||||
runs-on: utilities
|
||||
if: ${{ forge.event_name == 'pull_request' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .forgejo/pre-commit
|
||||
- name: Lint
|
||||
run: .forgejo/pre-commit/lint_commit_msg.py
|
||||
|
||||
lint:
|
||||
runs-on: utilities
|
||||
steps:
|
||||
|
Reference in New Issue
Block a user