✨ feat(commit-message): bundle policy and validator
This commit is contained in:
+132
-62
@@ -1,92 +1,162 @@
|
|||||||
---
|
---
|
||||||
name: commit-message
|
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
|
- `references/commit_policy.json`:提交信息格式策略。
|
||||||
emoji/type(scope): subject formatting.
|
- `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
|
1. 命令行 `--policy <path>`。
|
||||||
(`commitlint`, hooks, CI workflow/env, validation scripts), then the nearest
|
2. 环境变量 `COMMIT_POLICY_PATH`。
|
||||||
`commit_message.md`.
|
3. 本文件旁的 `references/commit_policy.json`。
|
||||||
- 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.
|
|
||||||
|
|
||||||
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.
|
显式机器策略与 bundled 默认值不一致时,必须在结果中说明。不要从说明性文件中
|
||||||
- For unrelated intents, stop before drafting a combined message. Output
|
推断规则覆盖。bundled policy 缺失、损坏或版本不支持时,应报告部署错误,不能
|
||||||
ordered groups with files, intent, and a provisional message for each.
|
悄悄退回到自行编造的 Conventional Commits 约定。
|
||||||
- Continue with one combined message only after the user explicitly opts out
|
|
||||||
of the recommended split.
|
|
||||||
|
|
||||||
4. **Draft and validate**
|
## 流程
|
||||||
|
|
||||||
- Produce one recommendation. Add alternatives only when materially distinct
|
1. **基线状态**
|
||||||
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.
|
|
||||||
|
|
||||||
5. **Finalize safely**
|
分别检查已暂存和未暂存改动,记录完整 cached diff 的上述指纹。如果没有任何
|
||||||
|
已暂存改动,停止并说明无法根据实际差异生成最终建议。只有用户明确要求时,才
|
||||||
|
可以提供基于未暂存内容的草稿。
|
||||||
|
|
||||||
Label the result as a suggestion or final choice. Run `git commit` only after
|
2. **判断提交边界**
|
||||||
explicit user authorization.
|
|
||||||
|
|
||||||
## Output Contract
|
找出主要意图,以及保证该意图正确所必需的文件或 hunk。如果同一文件内存在多个
|
||||||
|
无关 hunk,也必须按 patch 边界拆分,不能只按文件归组。如果已暂存改动包含互不
|
||||||
|
相关的意图,按顺序列出拆分组并为每组给出独立信息。在用户明确选择不拆分前,
|
||||||
|
不要用一个主题掩盖多个意图。
|
||||||
|
|
||||||
| State | Required output |
|
3. **起草信息**
|
||||||
| --- | --- |
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
`Spec` names the source, enforcement, and conflicts. `Validation` names the
|
对单一意图给出一个具体建议。只有存在实质不同且均有效的 type 或 scope 解释时,
|
||||||
validator result or manual checks.
|
才增加备选项。只有在说明动机、影响、验证、任务链接或破坏性变更时,才添加正文
|
||||||
|
或 footer。
|
||||||
|
|
||||||
## Success Criteria
|
4. **校验候选信息**
|
||||||
|
|
||||||
- Output matches the current staged diff and effective policy
|
使用 Python 3.10 或更高版本。根据本文件位置解析 skill 根目录,对每一个候选主题
|
||||||
- Mixed work yields a split plan, not a disguised combined message
|
以进程 API 的 `argv` 参数数组运行 validator:
|
||||||
- Every candidate is validated; filler alternatives are omitted
|
|
||||||
- No commit runs without explicit authorization
|
|
||||||
|
|
||||||
## Failure Handling
|
```text
|
||||||
|
argv = [
|
||||||
|
"<python3>",
|
||||||
|
"<skill-root>/scripts/validate_commit_message.py",
|
||||||
|
"--subject",
|
||||||
|
"<candidate>",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
No staged diff: explain and stop. No policy: state the fallback. Conflicts:
|
路径和候选必须分别作为 argv 元素传入;禁止把候选拼接到 shell 命令字符串中。
|
||||||
report them and use the stricter accepted form. Changed staging: restart.
|
Linux/macOS 通常使用 `python3`,Windows 使用当前环境可用的 Python 3 入口。
|
||||||
|
|
||||||
|
对提交信息文件可使用 `--message-file <path>`,但该模式只校验首行主题,不机械
|
||||||
|
校验 body/footer;结果中的 `Validation` 必须明确这一边界。如果项目指引要求使用
|
||||||
|
bundled policy 之外的策略,传入 `--policy <path>`;否则让 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 原因,不把它标记为合规信息。
|
||||||
|
- 意图混合或无法判断:建议拆分并说明边界依据。
|
||||||
|
- 审查期间项目状态变化:重新执行基线和边界判断。
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<emoji>:[a-z0-9_+-]+:)\s+)?"
|
||||||
|
r"(?P<type>[a-z][a-z0-9-]*)"
|
||||||
|
r"(?:\((?P<scope>[^()]*)\))?"
|
||||||
|
r":\s+(?P<text>.+)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
+31
-11
@@ -52,9 +52,10 @@ def copy_subtree_source(destination: Path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
(destination / "skills").mkdir()
|
(destination / "skills").mkdir()
|
||||||
|
for name in ("commit-message",):
|
||||||
shutil.copytree(
|
shutil.copytree(
|
||||||
ROOT / "skills" / "style-cleanup",
|
ROOT / "skills" / name,
|
||||||
destination / "skills" / "style-cleanup",
|
destination / "skills" / name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ no_backup = true
|
|||||||
[install_skills]
|
[install_skills]
|
||||||
agents_home = ".test-agents"
|
agents_home = ".test-agents"
|
||||||
mode = "list"
|
mode = "list"
|
||||||
skills = ["style-cleanup"]
|
skills = ["commit-message"]
|
||||||
no_backup = true
|
no_backup = true
|
||||||
""".lstrip(),
|
""".lstrip(),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@@ -153,7 +154,9 @@ class PlaybookDeploymentTests(unittest.TestCase):
|
|||||||
".agents/index.md",
|
".agents/index.md",
|
||||||
".agents/tsl/index.md",
|
".agents/tsl/index.md",
|
||||||
".agents/markdown/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 = [
|
missing = [
|
||||||
path
|
path
|
||||||
@@ -189,14 +192,31 @@ class PlaybookDeploymentTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn(f"- {docs_prefix}", agents_index)
|
self.assertIn(f"- {docs_prefix}", agents_index)
|
||||||
|
|
||||||
installed_skill = (
|
source_skill = ROOT / "skills" / "commit-message"
|
||||||
project_root
|
installed_commit_skill = (
|
||||||
/ ".test-agents/skills/style-cleanup/SKILL.md"
|
project_root / ".test-agents/skills/commit-message"
|
||||||
).read_text(encoding="utf-8")
|
)
|
||||||
self.assertIn(
|
self.assertEqual(
|
||||||
f"`{docs_prefix}/tsl/code_style.md`", installed_skill
|
(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(
|
rules_text = (project_root / "AGENT_RULES.md").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
|
|||||||
Reference in New Issue
Block a user