From 38ea8caaebfad653fa7c25212557495e0e22c911 Mon Sep 17 00:00:00 2001 From: csh Date: Wed, 22 Jul 2026 17:26:20 +0800 Subject: [PATCH] :sparkles: feat(commit-message): bundle policy and validator --- skills/commit-message/SKILL.md | 194 +++++-- .../references/commit_policy.json | 33 ++ .../scripts/validate_commit_message.py | 540 ++++++++++++++++++ test/test_playbook.py | 46 +- 4 files changed, 738 insertions(+), 75 deletions(-) create mode 100644 skills/commit-message/references/commit_policy.json create mode 100644 skills/commit-message/scripts/validate_commit_message.py diff --git a/skills/commit-message/SKILL.md b/skills/commit-message/SKILL.md index 1af891b5..b01f1a19 100644 --- a/skills/commit-message/SKILL.md +++ b/skills/commit-message/SKILL.md @@ -1,92 +1,162 @@ --- name: commit-message -description: "Use when the user asks for help writing a commit message, wants emoji/type(scope): subject formatting, or asks to review staged changes before committing." +description: "当用户需要撰写或审查提交信息、检查已暂存改动、判断是否拆分提交,或使用 emoji 与提交类型(作用域)格式时使用。" --- -# Commit Message(提交信息建议器) +# Commit Message(提交信息) -## Overview +## 概述 -Create a repository-compliant recommendation from the staged diff. Resolve its -boundary and enforced rules before drafting. +根据已暂存的差异生成符合仓库规范的提交信息建议。先确定当前有效的机器策略, +再判断一次提交的逻辑边界,并在展示任何候选信息前完成校验。 -## When to Use +本 skill 自带可独立部署的机器资产: -Use for commit-message drafting, staged-boundary review, split decisions, or -emoji/type(scope): subject formatting. +- `references/commit_policy.json`:提交信息格式策略。 +- `scripts/validate_commit_message.py`:候选信息和 CI 输入校验器。 -## When Not to Use +以包含本文件的目录作为 skill 根目录解析上述路径。不要假设存在 Playbook checkout、 +仓库 `docs/` 目录或特定的当前工作目录。 -Do not use for PR titles, release notes, changelogs, or exact-message execution. +## 适用场景 -## Inputs +以下情况使用本 skill:撰写提交信息、审查已暂存边界、判断是否拆分提交,或处理 +`emoji/type(scope): subject` 格式。 -Use staged/unstaged state and repository policy. +交互式起草 PR 标题、发布说明、变更日志,以及执行用户已经明确给出的原文,不使用 +本 skill。validator 的无参数 CI 模式可以把 `pull_request.title` 当作独立输入校验; +这不表示交互式 commit-message 工作流负责起草 PR 标题。 -## Procedure +## 输入 -1. **Baseline state** +- 暂存状态:`git status --short` 和 `git diff --cached`。 +- 暂存内容指纹: + `git diff --cached --binary --no-ext-diff | git hash-object --stdin`。 +- 未暂存状态,必须与已暂存差异分开检查。 +- 适用的项目指引和机械约束,例如 hook、CI 或显式机器策略。 +- 本 skill 目录中的 bundled policy 和 validator。 - - Inspect staged and unstaged changes separately. - - If nothing is staged, do not produce a final diff-based message. Offer an - unstaged draft only when explicitly requested. - - Record the staged file list and summary for the final consistency check. +## 有效策略 -2. **Resolve effective policy** +validator 按以下固定优先级选择机器策略: - - Read applicable repository instructions, mechanical enforcement - (`commitlint`, hooks, CI workflow/env, validation scripts), then the nearest - `commit_message.md`. - - Effective enforcement overrides optional prose defaults. Report conflicts - and use a form accepted by both whenever possible. - - With no repository policy, state and use this Conventional Commits fallback: - `type(scope): subject`; optional scope; type from `feat`, `fix`, `docs`, - `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, or `revert`; no - emoji; imperative lowercase subject, at most 72 characters, no final period. +1. 命令行 `--policy `。 +2. 环境变量 `COMMIT_POLICY_PATH`。 +3. 本文件旁的 `references/commit_policy.json`。 -3. **Classify the boundary** +同时读取适用的项目指引、hook 和 CI 约束;它们用于发现冲突,但不会改变上述路径 +优先级。需要让项目规则成为机器策略时,必须通过前两项之一显式选择对应 JSON。 +policy 的 `emoji.requirement_env` 未设置时使用 `required_by_default`;设置成 +非空 `false_values` 中任一值时关闭要求,设置成其它值时开启要求。空 +`false_values` 属无效 policy。 - - Identify the dominant intent and required coupled changes. - - For unrelated intents, stop before drafting a combined message. Output - ordered groups with files, intent, and a provisional message for each. - - Continue with one combined message only after the user explicitly opts out - of the recommended split. +显式机器策略与 bundled 默认值不一致时,必须在结果中说明。不要从说明性文件中 +推断规则覆盖。bundled policy 缺失、损坏或版本不支持时,应报告部署错误,不能 +悄悄退回到自行编造的 Conventional Commits 约定。 -4. **Draft and validate** +## 流程 - - Produce one recommendation. Add alternatives only when materially distinct - valid type or scope interpretations remain. - - Add body/footer only for motivation, impact, verification, issue links, or - breaking changes. - - Use a repository validator when it accepts candidate input. Otherwise check - type, emoji, scope, length, subject, body, and footer manually. A HEAD-only - check does not validate a candidate. - - Rerun the staged summary before finalizing. If it changed, reread the diff - and restart classification. +1. **基线状态** -5. **Finalize safely** + 分别检查已暂存和未暂存改动,记录完整 cached diff 的上述指纹。如果没有任何 + 已暂存改动,停止并说明无法根据实际差异生成最终建议。只有用户明确要求时,才 + 可以提供基于未暂存内容的草稿。 - Label the result as a suggestion or final choice. Run `git commit` only after - explicit user authorization. +2. **判断提交边界** -## Output Contract + 找出主要意图,以及保证该意图正确所必需的文件或 hunk。如果同一文件内存在多个 + 无关 hunk,也必须按 patch 边界拆分,不能只按文件归组。如果已暂存改动包含互不 + 相关的意图,按顺序列出拆分组并为每组给出独立信息。在用户明确选择不拆分前, + 不要用一个主题掩盖多个意图。 -| State | Required output | -| --- | --- | -| Single intent | `Detected`, `Spec`, one `Proposed`, `Validation`, optional body/footer and materially distinct alternatives | -| Mixed intents | `Detected`, `Split` groups, and `Notes`; no combined `Proposed` until the user opts out of splitting | +3. **起草信息** -`Spec` names the source, enforcement, and conflicts. `Validation` names the -validator result or manual checks. + 对单一意图给出一个具体建议。只有存在实质不同且均有效的 type 或 scope 解释时, + 才增加备选项。只有在说明动机、影响、验证、任务链接或破坏性变更时,才添加正文 + 或 footer。 -## Success Criteria +4. **校验候选信息** -- Output matches the current staged diff and effective policy -- Mixed work yields a split plan, not a disguised combined message -- Every candidate is validated; filler alternatives are omitted -- No commit runs without explicit authorization + 使用 Python 3.10 或更高版本。根据本文件位置解析 skill 根目录,对每一个候选主题 + 以进程 API 的 `argv` 参数数组运行 validator: -## Failure Handling + ```text + argv = [ + "", + "/scripts/validate_commit_message.py", + "--subject", + "", + ] + ``` -No staged diff: explain and stop. No policy: state the fallback. Conflicts: -report them and use the stricter accepted form. Changed staging: restart. + 路径和候选必须分别作为 argv 元素传入;禁止把候选拼接到 shell 命令字符串中。 + Linux/macOS 通常使用 `python3`,Windows 使用当前环境可用的 Python 3 入口。 + + 对提交信息文件可使用 `--message-file `,但该模式只校验首行主题,不机械 + 校验 body/footer;结果中的 `Validation` 必须明确这一边界。如果项目指引要求使用 + bundled policy 之外的策略,传入 `--policy `;否则让 validator 使用随 skill + 携带的 JSON。无参数运行检查 CI payload;只有未识别事件或本地调用才回退 `HEAD`, + 不能代替对新起草候选的校验。已识别的 push/PR 事件缺失或损坏 payload 时必须失败。 + PR 必须同时校验 `pull_request.title` 和本地 Git 中的 `base.sha..head.sha` 完整提交 + 范围;遇到 shallow repository、缺失范围元数据或 Git 对象时必须失败。PR CI 的 + workflow 编排必须来自可信 base/default branch:直接触发时使用 + `pull_request_target`,或先用 `pull_request` 准备输入、再由默认分支的 + `workflow_run` 执行校验。普通 `pull_request` 中来自 PR head 的 workflow 即使切到 + base worktree,也仍可被待审改动删除或绕过。可信 workflow 必须从 `base.sha` 的 + 可信 base worktree 运行 wrapper、validator 和 policy;只能读取 PR head 的 Git + 对象,不能在持有 secret 的步骤 checkout 或执行 PR head 内容。 + 普通 push 必须校验本地 Git 中的 `before..after` 完整范围;新分支 push 的 `before` + 为零对象时,必须用 payload 的目标 `refs/heads/*` 和本地分支图计算该分支相对其它 + 分支新增的完整提交集合。缺少有效范围元数据、目标引用或所需 Git 对象时必须失败; + push 所在仓库遇到 shallow repository 也必须失败。不能用可能截断的 payload + `commits` 数组或计数字段证明范围完整。 + +5. **一致性复核** + + 最终确定前重新运行 `git status --short` 和 cached diff 指纹。任一结果发生变化时, + 重新阅读差异、判断边界并重新校验候选信息;不要只比较文件名或 diff stat。 + +6. **安全收尾** + + 明确标注结果是建议还是最终选择。除非用户在审阅建议后明确授权,否则绝不运行 + `git commit`,不暂存文件,也不修改 index。 + +## 输出约定 + +以下固定字段名保留英文,以兼容已有调用方;字段内容使用中文: + +单一意图必须包含: + +- `Detected`:已暂存文件、主要意图,以及是否需要拆分。 +- `Spec`:使用的 bundled 或显式机器策略,以及项目机械约束冲突。 +- `Proposed`:一个已经校验通过的主题;确有必要时附正文或 footer。 +- `Validation`:validator 命令及其结果。 +- `Notes`:歧义、剩余风险或实质不同的备选解释。 + +多个意图必须包含 `Detected`、按顺序排列的 `Split` 分组以及 `Notes`。每个 `Split` +分组都必须包含: + +- `Files/Hunks`:文件及具体 hunk/patch 边界;同一文件可出现在不同组。 +- `Intent`:该组唯一的逻辑意图。 +- `Spec`:该组候选使用的机器策略和冲突。 +- `Proposed`:该组已经校验通过的独立主题。 +- `Validation`:该组实际运行的 validator argv 和结果。 + +在用户明确选择不拆分前,不要给出合并后的 `Proposed`。任何组未校验通过时,不得 +把整份拆分建议标为已验证。 + +## 成功标准 + +- 建议准确描述当前已暂存差异,而不是猜测用户意图。 +- 每个候选都使用 bundled 或显式选定的策略完成校验。 +- 混合改动得到明确的拆分建议。 +- 暂存状态或完整 cached diff 指纹变化会触发重新基线和重新判断。 +- 未经明确授权,不执行提交、暂存或 index 写入。 + +## 失败处理 + +- 没有已暂存差异:说明限制,并停止生成最终的差异型信息。 +- policy 或 validator 缺失/无效:报告部署或配置错误。 +- 候选无效:报告 validator 原因,不把它标记为合规信息。 +- 意图混合或无法判断:建议拆分并说明边界依据。 +- 审查期间项目状态变化:重新执行基线和边界判断。 diff --git a/skills/commit-message/references/commit_policy.json b/skills/commit-message/references/commit_policy.json new file mode 100644 index 00000000..4801dcb5 --- /dev/null +++ b/skills/commit-message/references/commit_policy.json @@ -0,0 +1,33 @@ +{ + "schema_version": 1, + "types": { + "init": ":tada:", + "feat": ":sparkles:", + "fix": ":bug:", + "perf": ":rocket:", + "refactor": ":recycle:", + "style": ":art:", + "docs": ":memo:", + "test": ":white_check_mark:", + "deps": ":package:", + "security": ":lock:", + "deprecate": ":warning:", + "remove": ":wastebasket:", + "chore": ":wrench:", + "contrib": ":busts_in_silhouette:", + "release": ":bookmark:" + }, + "scope": { + "optional": true, + "pattern": "^[a-z0-9]+(?:[-_][a-z0-9]+)*$" + }, + "subject": { + "max_length": 72, + "forbidden_suffixes": [".", "。"] + }, + "emoji": { + "required_by_default": true, + "requirement_env": "COMMIT_LINT_REQUIRE_EMOJI", + "false_values": ["0", "false"] + } +} diff --git a/skills/commit-message/scripts/validate_commit_message.py b/skills/commit-message/scripts/validate_commit_message.py new file mode 100644 index 00000000..921b217d --- /dev/null +++ b/skills/commit-message/scripts/validate_commit_message.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Pattern + +TYPE_RE = re.compile(r"^[a-z][a-z0-9-]*$") +EMOJI_RE = re.compile(r"^:[a-z0-9_+-]+:$") +GIT_OBJECT_ID_RE = re.compile(r"^[0-9a-fA-F]{40}(?:[0-9a-fA-F]{24})?$") +HEADER_RE = re.compile( + r"^(?:(?P:[a-z0-9_+-]+:)\s+)?" + r"(?P[a-z][a-z0-9-]*)" + r"(?:\((?P[^()]*)\))?" + r":\s+(?P.+)$" +) + + +class PolicyError(ValueError): + pass + + +class InputError(ValueError): + pass + + +@dataclass(frozen=True) +class CommitPolicy: + source: Path + types: dict[str, str] + scope_optional: bool + scope_pattern: Pattern[str] + subject_max_length: int + subject_forbidden_suffixes: tuple[str, ...] + emoji_required_by_default: bool + emoji_requirement_env: str + emoji_false_values: frozenset[str] + + +def _eprint(*args: object) -> None: + print(*args, file=sys.stderr) + + +def resolve_policy_path(cli_path: str | None) -> Path: + if cli_path is not None: + return Path(cli_path).expanduser() + environment_path = os.getenv("COMMIT_POLICY_PATH") + if environment_path: + return Path(environment_path).expanduser() + return Path(__file__).resolve().parent.parent / "references" / "commit_policy.json" + + +def _load_json(path: Path) -> object: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise PolicyError(f"cannot read policy {path}: {exc}") from exc + + +def load_policy(path: Path) -> CommitPolicy: + source = path.resolve() + raw = _load_json(source) + if not isinstance(raw, dict): + raise PolicyError(f"policy root must be an object: {source}") + + schema_version = raw.get("schema_version") + if type(schema_version) is not int: + raise PolicyError(f"policy schema_version must be the integer 1: {source}") + if schema_version != 1: + raise PolicyError( + f"unsupported policy schema_version {schema_version}: {source}" + ) + + types = raw.get("types") + if not isinstance(types, dict) or not types: + raise PolicyError("policy.types must be a non-empty object") + if not all( + isinstance(name, str) + and TYPE_RE.fullmatch(name) + and isinstance(emoji, str) + and EMOJI_RE.fullmatch(emoji) + for name, emoji in types.items() + ): + raise PolicyError( + "policy.types must map lowercase type names to colon-delimited emoji codes" + ) + + scope = raw.get("scope") + if not isinstance(scope, dict): + raise PolicyError("policy.scope must be an object") + scope_optional = scope.get("optional") + scope_expression = scope.get("pattern") + if not isinstance(scope_optional, bool): + raise PolicyError("policy.scope.optional must be boolean") + if not isinstance(scope_expression, str) or not scope_expression: + raise PolicyError("policy.scope.pattern must be a non-empty string") + try: + scope_pattern = re.compile(scope_expression) + except re.error as exc: + raise PolicyError(f"invalid policy.scope.pattern: {exc}") from exc + + subject = raw.get("subject") + if not isinstance(subject, dict): + raise PolicyError("policy.subject must be an object") + max_length = subject.get("max_length") + forbidden_suffixes = subject.get("forbidden_suffixes") + if type(max_length) is not int or max_length <= 0: + raise PolicyError("policy.subject.max_length must be a positive integer") + if not isinstance(forbidden_suffixes, list) or not all( + isinstance(suffix, str) and suffix for suffix in forbidden_suffixes + ): + raise PolicyError( + "policy.subject.forbidden_suffixes must contain non-empty strings" + ) + + emoji = raw.get("emoji") + if not isinstance(emoji, dict): + raise PolicyError("policy.emoji must be an object") + required_by_default = emoji.get("required_by_default") + requirement_env = emoji.get("requirement_env") + false_values = emoji.get("false_values") + if not isinstance(required_by_default, bool): + raise PolicyError("policy.emoji.required_by_default must be boolean") + if not isinstance(requirement_env, str) or not requirement_env.strip(): + raise PolicyError("policy.emoji.requirement_env must be a non-empty string") + if not isinstance(false_values, list) or not false_values or not all( + isinstance(value, str) and value.strip() for value in false_values + ): + raise PolicyError("policy.emoji.false_values must contain non-empty strings") + + return CommitPolicy( + source=source, + types=dict(types), + scope_optional=scope_optional, + scope_pattern=scope_pattern, + subject_max_length=max_length, + subject_forbidden_suffixes=tuple(forbidden_suffixes), + emoji_required_by_default=required_by_default, + emoji_requirement_env=requirement_env.strip(), + emoji_false_values=frozenset( + value.strip().casefold() for value in false_values + ), + ) + + +def emoji_is_required(policy: CommitPolicy) -> bool: + override = os.getenv(policy.emoji_requirement_env) + if override is None: + return policy.emoji_required_by_default + return override.strip().casefold() not in policy.emoji_false_values + + +def validate_subject( + line: str, + policy: CommitPolicy, + *, + require_emoji: bool, +) -> str | None: + if "\n" in line or "\r" in line: + return "subject must be a single line" + + if not line.strip(): + return "empty subject" + if line != line.strip(): + return "subject must not have leading or trailing whitespace" + subject = line + + match = HEADER_RE.fullmatch(subject) + if not match: + return "does not match ':emoji: type(scope): subject' or 'type(scope): subject'" + + type_name = match.group("type") + scope = match.group("scope") + text = match.group("text").rstrip() + if type_name not in policy.types: + return f"unknown type: {type_name}" + if scope is None and not policy.scope_optional: + return "missing required scope" + if scope is not None and policy.scope_pattern.fullmatch(scope) is None: + return f"invalid scope: {scope}" + + supplied_emoji = match.group("emoji") + if supplied_emoji is None and require_emoji: + false_values = ", ".join( + repr(value) for value in sorted(policy.emoji_false_values) + ) + return ( + "missing emoji " + f"(set {policy.emoji_requirement_env} to one of {false_values} to allow)" + ) + expected_emoji = policy.types[type_name] + if supplied_emoji is not None and supplied_emoji != expected_emoji: + return ( + "emoji/type mismatch: " + f"got {supplied_emoji} {type_name}, expected {expected_emoji}" + ) + + if not text: + return "empty subject" + if len(text) > policy.subject_max_length: + return f"subject exceeds {policy.subject_max_length} characters" + forbidden_suffix = next( + ( + suffix + for suffix in policy.subject_forbidden_suffixes + if text.endswith(suffix) + ), + None, + ) + if forbidden_suffix is not None: + if forbidden_suffix in {".", "。"}: + return ( + "subject should not end with a period " + f"(forbidden suffix {forbidden_suffix!r})" + ) + return f"subject must not end with forbidden suffix {forbidden_suffix!r}" + return None + + +def _read_message_file(path: Path) -> str: + try: + content = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise InputError(f"cannot read message file {path}: {exc}") from exc + lines = content.splitlines() + return lines[0] if lines else "" + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate commit-message subjects") + parser.add_argument("--policy", help="path to a versioned commit policy JSON") + candidates = parser.add_mutually_exclusive_group() + candidates.add_argument("--subject", help="single commit subject to validate") + candidates.add_argument( + "--message-file", + type=Path, + help="UTF-8 commit message file whose first line is validated", + ) + return parser.parse_args(argv) + + +def _requires_event_payload(event_name: str) -> bool: + return event_name == "push" or event_name.startswith("pull_request") + + +def _load_event_payload() -> tuple[str, dict[str, object] | None]: + 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: + if _requires_event_payload(event_name): + raise InputError(f"{event_name} event requires an event payload path") + return event_name, None + + path = Path(event_path) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + if _requires_event_payload(event_name): + raise InputError(f"cannot parse {event_name} event payload {path}: {exc}") from exc + _eprint(f"WARN: failed to parse event payload: {path} ({exc})") + return event_name, None + if not isinstance(payload, dict): + if _requires_event_payload(event_name): + raise InputError(f"{event_name} event payload must be an object: {path}") + _eprint(f"WARN: event payload is not an object: {path}") + return event_name, None + return event_name, payload + + +def _message_subject(message: str, label: str) -> str: + if not message: + raise InputError(f"{label}.message must be a non-empty string") + lines = message.splitlines() + if not lines: + raise InputError(f"{label}.message must contain a subject line") + return lines[0] + + +def _git_log_subjects( + revisions: list[str], + description: str, + *, + allow_empty: bool = False, + label_prefix: str = "push.commit", +) -> list[tuple[str, str]]: + try: + result = subprocess.run( + ["git", "log", "--reverse", "--format=%H%x00%s", *revisions], + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + raise InputError(f"cannot read {description}: {exc}") from exc + if result.returncode != 0: + detail = result.stderr.strip() + suffix = f": {detail}" if detail else "" + raise InputError(f"cannot read {description}{suffix}") + + subjects: list[tuple[str, str]] = [] + for line in result.stdout.splitlines(): + sha, separator, subject = line.partition("\0") + if not separator or not sha or not subject: + raise InputError(f"invalid git log output for {description}") + subjects.append((f"{label_prefix} {sha[:7]}", subject)) + if not subjects and not allow_empty: + raise InputError(f"{description} is empty") + return subjects + + +def _require_complete_git_history(context: str) -> None: + try: + result = subprocess.run( + ["git", "rev-parse", "--is-shallow-repository"], + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + raise InputError(f"cannot inspect repository depth for {context}: {exc}") from exc + if result.returncode != 0: + detail = result.stderr.strip() + suffix = f": {detail}" if detail else "" + raise InputError(f"cannot inspect repository depth for {context}{suffix}") + shallow_state = result.stdout.strip() + if shallow_state == "true": + raise InputError(f"{context} requires complete, non-shallow Git history") + if shallow_state != "false": + raise InputError(f"cannot determine repository depth for {context}") + + +def _zero_before_subjects( + payload: dict[str, object], after: str +) -> list[tuple[str, str]]: + target_ref = payload.get("ref") + if not isinstance(target_ref, str) or not target_ref.startswith("refs/heads/"): + raise InputError("zero-before push event requires a refs/heads/* ref") + try: + check_ref = subprocess.run( + ["git", "check-ref-format", target_ref], + capture_output=True, + text=True, + check=False, + ) + refs_result = subprocess.run( + [ + "git", + "for-each-ref", + "--format=%(refname)%00%(symref)", + "refs/heads", + "refs/remotes", + ], + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + raise InputError(f"cannot inspect refs for zero-before push: {exc}") from exc + if check_ref.returncode != 0: + raise InputError(f"invalid zero-before push ref: {target_ref}") + if refs_result.returncode != 0: + detail = refs_result.stderr.strip() + suffix = f": {detail}" if detail else "" + raise InputError(f"cannot inspect refs for zero-before push{suffix}") + + branch_name = target_ref.removeprefix("refs/heads/") + exclusions: list[str] = [] + for line in refs_result.stdout.splitlines(): + ref_name, separator, symbolic_target = line.partition("\0") + if not separator or not ref_name: + raise InputError("invalid git ref output for zero-before push") + if symbolic_target: + continue + if ref_name == target_ref: + continue + if ref_name.startswith("refs/remotes/"): + remote_parts = ref_name.split("/", 3) + if len(remote_parts) == 4 and remote_parts[3] == branch_name: + continue + exclusions.append(ref_name) + + revisions = [after] + if exclusions: + revisions.extend(["--not", *exclusions]) + return _git_log_subjects( + revisions, + f"zero-before push history for {target_ref}", + allow_empty=True, + ) + + +def _push_range_subjects(payload: dict[str, object]) -> list[tuple[str, str]]: + before = payload.get("before") + after = payload.get("after") + if ( + not isinstance(before, str) + or not isinstance(after, str) + or GIT_OBJECT_ID_RE.fullmatch(before) is None + or GIT_OBJECT_ID_RE.fullmatch(after) is None + ): + raise InputError("push event before/after must be full Git object IDs") + if set(after) == {"0"}: + raise InputError("push event after cannot be the zero object ID") + _require_complete_git_history("push event") + if set(before) == {"0"}: + return _zero_before_subjects(payload, after) + + revision_range = f"{before}..{after}" + return _git_log_subjects( + [revision_range], f"push commit range {revision_range}" + ) + + +def _gather_ci_subjects() -> tuple[str, list[tuple[str, str]]]: + event_name, payload = _load_event_payload() + subjects: list[tuple[str, str]] = [] + + if event_name.startswith("pull_request"): + if not isinstance(payload, dict): + raise InputError(f"{event_name} event payload is required") + pull_request = payload.get("pull_request") + if not isinstance(pull_request, dict): + raise InputError("pull_request event payload.pull_request must be an object") + title = pull_request.get("title") + if not isinstance(title, str) or not title: + raise InputError("pull_request.title must be a non-empty string") + subjects.append(("pull_request.title", _message_subject(title, "pull_request.title"))) + base = pull_request.get("base") + head = pull_request.get("head") + if not isinstance(base, dict) or not isinstance(head, dict): + raise InputError("pull_request.base/head must be objects") + base_sha = base.get("sha") + head_sha = head.get("sha") + if ( + not isinstance(base_sha, str) + or not isinstance(head_sha, str) + or GIT_OBJECT_ID_RE.fullmatch(base_sha) is None + or GIT_OBJECT_ID_RE.fullmatch(head_sha) is None + ): + raise InputError("pull_request.base/head.sha must be full Git object IDs") + _require_complete_git_history("pull request event") + revision_range = f"{base_sha}..{head_sha}" + subjects.extend( + _git_log_subjects( + [revision_range], + f"pull request commit range {revision_range}", + allow_empty=True, + label_prefix="pull_request.commit", + ) + ) + return event_name, subjects + + if event_name == "push": + if not isinstance(payload, dict): + raise InputError("push event payload is required") + commits = payload.get("commits") + if not isinstance(commits, list): + raise InputError("push event payload.commits must be a list") + for index, commit in enumerate(commits): + item_label = f"push.commits[{index}]" + if not isinstance(commit, dict): + raise InputError(f"{item_label} must be an object") + message = commit.get("message") + if not isinstance(message, str): + raise InputError(f"{item_label}.message must be a non-empty string") + subject = _message_subject(message, item_label) + sha = commit.get("id") or commit.get("sha") or "" + sha_text = str(sha) + label = f"push.commit {sha_text[:7]}" if sha_text else "push.commit" + subjects.append((label, subject)) + range_subjects = _push_range_subjects(payload) + return event_name, range_subjects + + try: + result = subprocess.run( + ["git", "log", "-1", "--format=%s", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + raise InputError(f"cannot read HEAD subject: {exc}") from exc + lines = result.stdout.splitlines() + if result.returncode == 0 and lines: + return event_name, [("HEAD", lines[0])] + + detail = result.stderr.strip() + suffix = f": {detail}" if detail else "" + raise InputError(f"no commit subject found in event payload or HEAD{suffix}") + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + try: + policy = load_policy(resolve_policy_path(args.policy)) + if args.subject is not None: + event_name = "" + subjects = [("--subject", args.subject)] + elif args.message_file is not None: + event_name = "" + subjects = [(str(args.message_file), _read_message_file(args.message_file))] + else: + event_name, subjects = _gather_ci_subjects() + except (InputError, PolicyError) as exc: + _eprint(f"ERROR: {exc}") + return 2 + + require_emoji = emoji_is_required(policy) + print(f"commit policy: {policy.source}") + if event_name: + print(f"event: {event_name}") + print(f"require emoji: {require_emoji}") + print(f"checks: {len(subjects)} subject(s)") + for label, _subject in subjects: + print(f"input: {label}") + + errors: list[str] = [] + for label, subject in subjects: + error = validate_subject(subject, policy, require_emoji=require_emoji) + if error: + errors.append(f"- {label}: {error}\n subject: {subject}") + + if errors: + _eprint("ERROR: commit message lint failed:") + for error in errors: + _eprint(error) + return 1 + + print("OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/test_playbook.py b/test/test_playbook.py index c50f3a6b..97edb36d 100644 --- a/test/test_playbook.py +++ b/test/test_playbook.py @@ -52,10 +52,11 @@ def copy_subtree_source(destination: Path) -> None: ) (destination / "skills").mkdir() - shutil.copytree( - ROOT / "skills" / "style-cleanup", - destination / "skills" / "style-cleanup", - ) + for name in ("commit-message",): + shutil.copytree( + ROOT / "skills" / name, + destination / "skills" / name, + ) def write_config(project_root: Path, install_mode: str, playbook_root: Path) -> Path: @@ -86,7 +87,7 @@ no_backup = true [install_skills] agents_home = ".test-agents" mode = "list" -skills = ["style-cleanup"] +skills = ["commit-message"] no_backup = true """.lstrip(), encoding="utf-8", @@ -153,7 +154,9 @@ class PlaybookDeploymentTests(unittest.TestCase): ".agents/index.md", ".agents/tsl/index.md", ".agents/markdown/index.md", - ".test-agents/skills/style-cleanup/SKILL.md", + ".test-agents/skills/commit-message/SKILL.md", + ".test-agents/skills/commit-message/references/commit_policy.json", + ".test-agents/skills/commit-message/scripts/validate_commit_message.py", ) missing = [ path @@ -189,14 +192,31 @@ class PlaybookDeploymentTests(unittest.TestCase): ) self.assertIn(f"- {docs_prefix}", agents_index) - installed_skill = ( - project_root - / ".test-agents/skills/style-cleanup/SKILL.md" - ).read_text(encoding="utf-8") - self.assertIn( - f"`{docs_prefix}/tsl/code_style.md`", installed_skill + source_skill = ROOT / "skills" / "commit-message" + installed_commit_skill = ( + project_root / ".test-agents/skills/commit-message" + ) + self.assertEqual( + (installed_commit_skill / "SKILL.md").read_text(encoding="utf-8"), + (source_skill / "SKILL.md").read_text(encoding="utf-8"), + ) + self.assertEqual( + ( + installed_commit_skill / "references/commit_policy.json" + ).read_text(encoding="utf-8"), + (source_skill / "references/commit_policy.json").read_text( + encoding="utf-8" + ), + ) + self.assertEqual( + ( + installed_commit_skill + / "scripts/validate_commit_message.py" + ).read_text(encoding="utf-8"), + ( + source_skill / "scripts/validate_commit_message.py" + ).read_text(encoding="utf-8"), ) - self.assertNotIn("`docs/tsl/code_style.md`", installed_skill) rules_text = (project_root / "AGENT_RULES.md").read_text( encoding="utf-8"