#!/usr/bin/env python3
#
# Copyright 2016-present the IoT DC3 original author or authors.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
#
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ALLOWED_TYPES = {
"feat",
"fix",
"perf",
"refactor",
"docs",
"build",
"ci",
"test",
"chore",
"style",
"security",
"revert",
}
WEAK_DESCRIPTIONS = {
".",
"add",
"add comment",
"change",
"change code",
"changes",
"fix",
"fix bug",
"misc",
"optimize",
"update",
"update code",
"wip",
}
SUBJECT_RE = re.compile(
r"^(?P[a-z]+)"
r"(?:\((?P[a-z0-9][a-z0-9._/-]*(?:,[a-z0-9][a-z0-9._/-]*)*)\))?"
r"(?P!)?: (?P.+)$"
)
CJK_RE = re.compile(r"[\u4e00-\u9fff]")
FIXUP_RE = re.compile(r"^(fixup|squash)! +", re.IGNORECASE)
GENERATED_CHANGELOG_SUBJECT = "docs(release): update generated changelog"
MAX_SUBJECT_LENGTH = 100
MIN_DESCRIPTION_LENGTH = 10
def read_subject(message_file: Path) -> tuple[str, str]:
text = message_file.read_text(encoding="utf-8")
lines = [line.rstrip() for line in text.splitlines()]
content_lines = [line for line in lines if line.strip() and not line.lstrip().startswith("#")]
subject = content_lines[0] if content_lines else ""
body = "\n".join(content_lines[1:])
while FIXUP_RE.match(subject):
subject = FIXUP_RE.sub("", subject, count=1)
return subject, body
def is_git_generated_subject(subject: str) -> bool:
return subject.startswith("Merge ") or subject.startswith("Revert ")
def normalized_description(description: str) -> str:
return re.sub(r"[\s.。!!??_-]+", " ", description.strip().lower()).strip()
def validate_subject(subject: str, body: str) -> list[str]:
errors: list[str] = []
if not subject:
return ["Commit subject is required."]
if is_git_generated_subject(subject):
return []
if subject == GENERATED_CHANGELOG_SUBJECT:
return []
if len(subject) > MAX_SUBJECT_LENGTH:
errors.append(f"Commit subject must be at most {MAX_SUBJECT_LENGTH} characters.")
if CJK_RE.search(subject):
errors.append("Commit subject must be written in English.")
match = SUBJECT_RE.match(subject)
if not match:
errors.append("Commit subject must match '(optional-scope): '.")
return errors
commit_type = match.group("type")
description = match.group("description").strip()
breaking = bool(match.group("breaking"))
if commit_type not in ALLOWED_TYPES:
allowed = ", ".join(sorted(ALLOWED_TYPES))
errors.append(f"Unsupported commit type '{commit_type}'. Allowed types: {allowed}.")
if len(description) < MIN_DESCRIPTION_LENGTH:
errors.append(f"Commit description must be at least {MIN_DESCRIPTION_LENGTH} characters.")
if normalized_description(description) in WEAK_DESCRIPTIONS:
errors.append("Commit description is too vague for generated release notes.")
if description[0].islower() is False and not description[0].isdigit():
errors.append("Commit description should start with lowercase imperative text.")
if breaking and "BREAKING CHANGE:" not in body:
errors.append("Breaking-change commits using '!' must include a 'BREAKING CHANGE:' body entry.")
return errors
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Validate IoT DC3 commit messages.")
parser.add_argument("message_file", type=Path)
return parser.parse_args()
def main() -> int:
args = parse_args()
subject, body = read_subject(args.message_file)
errors = validate_subject(subject, body)
if not errors:
return 0
print("Invalid commit message:", file=sys.stderr)
print(f" {subject or ''}", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
print("", file=sys.stderr)
print("Examples:", file=sys.stderr)
print(" feat(agentic): add session cleanup policy", file=sys.stderr)
print(" fix(manager): validate tenant scope for device queries", file=sys.stderr)
print(" docs(release): update generated changelog", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())