diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8614bb..34843fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/ops/lintcommit.py b/ops/lintcommit.py index 59fbe64..4a6766f 100644 --- a/ops/lintcommit.py +++ b/ops/lintcommit.py @@ -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, ) @@ -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---") @@ -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__": diff --git a/ops/tests/test_lintcommit.py b/ops/tests/test_lintcommit.py index 02d68d3..3b0d345 100644 --- a/ops/tests/test_lintcommit.py +++ b/ops/tests/test_lintcommit.py @@ -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 @@ -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