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"])