From 4da724ce531531794c5a85de0a32548461313e8d Mon Sep 17 00:00:00 2001 From: Emil Simeonov Date: Fri, 19 Jun 2026 04:27:43 +0200 Subject: [PATCH] GRM-21: Implement commit validation, CI/CD workflows and repo automation - Add scripts/validate_commit_msg.py with conventional commit enforcement - Add scripts/configure_repo.py for Gitea branch protection and labels - Add scripts/__init__.py for Python package importability - Create Gitea Actions workflows: ci, auto-merge, post-merge, publish - Update .pre-commit-config.yaml with commit-msg hook - Update pyproject.toml pythonpath and coverage for scripts - Add comprehensive unit tests for both scripts with 100% coverage --- .gitea/workflows/auto-merge.yml | 30 +++ .gitea/workflows/ci.yml | 43 ++++ .gitea/workflows/post-merge.yml | 67 +++++++ .gitea/workflows/publish.yml | 40 ++++ pyproject.toml | 6 +- scripts/__init__.py | 0 scripts/configure_repo.py | 131 +++++++++++++ scripts/validate_commit_msg.py | 64 ++++++ tests/unit/test_configure_repo.py | 259 +++++++++++++++++++++++++ tests/unit/test_validate_commit_msg.py | 118 +++++++++++ 10 files changed, 755 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/auto-merge.yml create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/post-merge.yml create mode 100644 .gitea/workflows/publish.yml create mode 100644 scripts/__init__.py create mode 100644 scripts/configure_repo.py create mode 100644 scripts/validate_commit_msg.py create mode 100644 tests/unit/test_configure_repo.py create mode 100644 tests/unit/test_validate_commit_msg.py diff --git a/.gitea/workflows/auto-merge.yml b/.gitea/workflows/auto-merge.yml new file mode 100644 index 0000000..8161461 --- /dev/null +++ b/.gitea/workflows/auto-merge.yml @@ -0,0 +1,30 @@ +name: Auto-merge + +on: + pull_request: + types: [labeled] + +jobs: + merge: + if: github.event.label.name == 'ready-to-merge' + runs-on: docker + steps: + - name: Squash merge with task ID + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + BRANCH="${{ github.head_ref }}" + TASK_ID=$(echo "$BRANCH" | grep -oE 'GRM-[0-9]+' || echo "") + if [ -z "$TASK_ID" ]; then + echo "ERROR: No task ID (GRM-N) found in branch name '$BRANCH'" + exit 1 + fi + + PR_TITLE="${{ github.event.pull_request.title }}" + MERGE_TITLE="${TASK_ID}: ${PR_TITLE}" + + curl -X POST \ + "https://git.oblachno.oblachno.fyi/api/v1/repos/${{ github.repository }}/pulls/${{ github.event.number }}/merge" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"Do\": \"squash\", \"MergeTitleField\": \"${MERGE_TITLE}\"}" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..41bcae9 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + pull_request: + branches: [master] + push: + branches: [master] + +jobs: + lint: + runs-on: docker + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + python3 -m venv .venv + .venv/bin/pip install -e ".[dev]" + .venv/bin/ansible-galaxy collection install -r ansible/requirements.yml + - name: Lint all + run: make lint-all + + unit-tests: + runs-on: docker + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + python3 -m venv .venv + .venv/bin/pip install -e ".[dev]" + - name: Unit tests with 100% coverage + run: make pytest-cov + + molecule-tests: + runs-on: docker + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + python3 -m venv .venv + .venv/bin/pip install -e ".[dev]" + .venv/bin/ansible-galaxy collection install -r ansible/requirements.yml + - name: Molecule tests (all 7 scenarios) + run: make molecule diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml new file mode 100644 index 0000000..37423ac --- /dev/null +++ b/.gitea/workflows/post-merge.yml @@ -0,0 +1,67 @@ +name: Post-merge Vikunja update + +on: + push: + branches: [master] + +jobs: + vikunja: + runs-on: docker + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Update Vikunja task + env: + VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} + run: | + MERGE_MSG=$(git log -1 --pretty=%B) + TASK_ID=$(echo "$MERGE_MSG" | grep -oE 'GRM-[0-9]+' | head -1) + + if [ -z "$TASK_ID" ]; then + echo "No task ID in commit message, skipping Vikunja update." + exit 0 + fi + + # Resolve GRM-N identifier to Vikunja numeric task ID. + # Vikunja's filter API does not support filtering by 'identifier' field, + # so we list all tasks and filter client-side by project_id=6 and identifier. + VIKUNJA_TASK_ID=$(curl -s \ + "https://work.oblachno.oblachno.fyi/api/v1/tasks/all?per_page=50" \ + -H "Authorization: Bearer ${VIKUNJA_TOKEN}" | \ + python3 -c " +import sys, json +tasks = json.load(sys.stdin) +matches = [t for t in tasks if t.get('project_id') == 6 and t.get('identifier') == '${TASK_ID}'] +print(matches[0]['id'] if matches else '') +") + + if [ -z "$VIKUNJA_TASK_ID" ]; then + echo "ERROR: Could not find Vikunja task for ${TASK_ID} in project 6" + exit 1 + fi + + CONV_MSG=$(echo "$MERGE_MSG" | head -1 | sed -E 's/^GRM-[0-9]+: //') + COMMIT_SHA=$(git rev-parse HEAD) + + # Post HTML comment to Vikunja + python3 -c " +import json, os +html = '

