Skip to content

Commit 0f3bbfa

Browse files
ayushiahjoliayaythomas
authored andcommitted
ci: add commit message linting to ci workflow
1 parent 30a4191 commit 0f3bbfa

3 files changed

Lines changed: 237 additions & 46 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: 114 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88

99
from __future__ import annotations
1010

11+
import argparse
1112
import re
13+
import subprocess
1214
import sys
15+
from dataclasses import dataclass, field
1316

1417
TYPES: set[str] = {
1518
"build",
@@ -124,75 +127,141 @@ def validate_message(message: str) -> tuple[str | None, list[str]]:
124127
return (error, warnings)
125128

126129

127-
def run_local() -> None:
128-
"""Validate local commit messages ahead of origin/main.
130+
@dataclass
131+
class CommitResult:
132+
"""Result of validating a single commit."""
129133

130-
If there are uncommitted changes, prints a warning and skips validation.
131-
"""
132-
import subprocess
134+
sha: str
135+
subject: str
136+
error: str | None = None
137+
warnings: list[str] = field(default_factory=list)
133138

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."
139+
140+
@dataclass
141+
class LintResult:
142+
"""Result of linting a range of commits."""
143+
144+
commits: list[CommitResult] = field(default_factory=list)
145+
skipped: bool = False
146+
skip_reason: str = ""
147+
empty: bool = False
148+
git_error: str = ""
149+
150+
@property
151+
def has_errors(self) -> bool:
152+
return bool(self.git_error) or any(c.error for c in self.commits)
153+
154+
155+
def lint_range(git_range: str, *, skip_dirty_check: bool = False) -> LintResult:
156+
"""Validate commit messages in a git range (e.g. 'origin/main..HEAD').
157+
158+
Args:
159+
git_range: A git revision range like 'origin/main..HEAD'.
160+
skip_dirty_check: When True, skip the uncommitted changes check
161+
(useful in CI where the worktree may be clean by definition).
162+
163+
Returns:
164+
A LintResult with per-commit validation results.
165+
"""
166+
if not skip_dirty_check:
167+
status = subprocess.run(
168+
["git", "status", "--porcelain"],
169+
capture_output=True,
170+
text=True,
144171
)
145-
return
172+
if status.stdout.strip():
173+
return LintResult(
174+
skipped=True,
175+
skip_reason=(
176+
"uncommitted changes detected, skipping commit message validation.\n"
177+
"Commit your changes and re-run to validate."
178+
),
179+
)
146180

147-
# Get all commit messages ahead of origin/main
148-
result: subprocess.CompletedProcess[str] = subprocess.run(
149-
["git", "log", "origin/main..HEAD", "--format=%H%n%B%n---END---"],
181+
result = subprocess.run(
182+
["git", "log", "--no-merges", git_range, "-z", "--format=%H%n%B"],
150183
capture_output=True,
151184
text=True,
152185
)
153186
if result.returncode != 0:
154-
print(f"git log failed: {result.stderr}", file=sys.stderr)
155-
sys.exit(1)
156-
157-
raw: str = result.stdout.strip()
158-
if not raw:
159-
print("No local commits ahead of origin/main")
160-
return
187+
return LintResult(git_error=result.stderr.strip())
161188

162-
blocks: list[str] = raw.split("---END---")
163-
has_errors: bool = False
189+
if not result.stdout.strip():
190+
return LintResult(empty=True)
164191

165-
for block in blocks:
166-
block = block.strip()
167-
if not block:
192+
commits: list[CommitResult] = []
193+
for record in result.stdout.split("\0"):
194+
if not record.strip():
168195
continue
169-
170-
lines: list[str] = block.splitlines()
171-
sha: str = lines[0][:7]
172-
message: str = "\n".join(lines[1:]).strip()
173-
196+
sha, _, message = record.partition("\n")
197+
message = message.strip()
174198
if not message:
175199
continue
176200

177201
error, warnings = validate_message(message)
178-
subject: str = message.splitlines()[0]
202+
subject = message.splitlines()[0]
203+
commits.append(
204+
CommitResult(
205+
sha=sha[:7],
206+
subject=subject,
207+
error=error,
208+
warnings=warnings,
209+
)
210+
)
211+
212+
return LintResult(commits=commits)
213+
214+
215+
def write_output(lint_result: LintResult, git_range: str) -> None:
216+
"""Write lint results to stdout/stderr."""
217+
if lint_result.skipped:
218+
print(f"WARNING: {lint_result.skip_reason}")
219+
return
179220

180-
if error:
181-
print(f"FAIL {sha}: {subject}", file=sys.stderr)
182-
print(f" Error: {error}", file=sys.stderr)
183-
has_errors = True
221+
if lint_result.git_error:
222+
print(f"git log failed: {lint_result.git_error}", file=sys.stderr)
223+
return
224+
225+
if lint_result.empty:
226+
print(f"No commits in range {git_range}")
227+
return
228+
229+
for commit in lint_result.commits:
230+
if commit.error:
231+
print(f"FAIL {commit.sha}: {commit.subject}", file=sys.stderr)
232+
print(f" Error: {commit.error}", file=sys.stderr)
184233
else:
185-
print(f"PASS {sha}: {subject}")
234+
print(f"PASS {commit.sha}: {commit.subject}")
186235

187-
for warning in warnings:
236+
for warning in commit.warnings:
188237
print(f" Warning: {warning}")
189238

190-
if has_errors:
239+
240+
def run_range(git_range: str, *, skip_dirty_check: bool = False) -> None:
241+
"""Validate commit messages in a git range and exit on errors."""
242+
lint_result = lint_range(git_range, skip_dirty_check=skip_dirty_check)
243+
write_output(lint_result, git_range)
244+
if lint_result.has_errors:
191245
sys.exit(1)
192246

193247

194248
def main() -> None:
195-
run_local()
249+
parser = argparse.ArgumentParser(
250+
description="Lint commit messages for conventional commits compliance."
251+
)
252+
parser.add_argument(
253+
"--range",
254+
default=None,
255+
dest="git_range",
256+
help="Validate all commits in a git revision range (e.g. 'origin/main..HEAD'). "
257+
"Skips the uncommitted-changes check (useful in CI).",
258+
)
259+
args = parser.parse_args()
260+
261+
if args.git_range is not None:
262+
run_range(args.git_range, skip_dirty_check=True)
263+
else:
264+
run_range("origin/main..HEAD")
196265

197266

198267
if __name__ == "__main__":

ops/tests/test_lintcommit.py

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

3-
from ops.lintcommit import validate_message, validate_subject
3+
from __future__ import annotations
4+
5+
from subprocess import CompletedProcess
6+
from unittest.mock import patch
7+
8+
import pytest
9+
10+
from ops.lintcommit import lint_range, validate_message, validate_subject
411

512

613
# region validate_subject: valid subjects
@@ -151,3 +158,104 @@ def test_empty_message() -> None:
151158
def test_invalid_subject_in_message() -> None:
152159
error, _ = validate_message("invalid title")
153160
assert error == "missing colon (:) char"
161+
162+
163+
# region lint_range
164+
165+
166+
def _make_git_log_output(*messages: str) -> str:
167+
"""Build fake ``git log --no-merges -z --format=%H%n%B`` output.
168+
169+
Records are separated by null characters.
170+
"""
171+
records: list[str] = []
172+
for i, msg in enumerate(messages):
173+
sha = f"abc{i:04d}" + "0" * 33 # 40-char fake SHA
174+
records.append(f"{sha}\n{msg}\n")
175+
return "\0".join(records)
176+
177+
178+
def _completed(
179+
stdout: str = "", stderr: str = "", returncode: int = 0
180+
) -> CompletedProcess[str]:
181+
"""Shorthand for a ``subprocess.CompletedProcess``."""
182+
return CompletedProcess(
183+
args=[], returncode=returncode, stdout=stdout, stderr=stderr
184+
)
185+
186+
187+
@patch("subprocess.run")
188+
def test_lint_range_all_valid(mock_run) -> None:
189+
log_output = _make_git_log_output(
190+
"feat: add new feature",
191+
"fix(sdk): resolve issue",
192+
)
193+
mock_run.return_value = _completed(stdout=log_output)
194+
195+
result = lint_range("origin/main..HEAD", skip_dirty_check=True)
196+
197+
assert not result.has_errors
198+
assert len(result.commits) == 2
199+
assert all(c.error is None for c in result.commits)
200+
201+
202+
@patch("subprocess.run")
203+
def test_lint_range_with_invalid_commit(mock_run) -> None:
204+
log_output = _make_git_log_output(
205+
"feat: add new feature",
206+
"bad commit no colon",
207+
)
208+
mock_run.return_value = _completed(stdout=log_output)
209+
210+
result = lint_range("origin/main..HEAD", skip_dirty_check=True)
211+
212+
assert result.has_errors
213+
assert result.commits[0].error is None
214+
assert result.commits[1].error == "missing colon (:) char"
215+
216+
217+
@patch("subprocess.run")
218+
def test_lint_range_empty(mock_run) -> None:
219+
mock_run.return_value = _completed(stdout="")
220+
221+
result = lint_range("origin/main..HEAD", skip_dirty_check=True)
222+
223+
assert result.empty
224+
assert not result.has_errors
225+
226+
227+
@patch("subprocess.run")
228+
def test_lint_range_git_failure(mock_run) -> None:
229+
mock_run.return_value = _completed(returncode=1, stderr="fatal: bad range")
230+
231+
result = lint_range("bad..range", skip_dirty_check=True)
232+
233+
assert result.has_errors
234+
assert result.git_error == "fatal: bad range"
235+
236+
237+
@patch("subprocess.run")
238+
def test_lint_range_dirty_worktree_skips(mock_run) -> None:
239+
"""When skip_dirty_check=False and worktree is dirty, validation is skipped."""
240+
mock_run.return_value = _completed(stdout=" M ops/lintcommit.py\n")
241+
242+
result = lint_range("origin/main..HEAD", skip_dirty_check=False)
243+
244+
assert result.skipped
245+
assert "uncommitted changes" in result.skip_reason
246+
# git log should never have been called (only git status)
247+
mock_run.assert_called_once()
248+
249+
250+
@patch("subprocess.run")
251+
def test_lint_range_warnings_collected(mock_run) -> None:
252+
log_output = _make_git_log_output(
253+
"feat: add thing\n\n" + "x" * 80,
254+
)
255+
mock_run.return_value = _completed(stdout=log_output)
256+
257+
result = lint_range("origin/main..HEAD", skip_dirty_check=True)
258+
259+
assert not result.has_errors
260+
assert len(result.commits) == 1
261+
assert any("exceeds 72 chars" in w for w in result.commits[0].warnings)

0 commit comments

Comments
 (0)