Files
devx/src/devx/ci/validate_commit_msg.py
T
emil 9060cd7b1e
Post-merge / detect-type (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / release (push) Successful in 58s
Post-merge / vikunja (push) Successful in 19s
Post-merge / badges (push) Successful in 55s
Post-merge / sync-wiki (push) Successful in 1m18s
DEVX-57: feat: add FORCE_DEPLOY env var, --git flag, --from-tag flag
2026-06-25 23:22:41 +00:00

129 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Validate commit messages for devx.
Rules:
- On feature branches: conventional commits ONLY, must NOT include <PREFIX>-N prefix.
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
e.g. 'DEVX-24: fix: resolve timeout'.
The task ID prefix is configurable via the ``DEVX_TASK_PREFIX`` environment
variable (default: ``DEVX``). Projects consuming devx (e.g., GRM) set
their own prefix (e.g., ``GRM``) so the validator enforces the correct
task ID format for each project.
"""
import re
import subprocess # nosec B404
import sys
import click
from devx.config import CONVENTIONAL_RE, TASK_PREFIX
from devx.i18n import _
MASTER_TASK_ID_RE = re.compile(rf"^{TASK_PREFIX}-\d+:")
def get_latest_commit_msg() -> str:
"""Get the latest commit message from git."""
result = subprocess.run( # nosec
["git", "log", "-1", "--format=%B"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def first_line(text: str) -> str:
return text.split("\n")[0]
def get_branch() -> str:
try:
result = subprocess.run( # nosec
["git", "symbolic-ref", "--short", "HEAD"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return ""
@click.command()
@click.argument("commit_msg_file", required=False)
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
@click.option(
"--git",
"from_git",
is_flag=True,
default=False,
help="Read commit message from git log instead of a file.",
)
def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> None:
if from_git:
msg = get_latest_commit_msg()
elif commit_msg_file:
if commit_msg_file == "-":
msg = sys.stdin.read().strip()
else:
with open(commit_msg_file) as f:
msg = f.read().strip()
else:
raise click.ClickException(_("Provide a commit message file or use --git."))
if branch is None:
branch = get_branch()
subject = first_line(msg)
if branch == "master":
if not MASTER_TASK_ID_RE.match(subject):
raise click.ClickException(
_(
"Oops! Master branch commits must start with a task ID.\n"
" Expected: {prefix}-N: <conventional commit message>\n"
" Got: {subject}",
prefix=TASK_PREFIX,
subject=subject,
)
)
remainder = MASTER_TASK_ID_RE.sub("", subject).strip()
if not CONVENTIONAL_RE.match(remainder):
raise click.ClickException(
_(
"Oops! Master branch commit must follow conventional format after task ID.\n"
" Expected: {prefix}-N: <type>: <description>\n"
" Got: {subject}",
prefix=TASK_PREFIX,
subject=subject,
)
)
return
if MASTER_TASK_ID_RE.match(subject):
raise click.ClickException(
_(
"Oops! Do not include task ID ({prefix}-N) in feature branch commits.\n"
" The task ID will be added automatically on merge via CI.",
prefix=TASK_PREFIX,
)
)
if not CONVENTIONAL_RE.match(subject):
raise click.ClickException(
_(
"Oops! Commit message must follow conventional commit format.\n"
" Expected: <type>: <description>\n"
" Got: {subject}\n"
" Allowed types: feat, fix, chore, docs, style, refactor,\n"
" perf, test, ci, build, revert, BREAKING CHANGE",
subject=subject,
)
)
if __name__ == "__main__": # pragma: no cover
main()