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
This commit is contained in:
Emil Simeonov
2026-06-19 04:27:43 +02:00
parent 1d3d2487ac
commit 4da724ce53
10 changed files with 755 additions and 3 deletions
+30
View File
@@ -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}\"}"
+43
View File
@@ -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
+67
View File
@@ -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 = '<p><strong>${TASK_ID}</strong>: ${CONV_MSG}</p><p>Commit: <code>${COMMIT_SHA}</code></p>'
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."
+40
View File
@@ -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."
+3 -3
View File
@@ -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"]
View File
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Configure GRM repository: branch protection + labels via Gitea REST API.
Usage:
GITEA_ADMIN_TOKEN=<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()
+64
View File
@@ -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 <commit-msg-file>")
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: <type>: <description>")
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()
+259
View File
@@ -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)
+118
View File
@@ -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"])