Skip to content

Commit 7f7fbfa

Browse files
committed
ci: add commit message linting to ci workflow
1 parent 72b5336 commit 7f7fbfa

3 files changed

Lines changed: 188 additions & 22 deletions

File tree

.github/workflows/ci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ on:
1212
branches: [ main ]
1313

1414
jobs:
15+
lint-commits:
16+
if: github.event_name == 'pull_request'
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
20+
with:
21+
fetch-depth: 0
22+
- name: Set up Python
23+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
24+
with:
25+
python-version: "3.12"
26+
- name: Lint commit messages
27+
run: python ops/lintcommit.py --range "origin/${{ github.event.pull_request.base.ref }}..${{ github.event.pull_request.head.sha }}"
28+
1529
build:
1630
runs-on: ubuntu-latest
1731
strategy:

ops/lintcommit.py

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -124,29 +124,33 @@ def validate_message(message: str) -> tuple[str | None, list[str]]:
124124
return (error, warnings)
125125

126126

127-
def run_local() -> None:
128-
"""Validate local commit messages ahead of origin/main.
127+
def run_range(git_range: str, *, skip_dirty_check: bool = False) -> None:
128+
"""Validate commit messages in a git range (e.g. 'origin/main..HEAD').
129129
130-
If there are uncommitted changes, prints a warning and skips validation.
130+
Args:
131+
git_range: A git revision range like 'origin/main..HEAD'.
132+
skip_dirty_check: When True, skip the uncommitted changes check
133+
(useful in CI where the worktree may be clean by definition).
131134
"""
132135
import subprocess
133136

134-
# Check for uncommitted changes
135-
status: subprocess.CompletedProcess[str] = subprocess.run(
136-
["git", "status", "--porcelain"],
137-
capture_output=True,
138-
text=True,
139-
)
140-
if status.stdout.strip():
141-
print(
142-
"WARNING: uncommitted changes detected, skipping commit message validation.\n"
143-
"Commit your changes and re-run to validate."
137+
if not skip_dirty_check:
138+
# Check for uncommitted changes
139+
status: subprocess.CompletedProcess[str] = subprocess.run(
140+
["git", "status", "--porcelain"],
141+
capture_output=True,
142+
text=True,
144143
)
145-
return
144+
if status.stdout.strip():
145+
print(
146+
"WARNING: uncommitted changes detected, skipping commit message validation.\n"
147+
"Commit your changes and re-run to validate."
148+
)
149+
return
146150

147-
# Get all commit messages ahead of origin/main
151+
# Get all commit messages in the range
148152
result: subprocess.CompletedProcess[str] = subprocess.run(
149-
["git", "log", "origin/main..HEAD", "--format=%H%n%B%n---END---"],
153+
["git", "log", git_range, "--format=%H%n%B%n---END---"],
150154
capture_output=True,
151155
text=True,
152156
)
@@ -156,7 +160,7 @@ def run_local() -> None:
156160

157161
raw: str = result.stdout.strip()
158162
if not raw:
159-
print("No local commits ahead of origin/main")
163+
print(f"No commits in range {git_range}")
160164
return
161165

162166
blocks: list[str] = raw.split("---END---")
@@ -174,15 +178,21 @@ def run_local() -> None:
174178
if not message:
175179
continue
176180

181+
subject_line: str = message.splitlines()[0]
182+
183+
# Skip merge commits (auto-generated by git)
184+
if subject_line.startswith("Merge "):
185+
print(f"SKIP {sha}: {subject_line} (merge commit)")
186+
continue
187+
177188
error, warnings = validate_message(message)
178-
subject: str = message.splitlines()[0]
179189

180190
if error:
181-
print(f"FAIL {sha}: {subject}", file=sys.stderr)
191+
print(f"FAIL {sha}: {subject_line}", file=sys.stderr)
182192
print(f" Error: {error}", file=sys.stderr)
183193
has_errors = True
184194
else:
185-
print(f"PASS {sha}: {subject}")
195+
print(f"PASS {sha}: {subject_line}")
186196

187197
for warning in warnings:
188198
print(f" Warning: {warning}")
@@ -191,8 +201,30 @@ def run_local() -> None:
191201
sys.exit(1)
192202

193203

204+
def run_local() -> None:
205+
"""Validate local commit messages ahead of origin/main."""
206+
run_range("origin/main..HEAD")
207+
208+
194209
def main() -> None:
195-
run_local()
210+
import argparse
211+
212+
parser = argparse.ArgumentParser(
213+
description="Lint commit messages for conventional commits compliance."
214+
)
215+
parser.add_argument(
216+
"--range",
217+
default=None,
218+
dest="git_range",
219+
help="Validate all commits in a git revision range (e.g. 'origin/main..HEAD'). "
220+
"Skips the uncommitted-changes check (useful in CI).",
221+
)
222+
args = parser.parse_args()
223+
224+
if args.git_range is not None:
225+
run_range(args.git_range, skip_dirty_check=True)
226+
else:
227+
run_local()
196228

197229

198230
if __name__ == "__main__":

ops/tests/test_lintcommit.py

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
#!/usr/bin/env python3
22

3-
from ops.lintcommit import validate_message, validate_subject
3+
from __future__ import annotations
4+
5+
from unittest.mock import patch
6+
7+
import pytest
8+
9+
from ops.lintcommit import run_range, validate_message, validate_subject
410

511

612
# region validate_subject: valid subjects
@@ -151,3 +157,117 @@ def test_empty_message() -> None:
151157
def test_invalid_subject_in_message() -> None:
152158
error, _ = validate_message("invalid title")
153159
assert error == "missing colon (:) char"
160+
161+
162+
# region run_range
163+
164+
165+
def _make_git_log_output(*messages: str) -> str:
166+
"""Build fake ``git log --format=%H%n%B%n---END---`` output."""
167+
blocks: list[str] = []
168+
for i, msg in enumerate(messages):
169+
sha = f"abc{i:04d}" + "0" * 33 # 40-char fake SHA
170+
blocks.append(f"{sha}\n{msg}\n---END---")
171+
return "\n".join(blocks)
172+
173+
174+
def _completed(stdout: str = "", stderr: str = "", returncode: int = 0):
175+
"""Shorthand for a ``subprocess.CompletedProcess``."""
176+
from subprocess import CompletedProcess
177+
178+
return CompletedProcess(
179+
args=[], returncode=returncode, stdout=stdout, stderr=stderr
180+
)
181+
182+
183+
@patch("subprocess.run")
184+
def test_run_range_all_valid(mock_run, capsys) -> None:
185+
log_output = _make_git_log_output(
186+
"feat: add new feature",
187+
"fix(sdk): resolve issue",
188+
)
189+
mock_run.return_value = _completed(stdout=log_output)
190+
191+
run_range("origin/main..HEAD", skip_dirty_check=True)
192+
193+
out = capsys.readouterr().out
194+
assert "PASS" in out
195+
assert out.count("PASS") == 2
196+
197+
198+
@patch("subprocess.run")
199+
def test_run_range_with_invalid_commit(mock_run, capsys) -> None:
200+
log_output = _make_git_log_output(
201+
"feat: add new feature",
202+
"bad commit no colon",
203+
)
204+
mock_run.return_value = _completed(stdout=log_output)
205+
206+
with pytest.raises(SystemExit, match="1"):
207+
run_range("origin/main..HEAD", skip_dirty_check=True)
208+
209+
captured = capsys.readouterr()
210+
assert "PASS" in captured.out
211+
assert "FAIL" in captured.err
212+
213+
214+
@patch("subprocess.run")
215+
def test_run_range_empty(mock_run, capsys) -> None:
216+
mock_run.return_value = _completed(stdout="")
217+
218+
run_range("origin/main..HEAD", skip_dirty_check=True)
219+
220+
out = capsys.readouterr().out
221+
assert "No commits in range" in out
222+
223+
224+
@patch("subprocess.run")
225+
def test_run_range_git_failure(mock_run) -> None:
226+
mock_run.return_value = _completed(returncode=1, stderr="fatal: bad range")
227+
228+
with pytest.raises(SystemExit, match="1"):
229+
run_range("bad..range", skip_dirty_check=True)
230+
231+
232+
@patch("subprocess.run")
233+
def test_run_range_dirty_worktree_skips(mock_run, capsys) -> None:
234+
"""When skip_dirty_check=False and worktree is dirty, validation is skipped."""
235+
mock_run.return_value = _completed(stdout=" M ops/lintcommit.py\n")
236+
237+
run_range("origin/main..HEAD", skip_dirty_check=False)
238+
239+
out = capsys.readouterr().out
240+
assert "uncommitted changes" in out
241+
# git log should never have been called (only git status)
242+
mock_run.assert_called_once()
243+
244+
245+
@patch("subprocess.run")
246+
def test_run_range_skips_merge_commits(mock_run, capsys) -> None:
247+
log_output = _make_git_log_output(
248+
"Merge branch 'main' into ci_linter",
249+
"feat: add new feature",
250+
)
251+
mock_run.return_value = _completed(stdout=log_output)
252+
253+
run_range("origin/main..HEAD", skip_dirty_check=True)
254+
255+
out = capsys.readouterr().out
256+
assert "SKIP" in out
257+
assert "merge commit" in out
258+
assert "PASS" in out
259+
assert out.count("PASS") == 1
260+
261+
262+
@patch("subprocess.run")
263+
def test_run_range_warnings_printed(mock_run, capsys) -> None:
264+
log_output = _make_git_log_output(
265+
"feat: add thing\n\n" + "x" * 80,
266+
)
267+
mock_run.return_value = _completed(stdout=log_output)
268+
269+
run_range("origin/main..HEAD", skip_dirty_check=True)
270+
271+
out = capsys.readouterr().out
272+
assert "PASS" in out
273+
assert "exceeds 72 chars" in out

0 commit comments

Comments
 (0)