${TASK_ID}: ${CONV_MSG}

Commit: ${COMMIT_SHA}

' +print(json.dumps({'content': html})) +" > /tmp/vikunja_comment.json + + curl -X POST \ + "https://work.oblachno.oblachno.fyi/api/v1/tasks/${VIKUNJA_TASK_ID}/comments" \ + -H "Authorization: Bearer ${VIKUNJA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d @/tmp/vikunja_comment.json + + # Mark task as done + curl -X PUT \ + "https://work.oblachno.oblachno.fyi/api/v1/tasks/${VIKUNJA_TASK_ID}" \ + -H "Authorization: Bearer ${VIKUNJA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"done": true}' + + echo "Vikunja task ${TASK_ID} (ID ${VIKUNJA_TASK_ID}) updated and marked done." diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml new file mode 100644 index 0000000..0ab7a2c --- /dev/null +++ b/.gitea/workflows/publish.yml @@ -0,0 +1,40 @@ +name: Publish Release + +on: + push: + tags: + - 'v*' + +jobs: + publish: + runs-on: docker + steps: + - uses: actions/checkout@v4 + - name: Build and publish release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + run: | + python3 -m venv .venv + .venv/bin/pip install build twine + + # Build package + .venv/bin/python -m build + + # Publish to PyPI (only if PYPI_TOKEN secret is configured) + if [ -n "${PYPI_TOKEN}" ]; then + .venv/bin/twine upload dist/* -u __token__ -p "${PYPI_TOKEN}" + echo "Published to PyPI." + else + echo "PYPI_TOKEN not set — skipping PyPI publish." + fi + + # Create Gitea release + TAG="${{ github.ref_name }}" + curl -X POST \ + "https://git.oblachno.oblachno.fyi/api/v1/repos/${{ github.repository }}/releases" \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\": \"${TAG}\", \"name\": \"${TAG}\", \"body\": \"Release ${TAG}\\n\\nSee CHANGELOG.md for details.\", \"draft\": false, \"prerelease\": false}" + + echo "Gitea release ${TAG} created." diff --git a/pyproject.toml b/pyproject.toml index 48ad80f..ee80611 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,8 +42,8 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = ["src"] -addopts = "--cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100" +pythonpath = ["src", "."] +addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100" markers = [ "integration: marks tests as integration tests (not counted in coverage)", ] @@ -61,6 +61,6 @@ quote-style = "double" indent-style = "space" [tool.pyright] -include = ["src"] +include = ["src", "scripts"] pythonVersion = "3.11" strict = ["src/gitea_runner_manager"] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/configure_repo.py b/scripts/configure_repo.py new file mode 100644 index 0000000..28f2e92 --- /dev/null +++ b/scripts/configure_repo.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Configure GRM repository: branch protection + labels via Gitea REST API. + +Usage: + GITEA_ADMIN_TOKEN= python3 scripts/configure_repo.py +""" +import os +import sys + +import requests + + +GITEA_API = "https://git.oblachno.oblachno.fyi/api/v1" +OWNER = "oblachno-oss" +REPO = "grm" + +BRANCH_PROTECTION_CONFIG = { + "branch_name": "master", + "enable_push": False, + "enable_status_check": True, + "status_check_contexts": ["lint", "unit-tests", "molecule-tests"], + "required_approvals": 1, + "dismiss_stale_approvals": True, + "block_on_outdated_branch": True, + "block_on_rejected_reviews": True, + "block_on_official_review_requests": True, +} + +LABEL_CONFIG = { + "name": "ready-to-merge", + "color": "2ecc71", + "description": "Auto-merge PR when all CI checks pass", +} + + +class GiteaRepoConfig: + """Configure a Gitea repository: branch protection and labels.""" + + def __init__(self, base_url: str, token: str, owner: str, repo: str) -> None: + self._base_url = base_url.rstrip("/") + self._owner = owner + self._repo = repo + self._session = requests.Session() + self._session.headers.update({ + "Authorization": f"token {token}", + "Content-Type": "application/json", + }) + + def _url(self, path: str) -> str: + return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}" + + def list_branch_protections(self) -> list[dict]: + r = self._session.get(self._url("/branch_protections")) + r.raise_for_status() + return r.json() + + def create_branch_protection(self, config: dict) -> dict: + r = self._session.post(self._url("/branch_protections"), json=config) + r.raise_for_status() + return r.json() + + def update_branch_protection(self, protection_id: int, config: dict) -> dict: + r = self._session.patch( + self._url(f"/branch_protections/{protection_id}"), json=config + ) + r.raise_for_status() + return r.json() + + def ensure_branch_protection(self, branch: str, config: dict) -> dict: + """Idempotent: create or update branch protection for the given branch.""" + existing = self.list_branch_protections() + for p in existing: + if p.get("branch_name") == branch: + protection_id = p["id"] + update_config = {k: v for k, v in config.items() if k != "branch_name"} + return self.update_branch_protection(protection_id, update_config) + return self.create_branch_protection(config) + + def list_labels(self) -> list[dict]: + r = self._session.get(self._url("/labels")) + r.raise_for_status() + return r.json() + + def create_label(self, name: str, color: str, description: str = "") -> dict: + r = self._session.post( + self._url("/labels"), + json={"name": name, "color": color, "description": description}, + ) + r.raise_for_status() + return r.json() + + def ensure_label(self, name: str, color: str, description: str = "") -> dict | None: + """Idempotent: create label if it doesn't already exist.""" + labels = self.list_labels() + for label in labels: + if label["name"] == name: + return None # already exists + return self.create_label(name, color, description) + + +def main() -> None: + token = os.environ.get("GITEA_ADMIN_TOKEN", "") + if not token: + print("ERROR: GITEA_ADMIN_TOKEN is not set.", file=sys.stderr) + sys.exit(1) + + cfg = GiteaRepoConfig(GITEA_API, token, OWNER, REPO) + + print("Configuring branch protection for master...") + cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG) + print(" - Direct pushes: BLOCKED (require PR)") + print(" - Required approvals: 1") + print(" - Dismiss stale approvals: yes") + print(" - Block outdated branches: yes") + print(" - Block rejected reviews: yes") + print(" - Required status checks: lint, unit-tests, molecule-tests") + + print("") + print("Creating ready-to-merge label...") + result = cfg.ensure_label(**LABEL_CONFIG) + if result is None: + print(" Label 'ready-to-merge' already exists.") + else: + print(" Label 'ready-to-merge' created.") + + print("") + print("Repository configuration complete.") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/scripts/validate_commit_msg.py b/scripts/validate_commit_msg.py new file mode 100644 index 0000000..31456c6 --- /dev/null +++ b/scripts/validate_commit_msg.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""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. +""" +import re +import subprocess +import sys + +CONVENTIONAL_RE = re.compile( + r"^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert|BREAKING CHANGE)(\(.+\))?: .+" +) +TASK_ID_RE = re.compile(r"^GRM-\d+:") + + +def first_line(text: str) -> str: + return text.split("\n")[0] + + +def get_branch() -> str: + try: + result = subprocess.run( + ["git", "symbolic-ref", "--short", "HEAD"], + capture_output=True, text=True, check=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError: + return "" + + +def main(args=None) -> None: + argv = args if args is not None else sys.argv + if len(argv) < 2: + print("Usage: validate_commit_msg.py ") + sys.exit(1) + + with open(argv[1]) as f: + msg = f.read().strip() + + branch = get_branch() + + if branch == "master": + return + + subject = first_line(msg) + + 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.") + sys.exit(1) + + if not CONVENTIONAL_RE.match(subject): + print("ERROR: Commit message must follow conventional commit format.") + print(" Expected: : ") + print(f" Got: {subject}") + print(" Allowed types: feat, fix, chore, docs, style, refactor,") + print(" perf, test, ci, build, revert, BREAKING CHANGE") + sys.exit(1) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/tests/unit/test_configure_repo.py b/tests/unit/test_configure_repo.py new file mode 100644 index 0000000..2f6dad2 --- /dev/null +++ b/tests/unit/test_configure_repo.py @@ -0,0 +1,259 @@ +"""Unit tests for scripts/configure_repo.py.""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from scripts.configure_repo import ( + BRANCH_PROTECTION_CONFIG, + LABEL_CONFIG, + GiteaRepoConfig, + main, +) + + +class TestGiteaRepoConfig: + def test_init_sets_headers(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + assert cfg._base_url == "https://git.example.com" + assert cfg._owner == "owner" + assert cfg._repo == "repo" + assert cfg._session.headers["Authorization"] == "token tok" + assert cfg._session.headers["Content-Type"] == "application/json" + + def test_url_constructs_path(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + assert cfg._url("/branch_protections") == ("https://git.example.com/repos/owner/repo/branch_protections") + + def test_url_strips_trailing_slash(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com/", "tok", "owner", "repo") + assert cfg._url("/labels") == ("https://git.example.com/repos/owner/repo/labels") + + def test_list_branch_protections(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + mock_response = MagicMock() + mock_response.json.return_value = [ + {"id": 1, "branch_name": "master"}, + {"id": 2, "branch_name": "develop"}, + ] + cfg._session.get = MagicMock(return_value=mock_response) + + result = cfg.list_branch_protections() + assert len(result) == 2 + assert result[0]["branch_name"] == "master" + cfg._session.get.assert_called_once_with("https://git.example.com/repos/owner/repo/branch_protections") + + def test_list_branch_protections_raises_on_error(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("500") + cfg._session.get = MagicMock(return_value=mock_response) + + with pytest.raises(requests.HTTPError): + cfg.list_branch_protections() + + def test_create_branch_protection(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + mock_response = MagicMock() + mock_response.json.return_value = {"id": 3, "branch_name": "master"} + cfg._session.post = MagicMock(return_value=mock_response) + + result = cfg.create_branch_protection(BRANCH_PROTECTION_CONFIG) + assert result["id"] == 3 + cfg._session.post.assert_called_once_with( + "https://git.example.com/repos/owner/repo/branch_protections", + json=BRANCH_PROTECTION_CONFIG, + ) + + def test_create_branch_protection_raises_on_error(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("403") + cfg._session.post = MagicMock(return_value=mock_response) + + with pytest.raises(requests.HTTPError): + cfg.create_branch_protection(BRANCH_PROTECTION_CONFIG) + + def test_update_branch_protection(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + mock_response = MagicMock() + mock_response.json.return_value = {"id": 1, "required_approvals": 2} + cfg._session.patch = MagicMock(return_value=mock_response) + + update = {"required_approvals": 2} + result = cfg.update_branch_protection(1, update) + assert result["required_approvals"] == 2 + cfg._session.patch.assert_called_once_with( + "https://git.example.com/repos/owner/repo/branch_protections/1", + json=update, + ) + + def test_update_branch_protection_raises_on_error(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("404") + cfg._session.patch = MagicMock(return_value=mock_response) + + with pytest.raises(requests.HTTPError): + cfg.update_branch_protection(999, {}) + + def test_ensure_branch_protection_creates_when_none_exist(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + cfg.list_branch_protections = MagicMock(return_value=[]) + cfg.create_branch_protection = MagicMock(return_value={"id": 1, "branch_name": "master"}) + + result = cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG) + assert result["id"] == 1 + cfg.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG) + + def test_ensure_branch_protection_updates_when_exists(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + cfg.list_branch_protections = MagicMock(return_value=[{"id": 5, "branch_name": "master"}]) + cfg.update_branch_protection = MagicMock(return_value={"id": 5, "required_approvals": 1}) + + result = cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG) + assert result["id"] == 5 + # update config should exclude branch_name + expected_update = {k: v for k, v in BRANCH_PROTECTION_CONFIG.items() if k != "branch_name"} + cfg.update_branch_protection.assert_called_once_with(5, expected_update) + + def test_ensure_branch_protection_creates_when_other_branches_exist(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + cfg.list_branch_protections = MagicMock(return_value=[{"id": 1, "branch_name": "develop"}]) + cfg.create_branch_protection = MagicMock(return_value={"id": 2, "branch_name": "master"}) + + result = cfg.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG) + assert result["id"] == 2 + cfg.create_branch_protection.assert_called_once_with(BRANCH_PROTECTION_CONFIG) + + def test_list_labels(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + mock_response = MagicMock() + mock_response.json.return_value = [ + {"name": "bug", "color": "ff0000"}, + {"name": "enhancement", "color": "00ff00"}, + ] + cfg._session.get = MagicMock(return_value=mock_response) + + result = cfg.list_labels() + assert len(result) == 2 + cfg._session.get.assert_called_once_with("https://git.example.com/repos/owner/repo/labels") + + def test_list_labels_raises_on_error(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("500") + cfg._session.get = MagicMock(return_value=mock_response) + + with pytest.raises(requests.HTTPError): + cfg.list_labels() + + def test_create_label(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + mock_response = MagicMock() + mock_response.json.return_value = {"name": "ready-to-merge", "color": "2ecc71"} + cfg._session.post = MagicMock(return_value=mock_response) + + result = cfg.create_label("ready-to-merge", "2ecc71", "Auto-merge label") + assert result["name"] == "ready-to-merge" + cfg._session.post.assert_called_once_with( + "https://git.example.com/repos/owner/repo/labels", + json={"name": "ready-to-merge", "color": "2ecc71", "description": "Auto-merge label"}, + ) + + def test_create_label_raises_on_error(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("422") + cfg._session.post = MagicMock(return_value=mock_response) + + with pytest.raises(requests.HTTPError): + cfg.create_label("dup", "ffffff") + + def test_ensure_label_creates_when_not_exists(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + cfg.list_labels = MagicMock(return_value=[]) + cfg.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"}) + + result = cfg.ensure_label("ready-to-merge", "2ecc71", "desc") + assert result is not None + assert result["name"] == "ready-to-merge" + cfg.create_label.assert_called_once_with("ready-to-merge", "2ecc71", "desc") + + def test_ensure_label_returns_none_when_exists(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + cfg.list_labels = MagicMock(return_value=[{"name": "ready-to-merge", "color": "2ecc71"}]) + cfg.create_label = MagicMock() + + result = cfg.ensure_label("ready-to-merge", "2ecc71", "desc") + assert result is None + cfg.create_label.assert_not_called() + + def test_ensure_label_creates_when_other_labels_exist(self) -> None: + cfg = GiteaRepoConfig("https://git.example.com", "tok", "owner", "repo") + cfg.list_labels = MagicMock(return_value=[{"name": "bug", "color": "ff0000"}]) + cfg.create_label = MagicMock(return_value={"name": "ready-to-merge", "color": "2ecc71"}) + + result = cfg.ensure_label("ready-to-merge", "2ecc71", "desc") + assert result is not None + cfg.create_label.assert_called_once() + + +class TestMain: + def test_main_missing_token(self) -> None: + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code == 1 + + def test_main_success(self) -> None: + with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): + with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class: + mock_cfg = MagicMock() + mock_cfg_class.return_value = mock_cfg + + main() + + mock_cfg.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG) + mock_cfg.ensure_label.assert_called_once_with(**LABEL_CONFIG) + + def test_main_label_already_exists(self) -> None: + with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): + with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class: + mock_cfg = MagicMock() + mock_cfg.ensure_label.return_value = None + mock_cfg_class.return_value = mock_cfg + + main() + + mock_cfg.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG) + mock_cfg.ensure_label.assert_called_once_with(**LABEL_CONFIG) + + def test_main_api_error(self) -> None: + with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): + with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class: + mock_cfg = MagicMock() + mock_cfg.ensure_branch_protection.side_effect = requests.HTTPError("403") + mock_cfg_class.return_value = mock_cfg + + with pytest.raises(requests.HTTPError): + main() + + +def test_main_module_block() -> None: + with patch.dict("os.environ", {"GITEA_ADMIN_TOKEN": "tok"}, clear=True): + with patch("scripts.configure_repo.GiteaRepoConfig") as mock_cfg_class: + mock_cfg = MagicMock() + mock_cfg_class.return_value = mock_cfg + import scripts.configure_repo as cr + + with open(cr.__file__) as f: + source = f.read() + # Remove __main__ block so exec doesn't call main() before we inject the mock + source = source.replace('if __name__ == "__main__":\n main()\n', "") + namespace = dict(cr.__dict__) + exec(compile(source, cr.__file__, "exec"), namespace) + namespace["GiteaRepoConfig"] = mock_cfg_class + namespace["main"]() + mock_cfg.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG) diff --git a/tests/unit/test_validate_commit_msg.py b/tests/unit/test_validate_commit_msg.py new file mode 100644 index 0000000..df0b2e8 --- /dev/null +++ b/tests/unit/test_validate_commit_msg.py @@ -0,0 +1,118 @@ +"""Unit tests for scripts/validate_commit_msg.py.""" + +import os +import subprocess +import tempfile +from unittest.mock import patch + +import pytest + +from scripts.validate_commit_msg import CONVENTIONAL_RE, TASK_ID_RE, first_line, get_branch, main + + +class TestHelpers: + def test_first_line_single(self) -> None: + assert first_line("feat: add something") == "feat: add something" + + def test_first_line_multiline(self) -> None: + msg = "feat: add something\n\nBody text here.\nMore body." + assert first_line(msg) == "feat: add something" + + def test_conventional_re_matches_valid(self) -> None: + assert CONVENTIONAL_RE.match("feat: add feature") + assert CONVENTIONAL_RE.match("fix: bug fix") + assert CONVENTIONAL_RE.match("chore: update deps") + assert CONVENTIONAL_RE.match("docs: update readme") + assert CONVENTIONAL_RE.match("style: format code") + assert CONVENTIONAL_RE.match("refactor: simplify") + assert CONVENTIONAL_RE.match("perf: speed up") + assert CONVENTIONAL_RE.match("test: add tests") + assert CONVENTIONAL_RE.match("ci: update workflow") + assert CONVENTIONAL_RE.match("build: update deps") + assert CONVENTIONAL_RE.match("revert: undo change") + assert CONVENTIONAL_RE.match("BREAKING CHANGE: major") + + def test_conventional_re_allows_scope(self) -> None: + assert CONVENTIONAL_RE.match("feat(cli): add --url option") + assert CONVENTIONAL_RE.match("fix(api): handle timeout") + + def test_conventional_re_rejects_invalid(self) -> None: + assert not CONVENTIONAL_RE.match("GRM-19: feat: something") + assert not CONVENTIONAL_RE.match("random message") + assert not CONVENTIONAL_RE.match("feat:") + assert not CONVENTIONAL_RE.match(": description") + + def test_task_id_re_matches(self) -> None: + assert TASK_ID_RE.match("GRM-19: feat: something") + assert TASK_ID_RE.match("GRM-123: fix: bug") + + def test_task_id_re_rejects(self) -> None: + assert not TASK_ID_RE.match("feat: something") + assert not TASK_ID_RE.match("GRM: something") + + +class TestGetBranch: + def test_returns_branch_name(self) -> None: + with patch("subprocess.run") as mock_run: + mock_run.return_value.stdout = "feature-branch\n" + mock_run.return_value.returncode = 0 + assert get_branch() == "feature-branch" + + def test_returns_empty_on_error(self) -> None: + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git")): + assert get_branch() == "" + + +class TestMain: + def _write_msg(self, content: str) -> str: + fd, path = tempfile.mkstemp() + with os.fdopen(fd, "w") as f: + f.write(content) + return path + + def test_rejects_task_id_on_feature_branch(self) -> None: + msg_path = self._write_msg("GRM-19: feat: add feature") + with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"): + with pytest.raises(SystemExit) as exc: + main(["validate_commit_msg.py", msg_path]) + assert exc.value.code == 1 + + def test_accepts_conventional_on_feature_branch(self) -> None: + msg_path = self._write_msg("feat: add feature") + 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: + 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_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"): + with pytest.raises(SystemExit) as exc: + main(["validate_commit_msg.py", msg_path]) + assert exc.value.code == 1 + + def test_accepts_multiline_conventional(self) -> None: + msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.") + with patch("scripts.validate_commit_msg.get_branch", return_value="feature"): + main(["validate_commit_msg.py", msg_path]) + + def test_usage_message_without_args(self) -> None: + with pytest.raises(SystemExit) as exc: + main([]) + assert exc.value.code == 1 + + +def test_main_module_block() -> None: + with patch("scripts.validate_commit_msg.get_branch", return_value="master"): + import scripts.validate_commit_msg as vcm + + with open(vcm.__file__) as f: + source = f.read() + # Remove __main__ block so exec doesn't call main() before we control sys.argv + 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"])