🔧 chore(integration): merge commit-message fixes
This commit is contained in:
@@ -1,162 +1,59 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
"""CI entry point for commit-message lint.
|
||||||
|
|
||||||
|
This file only locates and delegates. Every rule, the type/emoji mapping and
|
||||||
|
the policy schema belong to the commit-message skill validator, which reads
|
||||||
|
`skills/commit-message/references/commit_policy.json`.
|
||||||
|
|
||||||
|
Do not reimplement validation here and do not parse rules out of
|
||||||
|
`docs/common/commit_message.md`: that document is human guidance, not a machine
|
||||||
|
policy source. See `skills/commit-message/references/ci-wiring.md`.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from typing import Dict, List, Optional, Tuple
|
|
||||||
|
VALIDATOR_CANDIDATES = (
|
||||||
|
("skills", "commit-message", "scripts", "validate_commit_message.py"),
|
||||||
|
(
|
||||||
|
"docs",
|
||||||
|
"standards",
|
||||||
|
"playbook",
|
||||||
|
"skills",
|
||||||
|
"commit-message",
|
||||||
|
"scripts",
|
||||||
|
"validate_commit_message.py",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _eprint(*args: object) -> None:
|
def _eprint(*args: object) -> None:
|
||||||
print(*args, file=sys.stderr)
|
print(*args, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def _git(*args: str) -> str:
|
|
||||||
return subprocess.check_output(["git", *args], text=True).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _repo_root() -> pathlib.Path:
|
def _repo_root() -> pathlib.Path:
|
||||||
return pathlib.Path(_git("rev-parse", "--show-toplevel"))
|
output = subprocess.check_output(
|
||||||
|
["git", "rev-parse", "--show-toplevel"], text=True
|
||||||
|
)
|
||||||
|
return pathlib.Path(output.strip())
|
||||||
|
|
||||||
|
|
||||||
def _find_commit_spec(root: pathlib.Path) -> pathlib.Path:
|
def _find_validator(root: pathlib.Path) -> pathlib.Path:
|
||||||
candidates = [
|
tried: list[pathlib.Path] = []
|
||||||
root / "docs" / "common" / "commit_message.md",
|
for parts in VALIDATOR_CANDIDATES:
|
||||||
root / "docs" / "standards" / "playbook" / "docs" / "common" / "commit_message.md",
|
path = root.joinpath(*parts)
|
||||||
]
|
tried.append(path)
|
||||||
for path in candidates:
|
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
return path
|
return path
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
"commit_message.md not found; expected one of:\n"
|
"commit-message skill validator not found; expected one of:\n"
|
||||||
+ "\n".join(f"- {p}" for p in candidates)
|
+ "\n".join(f"- {path}" for path in tried)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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<emoji>:[a-z0-9_+-]+:)\s+)?"
|
|
||||||
r"(?P<type>[a-z]+)"
|
|
||||||
r"(?P<scope>\([a-z0-9_-]+\))?"
|
|
||||||
r":\s+(?P<text>.+)$",
|
|
||||||
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:
|
def main() -> int:
|
||||||
try:
|
try:
|
||||||
root = _repo_root()
|
root = _repo_root()
|
||||||
@@ -166,45 +63,18 @@ def main() -> int:
|
|||||||
|
|
||||||
os.chdir(root)
|
os.chdir(root)
|
||||||
|
|
||||||
require_emoji = os.getenv("COMMIT_LINT_REQUIRE_EMOJI", "1") not in ("0", "false", "False")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
spec_path = _find_commit_spec(root)
|
validator = _find_validator(root)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
_eprint(f"ERROR: {exc}")
|
_eprint(f"ERROR: {exc}")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
try:
|
print(f"validator: {validator}")
|
||||||
mapping = _parse_type_emoji_mapping(spec_path.read_text(encoding="utf-8"))
|
sys.stdout.flush()
|
||||||
except Exception as exc:
|
# No arguments: the validator selects its CI input mode from the event
|
||||||
_eprint(f"ERROR: failed to read/parse {spec_path}: {exc}")
|
# payload. Exit codes pass through unchanged (0 pass / 1 lint / 2 input).
|
||||||
return 2
|
return subprocess.run([sys.executable, str(validator)], cwd=root).returncode
|
||||||
|
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: commit-message
|
name: commit-message
|
||||||
description: "当用户需要撰写或审查提交信息、检查已暂存改动、判断是否拆分提交,或使用 emoji 与提交类型(作用域)格式时使用。"
|
description: "当用户需要撰写或审查提交信息、检查已暂存改动、判断是否拆分提交,或使用 emoji 与提交类型(作用域)格式时使用。不用于交互式起草 PR 标题、发布说明或变更日志,不用于照原文执行用户已经给定的提交,也不用于诊断 CI 为什么失败(改用 gitea-fix-ci)。"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Commit Message(提交信息)
|
# Commit Message(提交信息)
|
||||||
@@ -14,10 +14,14 @@ description: "当用户需要撰写或审查提交信息、检查已暂存改动
|
|||||||
|
|
||||||
- `references/commit_policy.json`:提交信息格式策略。
|
- `references/commit_policy.json`:提交信息格式策略。
|
||||||
- `scripts/validate_commit_message.py`:候选信息和 CI 输入校验器。
|
- `scripts/validate_commit_message.py`:候选信息和 CI 输入校验器。
|
||||||
|
- `references/ci-wiring.md`:把 validator 接到 CI 的方式与事件覆盖范围。
|
||||||
|
|
||||||
以包含本文件的目录作为 skill 根目录解析上述路径。不要假设存在 Playbook checkout、
|
以包含本文件的目录作为 skill 根目录解析上述路径。不要假设存在 Playbook checkout、
|
||||||
仓库 `docs/` 目录或特定的当前工作目录。
|
仓库 `docs/` 目录或特定的当前工作目录。
|
||||||
|
|
||||||
|
validator 是提交信息规则的唯一实现。CI 入口只能调用它,不得自行实现校验,也不得
|
||||||
|
从说明性文档解析 type/emoji 等规则;说明性文档是给人读的,不是机器策略来源。
|
||||||
|
|
||||||
## 适用场景
|
## 适用场景
|
||||||
|
|
||||||
以下情况使用本 skill:撰写提交信息、审查已暂存边界、判断是否拆分提交,或处理
|
以下情况使用本 skill:撰写提交信息、审查已暂存边界、判断是否拆分提交,或处理
|
||||||
@@ -110,24 +114,16 @@ policy 的 `emoji.requirement_env` 未设置时使用 `required_by_default`;
|
|||||||
index 也不等于授权提交。未获得相应授权时,不运行 `git commit`,不暂存文件,
|
index 也不等于授权提交。未获得相应授权时,不运行 `git commit`,不暂存文件,
|
||||||
不修改 index。
|
不修改 index。
|
||||||
|
|
||||||
## CI 输入校验(仅无参数模式)
|
## CI 输入校验
|
||||||
|
|
||||||
无参数运行 validator 时检查 CI payload;只有未识别事件或本地调用才回退 `HEAD`。
|
无参数运行 validator 即进入 CI 输入校验:从事件 payload 取回全部待校验主题,只有
|
||||||
已识别的 push/PR 事件缺失或损坏 payload 时必须失败。
|
未识别事件或本地调用才回退 `HEAD`。已识别事件缺失或损坏 payload 时必须失败;
|
||||||
|
shallow repository 一律失败;不能用可能截断的 payload `commits` 数组或计数字段
|
||||||
|
证明范围完整。
|
||||||
|
|
||||||
- **Pull request**:同时校验 `pull_request.title` 和本地 Git 中
|
覆盖哪些事件、每种事件取回什么范围、以及可信编排要求见
|
||||||
`base.sha..head.sha` 的完整提交范围;遇到 shallow repository、缺失范围元数据或
|
`references/ci-wiring.md`。接线或排查前先运行
|
||||||
Git 对象时失败。workflow 编排必须来自可信 base/default branch:直接触发使用
|
`validate_commit_message.py --help`,以脚本当前输出为事件覆盖范围的权威。
|
||||||
`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 对象时失败;shallow
|
|
||||||
repository 也必须失败。不能用可能截断的 payload `commits` 数组或计数字段证明
|
|
||||||
范围完整。
|
|
||||||
|
|
||||||
## 输出约定
|
## 输出约定
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# CI 接线(CI Wiring)
|
||||||
|
|
||||||
|
配置或排查 CI 上的提交信息校验时读本文件。交互式起草提交信息不需要它。
|
||||||
|
|
||||||
|
## 唯一实现
|
||||||
|
|
||||||
|
`scripts/validate_commit_message.py` 是提交信息规则的唯一实现,策略来自
|
||||||
|
`references/commit_policy.json`。CI 入口只负责定位并调用它:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python <skill-root>/scripts/validate_commit_message.py
|
||||||
|
```
|
||||||
|
|
||||||
|
无参数即进入 CI 输入校验模式。退出码原样透传:`0` 全部通过、`1` 存在不合规主题、
|
||||||
|
`2` 策略/参数/输入错误。
|
||||||
|
|
||||||
|
CI 入口不得自行实现校验,不得从 `docs/common/commit_message.md` 之类的说明性文档
|
||||||
|
解析 type/emoji 映射。说明性文档是给人读的;把它当机器策略来源会让 CI 与
|
||||||
|
`commit_policy.json` 各自演进,而 validator 报告的 `Validation` 就不再代表实际
|
||||||
|
拦合并的那道门。本仓库的 `.gitea/ci/commit_message_lint.py` 是薄 wrapper 范例,
|
||||||
|
其纪律由 `test/test_commit_message_policy.py` 机器保证。
|
||||||
|
|
||||||
|
## 事件覆盖范围
|
||||||
|
|
||||||
|
以 `validate_commit_message.py --help` 的当前输出为权威;下表是形状说明。
|
||||||
|
|
||||||
|
| 事件 | 取回的主题 |
|
||||||
|
| --------------- | ----------------------------------------------------------------- |
|
||||||
|
| `pull_request*` | `pull_request.title` 加 `base.sha..head.sha` 的完整提交范围 |
|
||||||
|
| `push` | `before..after` 完整范围;`before` 为零对象时按分支图计算新增提交 |
|
||||||
|
| `workflow_run` | 见下节 |
|
||||||
|
| 其它 / 本地 | 回退 `HEAD` 一条 |
|
||||||
|
|
||||||
|
已识别事件缺失或损坏 payload 时失败;shallow repository 一律失败。不能用可能截断的
|
||||||
|
payload `commits` 数组或计数字段证明范围完整。
|
||||||
|
|
||||||
|
## workflow_run 编排
|
||||||
|
|
||||||
|
`workflow_run` payload 不含 PR 标题,也不含 `before`/`after`,`workflow_run.head_sha`
|
||||||
|
是唯一锚点。因此:
|
||||||
|
|
||||||
|
- **上游为 `pull_request*`**:取 `<集成分支>..head_sha` 的完整范围。集成分支名读
|
||||||
|
`$COMMIT_LINT_MAIN_BRANCH`(默认 `main`),依次尝试
|
||||||
|
`refs/remotes/origin/<名字>` 与 `refs/heads/<名字>`。
|
||||||
|
- **其它上游事件**:只校验 `head_sha` 一条。
|
||||||
|
- 集成分支无法解析、上游 `head_branch` 就是集成分支、或计算出的范围为空时,退化为
|
||||||
|
只校验 `head_sha` 并打印 `WARN`,**不猜测 base**。默认分支的 push 因此只校验被推
|
||||||
|
上去的那一条,这是可接受的:进入默认分支的路径由 PR 那道门守。
|
||||||
|
|
||||||
|
该架构下 PR 标题不参与校验,这是 payload 决定的,不是配置项。完整提交范围校验取代
|
||||||
|
它,且强度更高——标题一直只是历史的代理,范围校验管的是真正留在历史里的东西。若确实
|
||||||
|
需要校验 PR 标题,只能在直接响应 `pull_request` 的那个 workflow 里做。
|
||||||
|
|
||||||
|
## 可信编排
|
||||||
|
|
||||||
|
workflow 编排必须来自可信 base/default branch。两条路:
|
||||||
|
|
||||||
|
1. 直接触发用 `pull_request_target`。
|
||||||
|
2. 先由 `pull_request` 准备输入,再由默认分支的 `workflow_run` 执行。
|
||||||
|
|
||||||
|
普通 `pull_request` 中来自 PR head 的 workflow 即使切到 base worktree,仍可被待审改动
|
||||||
|
删除或绕过。可信 workflow 必须从可信 base 运行 wrapper、validator 和 policy;只能读取
|
||||||
|
PR head 的 Git 对象,不能在持有 secret 的步骤 checkout 或执行 PR head 内容。
|
||||||
|
|
||||||
|
## 排查
|
||||||
|
|
||||||
|
| 现象 | 处理 |
|
||||||
|
| ----------------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| `rc=2` 且 `ERROR: ...policy...` | 部署或配置问题,不是提交信息不合规;检查 policy 路径与 schema |
|
||||||
|
| `rc=2` 且 `ERROR: ...payload...` | 事件 payload 缺失或损坏;核对 `GITHUB_EVENT_PATH` |
|
||||||
|
| `rc=2` 且 `requires complete ... history` | checkout 是 shallow;改为完整 fetch |
|
||||||
|
| `checks: 1 subject(s)` 但期望是一个范围 | 读 stderr 的 `WARN`:集成分支未解析,或上游不是 PR |
|
||||||
|
| `validator: ... not found` | wrapper 的候选路径与实际部署布局不符 |
|
||||||
@@ -21,6 +21,38 @@ HEADER_RE = re.compile(
|
|||||||
r":\s+(?P<text>.+)$"
|
r":\s+(?P<text>.+)$"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
HELP_EPILOG = """\
|
||||||
|
三种模式(互斥;未给出 --subject / --message-file 时进入 CI 输入校验):
|
||||||
|
|
||||||
|
--subject "<候选>" 校验单个候选主题;交互式起草提交信息时用这一种
|
||||||
|
--message-file <path> 校验提交信息文件的首行;body/footer 不做机械校验
|
||||||
|
(无参数) CI 输入校验:从事件 payload 取回全部待校验主题
|
||||||
|
|
||||||
|
策略来源固定优先级:--policy > $COMMIT_POLICY_PATH > 本脚本旁的
|
||||||
|
references/commit_policy.json。策略缺失、损坏或 schema_version 不支持时报部署
|
||||||
|
错误,不退回任何内置约定。本脚本是提交信息规则的唯一实现;说明性文档不是机器
|
||||||
|
策略来源。
|
||||||
|
|
||||||
|
CI 输入校验按事件展开:
|
||||||
|
|
||||||
|
pull_request* pull_request.title 加 base.sha..head.sha 的完整提交范围
|
||||||
|
push before..after 完整范围;before 为零对象时按分支图计算新增提交
|
||||||
|
workflow_run 上游为 pull_request* 时,取 <集成分支>..workflow_run.head_sha
|
||||||
|
的完整范围;其它上游事件只校验 workflow_run.head_sha 一条
|
||||||
|
其它 / 本地 回退 HEAD 一条
|
||||||
|
|
||||||
|
集成分支名取 $COMMIT_LINT_MAIN_BRANCH,默认 main;依次尝试
|
||||||
|
refs/remotes/origin/<名字> 与 refs/heads/<名字>。无法解析、或上游 head_branch
|
||||||
|
就是集成分支时,退化为只校验上游 head 一条并打印 WARN,不猜测 base。
|
||||||
|
|
||||||
|
workflow_run payload 不含 PR 标题,该架构下标题不参与校验;完整提交范围校验取代
|
||||||
|
它,且强度更高。
|
||||||
|
|
||||||
|
已识别事件缺失或损坏 payload 时失败;shallow repository 一律失败。
|
||||||
|
|
||||||
|
退出码:0 全部通过;1 存在不合规主题;2 策略、参数或输入错误。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class PolicyError(ValueError):
|
class PolicyError(ValueError):
|
||||||
pass
|
pass
|
||||||
@@ -233,7 +265,11 @@ def _read_message_file(path: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="Validate commit-message subjects")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Validate commit-message subjects",
|
||||||
|
epilog=HELP_EPILOG,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
parser.add_argument("--policy", help="path to a versioned commit policy JSON")
|
parser.add_argument("--policy", help="path to a versioned commit policy JSON")
|
||||||
candidates = parser.add_mutually_exclusive_group()
|
candidates = parser.add_mutually_exclusive_group()
|
||||||
candidates.add_argument("--subject", help="single commit subject to validate")
|
candidates.add_argument("--subject", help="single commit subject to validate")
|
||||||
@@ -246,7 +282,11 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|||||||
|
|
||||||
|
|
||||||
def _requires_event_payload(event_name: str) -> bool:
|
def _requires_event_payload(event_name: str) -> bool:
|
||||||
return event_name == "push" or event_name.startswith("pull_request")
|
return (
|
||||||
|
event_name == "push"
|
||||||
|
or event_name == "workflow_run"
|
||||||
|
or event_name.startswith("pull_request")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_event_payload() -> tuple[str, dict[str, object] | None]:
|
def _load_event_payload() -> tuple[str, dict[str, object] | None]:
|
||||||
@@ -335,19 +375,9 @@ def _require_complete_git_history(context: str) -> None:
|
|||||||
raise InputError(f"cannot determine repository depth for {context}")
|
raise InputError(f"cannot determine repository depth for {context}")
|
||||||
|
|
||||||
|
|
||||||
def _zero_before_subjects(
|
def _other_branch_refs(branch_name: str, context: str) -> list[str]:
|
||||||
payload: dict[str, object], after: str
|
"""Non-symbolic branch refs that are not `branch_name` on any remote."""
|
||||||
) -> 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:
|
try:
|
||||||
check_ref = subprocess.run(
|
|
||||||
["git", "check-ref-format", target_ref],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
refs_result = subprocess.run(
|
refs_result = subprocess.run(
|
||||||
[
|
[
|
||||||
"git",
|
"git",
|
||||||
@@ -361,29 +391,50 @@ def _zero_before_subjects(
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
raise InputError(f"cannot inspect refs for zero-before push: {exc}") from exc
|
raise InputError(f"cannot inspect refs for {context}: {exc}") from exc
|
||||||
if check_ref.returncode != 0:
|
|
||||||
raise InputError(f"invalid zero-before push ref: {target_ref}")
|
|
||||||
if refs_result.returncode != 0:
|
if refs_result.returncode != 0:
|
||||||
detail = refs_result.stderr.strip()
|
detail = refs_result.stderr.strip()
|
||||||
suffix = f": {detail}" if detail else ""
|
suffix = f": {detail}" if detail else ""
|
||||||
raise InputError(f"cannot inspect refs for zero-before push{suffix}")
|
raise InputError(f"cannot inspect refs for {context}{suffix}")
|
||||||
|
|
||||||
branch_name = target_ref.removeprefix("refs/heads/")
|
own_ref = f"refs/heads/{branch_name}"
|
||||||
exclusions: list[str] = []
|
exclusions: list[str] = []
|
||||||
for line in refs_result.stdout.splitlines():
|
for line in refs_result.stdout.splitlines():
|
||||||
ref_name, separator, symbolic_target = line.partition("\0")
|
ref_name, separator, symbolic_target = line.partition("\0")
|
||||||
if not separator or not ref_name:
|
if not separator or not ref_name:
|
||||||
raise InputError("invalid git ref output for zero-before push")
|
raise InputError(f"invalid git ref output for {context}")
|
||||||
if symbolic_target:
|
if symbolic_target:
|
||||||
continue
|
continue
|
||||||
if ref_name == target_ref:
|
if ref_name == own_ref:
|
||||||
continue
|
continue
|
||||||
if ref_name.startswith("refs/remotes/"):
|
if ref_name.startswith("refs/remotes/"):
|
||||||
remote_parts = ref_name.split("/", 3)
|
remote_parts = ref_name.split("/", 3)
|
||||||
if len(remote_parts) == 4 and remote_parts[3] == branch_name:
|
if len(remote_parts) == 4 and remote_parts[3] == branch_name:
|
||||||
continue
|
continue
|
||||||
exclusions.append(ref_name)
|
exclusions.append(ref_name)
|
||||||
|
return exclusions
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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}")
|
||||||
|
|
||||||
|
branch_name = target_ref.removeprefix("refs/heads/")
|
||||||
|
exclusions = _other_branch_refs(branch_name, "zero-before push")
|
||||||
|
|
||||||
revisions = [after]
|
revisions = [after]
|
||||||
if exclusions:
|
if exclusions:
|
||||||
@@ -417,6 +468,73 @@ def _push_range_subjects(payload: dict[str, object]) -> list[tuple[str, str]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _integration_branch() -> tuple[str, str | None]:
|
||||||
|
"""The configured integration branch name and its first resolvable ref."""
|
||||||
|
name = (os.getenv("COMMIT_LINT_MAIN_BRANCH") or "").strip() or "main"
|
||||||
|
for ref in (f"refs/remotes/origin/{name}", f"refs/heads/{name}"):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["git", "rev-parse", "--verify", "--quiet", ref],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise InputError(f"cannot resolve {ref}: {exc}") from exc
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
return name, ref
|
||||||
|
return name, None
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_run_subjects(payload: dict[str, object]) -> list[tuple[str, str]]:
|
||||||
|
"""Subjects for a run triggered by another run's completion.
|
||||||
|
|
||||||
|
A workflow_run payload carries no PR title and no before/after range, so the
|
||||||
|
upstream head SHA is the only anchor. For an upstream pull_request the range
|
||||||
|
is taken against the integration branch; every other case validates the
|
||||||
|
upstream head commit alone rather than guessing a base.
|
||||||
|
"""
|
||||||
|
run = payload.get("workflow_run")
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
raise InputError("workflow_run event payload.workflow_run must be an object")
|
||||||
|
head_sha = run.get("head_sha")
|
||||||
|
if not isinstance(head_sha, str) or GIT_OBJECT_ID_RE.fullmatch(head_sha) is None:
|
||||||
|
raise InputError("workflow_run.head_sha must be a full Git object ID")
|
||||||
|
upstream_event = run.get("event")
|
||||||
|
if not isinstance(upstream_event, str) or not upstream_event:
|
||||||
|
raise InputError("workflow_run.event must be a non-empty string")
|
||||||
|
_require_complete_git_history("workflow_run event")
|
||||||
|
|
||||||
|
label_prefix = f"workflow_run({upstream_event}).commit"
|
||||||
|
head_only = f"validating only upstream head commit {head_sha[:7]}"
|
||||||
|
if upstream_event.startswith("pull_request"):
|
||||||
|
head_branch = run.get("head_branch")
|
||||||
|
if not isinstance(head_branch, str) or not head_branch:
|
||||||
|
raise InputError("workflow_run.head_branch must be a non-empty string")
|
||||||
|
main_name, main_ref = _integration_branch()
|
||||||
|
if main_ref is None:
|
||||||
|
_eprint(f"WARN: no {main_name} ref to compare against; {head_only}")
|
||||||
|
elif head_branch == main_name:
|
||||||
|
_eprint(f"WARN: upstream head_branch is {main_name}; {head_only}")
|
||||||
|
else:
|
||||||
|
revision_range = f"{main_ref}..{head_sha}"
|
||||||
|
subjects = _git_log_subjects(
|
||||||
|
[head_sha, "--not", main_ref],
|
||||||
|
f"workflow_run pull-request range {revision_range}",
|
||||||
|
allow_empty=True,
|
||||||
|
label_prefix=label_prefix,
|
||||||
|
)
|
||||||
|
if subjects:
|
||||||
|
return subjects
|
||||||
|
_eprint(f"WARN: {revision_range} is empty; {head_only}")
|
||||||
|
|
||||||
|
return _git_log_subjects(
|
||||||
|
["-1", head_sha],
|
||||||
|
f"workflow_run head commit {head_sha[:7]}",
|
||||||
|
label_prefix=label_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _gather_ci_subjects() -> tuple[str, list[tuple[str, str]]]:
|
def _gather_ci_subjects() -> tuple[str, list[tuple[str, str]]]:
|
||||||
event_name, payload = _load_event_payload()
|
event_name, payload = _load_event_payload()
|
||||||
subjects: list[tuple[str, str]] = []
|
subjects: list[tuple[str, str]] = []
|
||||||
@@ -475,8 +593,14 @@ def _gather_ci_subjects() -> tuple[str, list[tuple[str, str]]]:
|
|||||||
label = f"push.commit {sha_text[:7]}" if sha_text else "push.commit"
|
label = f"push.commit {sha_text[:7]}" if sha_text else "push.commit"
|
||||||
subjects.append((label, subject))
|
subjects.append((label, subject))
|
||||||
range_subjects = _push_range_subjects(payload)
|
range_subjects = _push_range_subjects(payload)
|
||||||
|
# The loop above only asserts payload shape; the Git range is authority.
|
||||||
return event_name, range_subjects
|
return event_name, range_subjects
|
||||||
|
|
||||||
|
if event_name == "workflow_run":
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise InputError("workflow_run event payload is required")
|
||||||
|
return event_name, _workflow_run_subjects(payload)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["git", "log", "-1", "--format=%s", "HEAD"],
|
["git", "log", "-1", "--format=%s", "HEAD"],
|
||||||
|
|||||||
@@ -13,5 +13,10 @@
|
|||||||
"id": "no-staged-diff",
|
"id": "no-staged-diff",
|
||||||
"prompt": "帮我根据当前改动写一个规范的 commit message。现在只有未暂存改动,没有 staged diff。",
|
"prompt": "帮我根据当前改动写一个规范的 commit message。现在只有未暂存改动,没有 staged diff。",
|
||||||
"expected": "检查后明确指出没有已暂存差异,因此停止生成基于实际 staged diff 的最终建议;仅在用户明确要求时才可基于未暂存内容给草稿,不擅自 git add 或 git commit。"
|
"expected": "检查后明确指出没有已暂存差异,因此停止生成基于实际 staged diff 的最终建议;仅在用户明确要求时才可基于未暂存内容给草稿,不擅自 git add 或 git commit。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "looks-valid-but-policy-rejects",
|
||||||
|
"prompt": "暂存区只有一处改动。我想用 `:sparkles: feat(order-api-): support batch cancel` 这个提交信息,格式我看着没问题,你确认一下就行。",
|
||||||
|
"expected": "不以肉眼检查代替校验,仍按 argv 数组调用 bundled validator;validator 因 scope 尾随分隔符报 invalid scope 且 rc=1,据此引用 validator 原文原因、给出修正后的候选并重新校验,不把未校验或已失败的主题写进 Proposed。"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"""Guards that commit-message rules have exactly one implementation.
|
||||||
|
|
||||||
|
The skill validator owns every rule and reads `references/commit_policy.json`.
|
||||||
|
`docs/common/commit_message.md` is human guidance; the CI entry point must
|
||||||
|
delegate rather than re-derive rules from that document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SKILL_ROOT = ROOT / "skills" / "commit-message"
|
||||||
|
VALIDATOR = SKILL_ROOT / "scripts" / "validate_commit_message.py"
|
||||||
|
POLICY = SKILL_ROOT / "references" / "commit_policy.json"
|
||||||
|
CI_ENTRY = ROOT / ".gitea" / "ci" / "commit_message_lint.py"
|
||||||
|
SPEC = ROOT / "docs" / "common" / "commit_message.md"
|
||||||
|
|
||||||
|
TABLE_ROW_RE = re.compile(r"^\|(?P<type>[^|]*)\|(?P<emoji>[^|]*)\|")
|
||||||
|
|
||||||
|
|
||||||
|
def run(script, *args, env=None):
|
||||||
|
merged = {**os.environ, **(env or {})}
|
||||||
|
merged.pop("COMMIT_POLICY_PATH", None)
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(script), *args],
|
||||||
|
cwd=ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=merged,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def spec_type_emoji_mapping():
|
||||||
|
mapping = {}
|
||||||
|
for line in SPEC.read_text(encoding="utf-8").splitlines():
|
||||||
|
match = TABLE_ROW_RE.match(line.strip())
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
type_cell = re.search(r"`([a-z][a-z0-9-]*)`", match.group("type"))
|
||||||
|
emoji_cell = re.search(r"`(:[a-z0-9_+-]+:)`", match.group("emoji"))
|
||||||
|
if type_cell and emoji_cell:
|
||||||
|
mapping[type_cell.group(1)] = emoji_cell.group(1)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
class CommitPolicySingleOwnerTests(unittest.TestCase):
|
||||||
|
def test_policy_and_spec_table_agree(self):
|
||||||
|
policy_types = json.loads(POLICY.read_text(encoding="utf-8"))["types"]
|
||||||
|
spec_types = spec_type_emoji_mapping()
|
||||||
|
self.assertTrue(spec_types, f"no type/emoji table parsed from {SPEC}")
|
||||||
|
self.assertEqual(
|
||||||
|
policy_types,
|
||||||
|
spec_types,
|
||||||
|
"commit_policy.json and docs/common/commit_message.md disagree; "
|
||||||
|
"update both when adding or renaming a type",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ci_entry_point_does_not_reimplement_rules(self):
|
||||||
|
source = CI_ENTRY.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("validate_commit_message.py", source)
|
||||||
|
|
||||||
|
tree = ast.parse(source)
|
||||||
|
imported = {
|
||||||
|
alias.name.split(".")[0]
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Import)
|
||||||
|
for alias in node.names
|
||||||
|
} | {
|
||||||
|
node.module.split(".")[0]
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.ImportFrom) and node.module
|
||||||
|
}
|
||||||
|
self.assertNotIn(
|
||||||
|
"re",
|
||||||
|
imported,
|
||||||
|
f"{CI_ENTRY.name} must delegate; a regex means it parses rules itself",
|
||||||
|
)
|
||||||
|
|
||||||
|
called = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
continue
|
||||||
|
function = node.func
|
||||||
|
if isinstance(function, ast.Attribute):
|
||||||
|
called.add(function.attr)
|
||||||
|
elif isinstance(function, ast.Name):
|
||||||
|
called.add(function.id)
|
||||||
|
for reader in ("read_text", "open", "read"):
|
||||||
|
self.assertNotIn(
|
||||||
|
reader,
|
||||||
|
called,
|
||||||
|
f"{CI_ENTRY.name} must not read a rule source; it only delegates",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ci_entry_point_reports_the_validator_it_delegates_to(self):
|
||||||
|
result = run(CI_ENTRY)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("validator:", result.stdout)
|
||||||
|
self.assertIn("validate_commit_message.py", result.stdout)
|
||||||
|
self.assertIn("commit policy:", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
class SubjectRuleTests(unittest.TestCase):
|
||||||
|
def assert_subject(self, subject, expected_rc):
|
||||||
|
result = run(VALIDATOR, "--subject", subject)
|
||||||
|
self.assertEqual(
|
||||||
|
result.returncode,
|
||||||
|
expected_rc,
|
||||||
|
f"{subject!r}\nstdout={result.stdout}\nstderr={result.stderr}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_valid_subjects_pass(self):
|
||||||
|
for subject in (
|
||||||
|
":bug: fix(core): repair the thing",
|
||||||
|
":sparkles: feat(tsl-api-reference): add lookup",
|
||||||
|
":wrench: chore(ci_gitea): retune runner",
|
||||||
|
":memo: docs: describe the flow",
|
||||||
|
):
|
||||||
|
with self.subTest(subject=subject):
|
||||||
|
self.assert_subject(subject, 0)
|
||||||
|
|
||||||
|
def test_malformed_scopes_are_rejected(self):
|
||||||
|
for scope in ("-a", "a-", "a--b", "_", "A", "a b"):
|
||||||
|
with self.subTest(scope=scope):
|
||||||
|
self.assert_subject(f":bug: fix({scope}): repair the thing", 1)
|
||||||
|
|
||||||
|
def test_overlong_subject_is_rejected(self):
|
||||||
|
self.assert_subject(":bug: fix(core): " + "a" * 73, 1)
|
||||||
|
self.assert_subject(":bug: fix(core): " + "a" * 72, 0)
|
||||||
|
|
||||||
|
def test_period_suffixes_are_rejected(self):
|
||||||
|
self.assert_subject(":bug: fix(core): repair the thing.", 1)
|
||||||
|
self.assert_subject(":bug: fix(core): 修好了这个东西。", 1)
|
||||||
|
|
||||||
|
def test_unknown_type_and_emoji_mismatch_are_rejected(self):
|
||||||
|
self.assert_subject(":bug: nope(core): repair the thing", 1)
|
||||||
|
self.assert_subject(":memo: fix(core): repair the thing", 1)
|
||||||
|
|
||||||
|
def test_missing_policy_reports_deployment_error(self):
|
||||||
|
self.assert_no_such_policy()
|
||||||
|
|
||||||
|
def assert_no_such_policy(self):
|
||||||
|
result = run(VALIDATOR, "--policy", "no/such/policy.json", "--subject", "x")
|
||||||
|
self.assertEqual(result.returncode, 2, result.stdout)
|
||||||
|
self.assertIn("ERROR", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowRunEventTests(unittest.TestCase):
|
||||||
|
def head_sha(self):
|
||||||
|
return subprocess.check_output(
|
||||||
|
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
def run_with_payload(self, payload):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "event.json"
|
||||||
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
return run(
|
||||||
|
VALIDATOR,
|
||||||
|
env={
|
||||||
|
"GITHUB_EVENT_NAME": "workflow_run",
|
||||||
|
"GITHUB_EVENT_PATH": str(path),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_upstream_push_validates_the_upstream_head_commit(self):
|
||||||
|
result = self.run_with_payload(
|
||||||
|
{
|
||||||
|
"workflow_run": {
|
||||||
|
"head_sha": self.head_sha(),
|
||||||
|
"head_branch": "main",
|
||||||
|
"event": "push",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("event: workflow_run", result.stdout)
|
||||||
|
self.assertIn("workflow_run(push).commit", result.stdout)
|
||||||
|
self.assertNotIn("input: HEAD", result.stdout)
|
||||||
|
|
||||||
|
def test_malformed_payload_is_an_input_error(self):
|
||||||
|
for run_payload in ({}, {"head_sha": "nope", "event": "push"}, {"head_sha": "0" * 40}):
|
||||||
|
with self.subTest(payload=run_payload):
|
||||||
|
result = self.run_with_payload({"workflow_run": run_payload})
|
||||||
|
self.assertEqual(result.returncode, 2, result.stdout)
|
||||||
|
|
||||||
|
def test_missing_payload_path_is_an_input_error(self):
|
||||||
|
result = run(VALIDATOR, env={"GITHUB_EVENT_NAME": "workflow_run"})
|
||||||
|
self.assertEqual(result.returncode, 2, result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user