Skip to content

Commit e660273

Browse files
committed
ci: add pr title linting to ci workflow
1 parent df108f6 commit e660273

File tree

3 files changed

+160
-19
lines changed

3 files changed

+160
-19
lines changed

.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: 44 additions & 18 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---")
@@ -191,8 +195,30 @@ def run_local() -> None:
191195
sys.exit(1)
192196

193197

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

197223

198224
if __name__ == "__main__":

ops/tests/test_lintcommit.py

Lines changed: 102 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,98 @@ 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(args=[], returncode=returncode, stdout=stdout, stderr=stderr)
179+
180+
181+
@patch("subprocess.run")
182+
def test_run_range_all_valid(mock_run, capsys) -> None:
183+
log_output = _make_git_log_output(
184+
"feat: add new feature",
185+
"fix(sdk): resolve issue",
186+
)
187+
mock_run.return_value = _completed(stdout=log_output)
188+
189+
run_range("origin/main..HEAD", skip_dirty_check=True)
190+
191+
out = capsys.readouterr().out
192+
assert "PASS" in out
193+
assert out.count("PASS") == 2
194+
195+
196+
@patch("subprocess.run")
197+
def test_run_range_with_invalid_commit(mock_run, capsys) -> None:
198+
log_output = _make_git_log_output(
199+
"feat: add new feature",
200+
"bad commit no colon",
201+
)
202+
mock_run.return_value = _completed(stdout=log_output)
203+
204+
with pytest.raises(SystemExit, match="1"):
205+
run_range("origin/main..HEAD", skip_dirty_check=True)
206+
207+
captured = capsys.readouterr()
208+
assert "PASS" in captured.out
209+
assert "FAIL" in captured.err
210+
211+
212+
@patch("subprocess.run")
213+
def test_run_range_empty(mock_run, capsys) -> None:
214+
mock_run.return_value = _completed(stdout="")
215+
216+
run_range("origin/main..HEAD", skip_dirty_check=True)
217+
218+
out = capsys.readouterr().out
219+
assert "No commits in range" in out
220+
221+
222+
@patch("subprocess.run")
223+
def test_run_range_git_failure(mock_run) -> None:
224+
mock_run.return_value = _completed(returncode=1, stderr="fatal: bad range")
225+
226+
with pytest.raises(SystemExit, match="1"):
227+
run_range("bad..range", skip_dirty_check=True)
228+
229+
230+
@patch("subprocess.run")
231+
def test_run_range_dirty_worktree_skips(mock_run, capsys) -> None:
232+
"""When skip_dirty_check=False and worktree is dirty, validation is skipped."""
233+
mock_run.return_value = _completed(stdout=" M ops/lintcommit.py\n")
234+
235+
run_range("origin/main..HEAD", skip_dirty_check=False)
236+
237+
out = capsys.readouterr().out
238+
assert "uncommitted changes" in out
239+
# git log should never have been called (only git status)
240+
mock_run.assert_called_once()
241+
242+
243+
@patch("subprocess.run")
244+
def test_run_range_warnings_printed(mock_run, capsys) -> None:
245+
log_output = _make_git_log_output(
246+
"feat: add thing\n\n" + "x" * 80,
247+
)
248+
mock_run.return_value = _completed(stdout=log_output)
249+
250+
run_range("origin/main..HEAD", skip_dirty_check=True)
251+
252+
out = capsys.readouterr().out
253+
assert "PASS" in out
254+
assert "exceeds 72 chars" in out

0 commit comments

Comments
 (0)