diff --git a/.gitea/workflows/auto-merge.yml b/.gitea/workflows/auto-merge.yml index 8161461..5479dd0 100644 --- a/.gitea/workflows/auto-merge.yml +++ b/.gitea/workflows/auto-merge.yml @@ -21,6 +21,15 @@ jobs: fi PR_TITLE="${{ github.event.pull_request.title }}" + + # Validate PR title follows conventional commits so squash merge message is valid + if ! echo "$PR_TITLE" | grep -qE '^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert|BREAKING CHANGE)(\(.+\))?: .+'; then + echo "ERROR: PR title must follow conventional commit format." + echo " Expected: : " + echo " Got: $PR_TITLE" + exit 1 + fi + MERGE_TITLE="${TASK_ID}: ${PR_TITLE}" curl -X POST \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff61eb7..b185e08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,7 @@ The `GRM-N` prefix is mandatory — CI extracts it for merge messages and Vikunj ## Commit Format +### Feature branches Use **conventional commits** on feature branches: ``` @@ -25,17 +26,18 @@ docs: improve README Allowed types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `ci`, `build`, `revert`, `BREAKING CHANGE`. -**Do NOT** include the `GRM-N:` prefix in commit messages on feature branches — it is added automatically during squash merge. +**Do NOT** include the `GRM-N:` prefix in commit messages on feature branches. -## PR Workflow +### Master branch (squash merges) +Squash commits on `master` must follow: -1. Create a branch `GRM-N` or `GRM-N-brief-description` from `master` -2. Make changes, committing with conventional format -3. Push and open a PR against `master` -4. CI runs lint, unit tests, and molecule tests -5. When all checks pass, add the `ready-to-merge` label -6. The auto-merge workflow squash-merges with message `GRM-N: ` -7. The post-merge workflow updates the Vikunja task +``` +GRM-N: +``` + +Example: `GRM-24: fix: resolve molecule idempotence`. + +This format is enforced by the auto-merge workflow, which validates the PR title is a conventional commit before squash-merging and prepending the task ID. ## Local Testing diff --git a/scripts/validate_commit_msg.py b/scripts/validate_commit_msg.py index 31456c6..62c1d5d 100644 --- a/scripts/validate_commit_msg.py +++ b/scripts/validate_commit_msg.py @@ -2,8 +2,9 @@ """Validate commit messages for GRM. Rules: -- On master branch: allow anything (merge commits already have task ID). -- On feature branches: must use conventional commit format, must NOT include GRM-N prefix. +- On feature branches: conventional commits ONLY, must NOT include GRM-N prefix. +- On master branch: must follow ': ' pattern, + e.g. 'GRM-24: fix: resolve timeout'. """ import re import subprocess @@ -41,11 +42,23 @@ def main(args=None) -> None: branch = get_branch() - if branch == "master": - return - subject = first_line(msg) + if branch == "master": + if not TASK_ID_RE.match(subject): + print("ERROR: Master branch commits must start with a task ID.") + print(" Expected: GRM-N: ") + print(f" Got: {subject}") + sys.exit(1) + # Strip task-id prefix and validate the remainder as conventional + remainder = TASK_ID_RE.sub("", subject).strip() + if not CONVENTIONAL_RE.match(remainder): + print("ERROR: Master branch commit message must follow conventional format after task ID.") + print(" Expected: GRM-N: : ") + print(f" Got: {subject}") + sys.exit(1) + return + if TASK_ID_RE.match(subject): print("ERROR: Do not include task ID (GRM-N) in feature branch commits.") print(" Task ID will be added automatically on merge via CI.") diff --git a/tests/unit/test_validate_commit_msg.py b/tests/unit/test_validate_commit_msg.py index df0b2e8..5a70ba4 100644 --- a/tests/unit/test_validate_commit_msg.py +++ b/tests/unit/test_validate_commit_msg.py @@ -82,11 +82,25 @@ class TestMain: with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"): main(["validate_commit_msg.py", msg_path]) - def test_accepts_anything_on_master(self) -> None: + def test_accepts_valid_master_commit(self) -> None: msg_path = self._write_msg("GRM-19: feat: add feature") with patch("scripts.validate_commit_msg.get_branch", return_value="master"): main(["validate_commit_msg.py", msg_path]) + def test_rejects_master_without_task_id(self) -> None: + msg_path = self._write_msg("feat: add feature") + with patch("scripts.validate_commit_msg.get_branch", return_value="master"): + with pytest.raises(SystemExit) as exc: + main(["validate_commit_msg.py", msg_path]) + assert exc.value.code == 1 + + def test_rejects_master_with_non_conventional_after_task_id(self) -> None: + msg_path = self._write_msg("GRM-19: random message") + with patch("scripts.validate_commit_msg.get_branch", return_value="master"): + with pytest.raises(SystemExit) as exc: + main(["validate_commit_msg.py", msg_path]) + assert exc.value.code == 1 + def test_rejects_non_conventional_on_feature_branch(self) -> None: msg_path = self._write_msg("random message") with patch("scripts.validate_commit_msg.get_branch", return_value="feature"): @@ -106,6 +120,12 @@ class TestMain: def test_main_module_block() -> None: + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("GRM-1: feat: test") + msg_path = f.name + with patch("scripts.validate_commit_msg.get_branch", return_value="master"): import scripts.validate_commit_msg as vcm @@ -115,4 +135,6 @@ def test_main_module_block() -> None: source = source.replace('if __name__ == "__main__":\n main()\n', "") namespace = dict(vcm.__dict__) exec(compile(source, vcm.__file__, "exec"), namespace) - namespace["main"](["validate_commit_msg.py", "/dev/null"]) + namespace["main"](["validate_commit_msg.py", msg_path]) + + os.unlink(msg_path)