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
2import json
3import os
4import re
5import subprocess
6import sys
7import urllib.request as request
8from collections.abc import Callable
9
10LintBody = list[str]
11LintFunction = Callable[[LintBody], bool]
12LintRule = tuple[LintFunction, str]
13
14def 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
19lint_rules: dict[str, LintRule] = {}
20
21# A lint rule should return True if everything is okay
22def 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
28def 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
50def 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
68def 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
77def 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
96NO_PREFIX_WHITELIST = \
97 r"^Revert \"(.*)\"|^Reapply \"(.*)\""
98
99@lint_rule("Subject line must contain a prefix identifying the sub system")
100def 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")
105def 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")
111def 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")
115def empty_line(body: LintBody) -> bool:
116 return len(body) == 1 or body[1].strip() == ""
117
118@lint_rule("Do not use 'conventional commits' style")
119def 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")
123def 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")
128def no_file_exts(body: LintBody) -> bool:
129 return not re.search(r"[a-z0-9]\.([chm]|texi): ", body[0])
130
131
132if __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
5 pull_request:5 pull_request:
6 6
7jobs:7jobs:
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: utilities20 runs-on: utilities
10 steps:21 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