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:
@@ -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}\"}"
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
Reference in New Issue
Block a user