Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ on:
branches: [ main ]

jobs:
lint-commits:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Lint commit messages
run: python ops/lintcommit.py --range "origin/${{ github.event.pull_request.base.ref }}..${{ github.event.pull_request.head.sha }}"

build:
runs-on: ubuntu-latest
strategy:
Expand Down
62 changes: 44 additions & 18 deletions ops/lintcommit.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,29 +124,33 @@ def validate_message(message: str) -> tuple[str | None, list[str]]:
return (error, warnings)


def run_local() -> None:
"""Validate local commit messages ahead of origin/main.
def run_range(git_range: str, *, skip_dirty_check: bool = False) -> None:
"""Validate commit messages in a git range (e.g. 'origin/main..HEAD').

If there are uncommitted changes, prints a warning and skips validation.
Args:
git_range: A git revision range like 'origin/main..HEAD'.
skip_dirty_check: When True, skip the uncommitted changes check
(useful in CI where the worktree may be clean by definition).
"""
import subprocess

# Check for uncommitted changes
status: subprocess.CompletedProcess[str] = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
)
if status.stdout.strip():
print(
"WARNING: uncommitted changes detected, skipping commit message validation.\n"
"Commit your changes and re-run to validate."
if not skip_dirty_check:
# Check for uncommitted changes
status: subprocess.CompletedProcess[str] = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
)
return
if status.stdout.strip():
print(
"WARNING: uncommitted changes detected, skipping commit message validation.\n"
"Commit your changes and re-run to validate."
)
return

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

raw: str = result.stdout.strip()
if not raw:
print("No local commits ahead of origin/main")
print(f"No commits in range {git_range}")
return

blocks: list[str] = raw.split("---END---")
Expand Down Expand Up @@ -191,8 +195,30 @@ def run_local() -> None:
sys.exit(1)


def run_local() -> None:
"""Validate local commit messages ahead of origin/main."""
run_range("origin/main..HEAD")


def main() -> None:
run_local()
import argparse

parser = argparse.ArgumentParser(
description="Lint commit messages for conventional commits compliance."
)
parser.add_argument(
"--range",
default=None,
dest="git_range",
help="Validate all commits in a git revision range (e.g. 'origin/main..HEAD'). "
"Skips the uncommitted-changes check (useful in CI).",
)
args = parser.parse_args()

if args.git_range is not None:
run_range(args.git_range, skip_dirty_check=True)
else:
run_local()


if __name__ == "__main__":
Expand Down
103 changes: 102 additions & 1 deletion ops/tests/test_lintcommit.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
#!/usr/bin/env python3

from ops.lintcommit import validate_message, validate_subject
from __future__ import annotations

from unittest.mock import patch

import pytest

from ops.lintcommit import run_range, validate_message, validate_subject


# region validate_subject: valid subjects
Expand Down Expand Up @@ -151,3 +157,98 @@ def test_empty_message() -> None:
def test_invalid_subject_in_message() -> None:
error, _ = validate_message("invalid title")
assert error == "missing colon (:) char"


# region run_range


def _make_git_log_output(*messages: str) -> str:
"""Build fake ``git log --format=%H%n%B%n---END---`` output."""
blocks: list[str] = []
for i, msg in enumerate(messages):
sha = f"abc{i:04d}" + "0" * 33 # 40-char fake SHA
blocks.append(f"{sha}\n{msg}\n---END---")
return "\n".join(blocks)


def _completed(stdout: str = "", stderr: str = "", returncode: int = 0):
"""Shorthand for a ``subprocess.CompletedProcess``."""
from subprocess import CompletedProcess

return CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr=stderr)


@patch("subprocess.run")
def test_run_range_all_valid(mock_run, capsys) -> None:
log_output = _make_git_log_output(
"feat: add new feature",
"fix(sdk): resolve issue",
)
mock_run.return_value = _completed(stdout=log_output)

run_range("origin/main..HEAD", skip_dirty_check=True)

out = capsys.readouterr().out
assert "PASS" in out
assert out.count("PASS") == 2


@patch("subprocess.run")
def test_run_range_with_invalid_commit(mock_run, capsys) -> None:
log_output = _make_git_log_output(
"feat: add new feature",
"bad commit no colon",
)
mock_run.return_value = _completed(stdout=log_output)

with pytest.raises(SystemExit, match="1"):
run_range("origin/main..HEAD", skip_dirty_check=True)

captured = capsys.readouterr()
assert "PASS" in captured.out
assert "FAIL" in captured.err


@patch("subprocess.run")
def test_run_range_empty(mock_run, capsys) -> None:
mock_run.return_value = _completed(stdout="")

run_range("origin/main..HEAD", skip_dirty_check=True)

out = capsys.readouterr().out
assert "No commits in range" in out


@patch("subprocess.run")
def test_run_range_git_failure(mock_run) -> None:
mock_run.return_value = _completed(returncode=1, stderr="fatal: bad range")

with pytest.raises(SystemExit, match="1"):
run_range("bad..range", skip_dirty_check=True)


@patch("subprocess.run")
def test_run_range_dirty_worktree_skips(mock_run, capsys) -> None:
"""When skip_dirty_check=False and worktree is dirty, validation is skipped."""
mock_run.return_value = _completed(stdout=" M ops/lintcommit.py\n")

run_range("origin/main..HEAD", skip_dirty_check=False)

out = capsys.readouterr().out
assert "uncommitted changes" in out
# git log should never have been called (only git status)
mock_run.assert_called_once()


@patch("subprocess.run")
def test_run_range_warnings_printed(mock_run, capsys) -> None:
log_output = _make_git_log_output(
"feat: add thing\n\n" + "x" * 80,
)
mock_run.return_value = _completed(stdout=log_output)

run_range("origin/main..HEAD", skip_dirty_check=True)

out = capsys.readouterr().out
assert "PASS" in out
assert "exceeds 72 chars" in out
Loading