History

forgejo/lint_commit_msg: add script for commit message linting

Based on mpv's script, under LGPL. https://github.com/mpv-player/mpv

Kacper Michajłow committed 1 year ago

Commit cc6ad701 parent d5f7a422 files · +149 −0Browse files

Unified · Split

2 files
  1. .forgejo/pre-commit/lint_commit_msg.py +138 −0
  2. .forgejo/workflows/lint.yml +11 −0
.forgejo/pre-commit/lint_commit_msg.py+138−0
@@ -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)
.forgejo/workflows/lint.yml+11−0
@@ -5,6 +5,17 @@ on: · 4 unmodified lines
55 pull_request:
66
77 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+
819 lint:
920 runs-on: utilities
1021 steps:

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t