#!/usr/bin/env python3 from __future__ import annotations import json import os import pathlib import re import subprocess import sys from typing import Dict, List, Optional, Tuple def _eprint(*args: object) -> None: print(*args, file=sys.stderr) def _git(*args: str) -> str: return subprocess.check_output(["git", *args], text=True).strip() def _repo_root() -> pathlib.Path: return pathlib.Path(_git("rev-parse", "--show-toplevel")) def _find_commit_spec(root: pathlib.Path) -> pathlib.Path: candidates = [ root / "docs" / "common" / "commit_message.md", root / "docs" / "standards" / "playbook" / "docs" / "common" / "commit_message.md", ] for path in candidates: if path.is_file(): return path raise FileNotFoundError( "commit_message.md not found; expected one of:\n" + "\n".join(f"- {p}" for p in candidates) ) def _parse_type_emoji_mapping(md_text: str) -> Dict[str, str]: mapping: Dict[str, str] = {} for raw_line in md_text.splitlines(): line = raw_line.strip() if not (line.startswith("|") and line.endswith("|")): continue if "type" in line and "emoji" in line: continue if re.fullmatch(r"\|\s*-+\s*(\|\s*-+\s*)+\|", line): continue cols = [c.strip() for c in line.strip("|").split("|")] if len(cols) < 2: continue m_type = re.search(r"`([^`]+)`", cols[0]) m_emoji = re.search(r"`(:[^`]+:)`", cols[1]) if not m_type or not m_emoji: continue type_name = m_type.group(1).strip() emoji_code = m_emoji.group(1).strip() mapping[type_name] = emoji_code if not mapping: raise ValueError("failed to parse type/emoji mapping from commit_message.md") return mapping def _validate_subject_line( line: str, mapping: Dict[str, str], *, require_emoji: bool, ) -> Optional[str]: subject = line.strip() if not subject: return "empty subject" m = re.match( r"^(?:(?P:[a-z0-9_+-]+:)\s+)?" r"(?P[a-z]+)" r"(?P\([a-z0-9_]+\))?" r":\s+(?P.+)$", subject, ) if not m: return "does not match ':emoji: type(scope): subject' or 'type(scope): subject'" emoji = m.group("emoji") type_name = m.group("type") text = (m.group("text") or "").rstrip() if type_name not in mapping: return f"unknown type: {type_name}" if emoji: expected = mapping[type_name] if emoji != expected: return f"emoji/type mismatch: got {emoji} {type_name}, expected {expected} for type {type_name}" elif require_emoji: return "missing emoji (set COMMIT_LINT_REQUIRE_EMOJI=0 to allow)" if text.endswith((".", "。")): return "subject should not end with a period" return None def _load_event_payload() -> Tuple[str, Optional[dict]]: event_name = os.getenv("GITHUB_EVENT_NAME") or os.getenv("GITEA_EVENT_NAME") or "" event_path = os.getenv("GITHUB_EVENT_PATH") or os.getenv("GITEA_EVENT_PATH") or "" if not event_path: return event_name, None path = pathlib.Path(event_path) if not path.is_file(): return event_name, None try: return event_name, json.loads(path.read_text(encoding="utf-8")) except Exception as exc: _eprint(f"WARN: failed to parse event payload: {path} ({exc})") return event_name, None def _gather_subjects(event_name: str, payload: Optional[dict]) -> List[Tuple[str, str]]: subjects: List[Tuple[str, str]] = [] if isinstance(payload, dict): if event_name.startswith("pull_request"): pr = payload.get("pull_request") if isinstance(pr, dict): title = (pr.get("title") or "").strip() if title: subjects.append(("pull_request.title", title.splitlines()[0].strip())) if event_name == "push": commits = payload.get("commits") if isinstance(commits, list): for commit in commits: if not isinstance(commit, dict): continue msg = (commit.get("message") or "").strip() if not msg: continue subject = msg.splitlines()[0].strip() sha = commit.get("id") or commit.get("sha") or "" label = f"push.commit {sha[:7]}" if sha else "push.commit" subjects.append((label, subject)) if subjects: return subjects try: subjects.append(("HEAD", _git("log", "-1", "--format=%s", "HEAD"))) except Exception: pass return subjects def main() -> int: try: root = _repo_root() except Exception as exc: _eprint(f"ERROR: not a git repository: {exc}") return 2 os.chdir(root) require_emoji = os.getenv("COMMIT_LINT_REQUIRE_EMOJI", "1") not in ("0", "false", "False") try: spec_path = _find_commit_spec(root) except FileNotFoundError as exc: _eprint(f"ERROR: {exc}") return 2 try: mapping = _parse_type_emoji_mapping(spec_path.read_text(encoding="utf-8")) except Exception as exc: _eprint(f"ERROR: failed to read/parse {spec_path}: {exc}") return 2 event_name, payload = _load_event_payload() subjects = _gather_subjects(event_name, payload) print(f"commit spec: {spec_path}") if event_name: print(f"event: {event_name}") print(f"require emoji: {require_emoji}") print(f"checks: {len(subjects)} subject(s)") errors: List[str] = [] for label, subject in subjects: err = _validate_subject_line(subject, mapping, require_emoji=require_emoji) if err: errors.append(f"- {label}: {err}\n subject: {subject}") if errors: _eprint("ERROR: commit message lint failed:") for item in errors: _eprint(item) return 1 print("OK") return 0 if __name__ == "__main__": raise SystemExit(main())