feat(skills): 补充 gitlink-scaffold 配套脚本与测试

依审阅意见,将 SKILL.md 方式A引用的脚本(scripts/)与单元测试(tests/)一并纳入,使 PR 自包含可运行。
This commit is contained in:
Ct201314 2026-06-12 18:00:30 +08:00
parent 178255b70c
commit af7a8a2fa9
3 changed files with 770 additions and 0 deletions

View File

@ -0,0 +1,241 @@
"""GitLink 公开 API 共享客户端。
gitlink-skills-pack 下各 Skill 的脚本复用仅依赖 Python 标准库
无需第三方包便于在受限环境或 Agent 沙箱中运行
数据全部来自 GitLink 平台公开接口https://www.gitlink.org.cn/api
默认无需 token如需访问私有仓库可传入 token
所有方法均为只读不修改任何远程数据
"""
from __future__ import annotations
import base64
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
API_BASE = "https://www.gitlink.org.cn/api"
USER_AGENT = "gitlink-skills-pack/1.0 (+https://www.gitlink.org.cn)"
DEFAULT_TIMEOUT = 30
COMMIT_PAGE_SIZE = 50 # GitLink commits 接口每页硬上限
class GitLinkError(RuntimeError):
"""API 调用中不可恢复的错误。"""
class GitLinkClient:
"""GitLink 公开数据接口客户端。
带可选文件缓存同一资源重复读取不重复打网对平台友好
"""
def __init__(self, base: str = API_BASE, token: str | None = None,
timeout: int = DEFAULT_TIMEOUT, cache_dir: Path | None = None) -> None:
self.base = base.rstrip("/")
self.token = token
self.timeout = timeout
self.cache_dir = cache_dir
if self.cache_dir:
self.cache_dir.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# 底层请求
# ------------------------------------------------------------------
def _cache_path(self, url: str) -> Path | None:
if not self.cache_dir:
return None
safe = urllib.parse.quote(url, safe="")
return self.cache_dir / f"{safe}.json"
def get(self, path: str, query: dict[str, Any] | None = None) -> Any:
"""GET 请求,返回解析后的 JSONdict/list或 None。"""
url = f"{self.base}/{path.lstrip('/')}"
if query:
url = f"{url}?{urllib.parse.urlencode(query)}"
cache_path = self._cache_path(url)
if cache_path and cache_path.exists():
return json.loads(cache_path.read_text(encoding="utf-8"))
headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
raise GitLinkError(f"HTTP {exc.code}: {url}") from exc
except urllib.error.URLError as exc:
raise GitLinkError(f"网络错误: {url} -> {exc.reason}") from exc
text = raw.strip()
if not text or text in ("null", "{}", "[]"):
data: Any = None
elif text[0] in "{[":
try:
data = json.loads(text)
except json.JSONDecodeError as exc:
raise GitLinkError(f"响应非 JSON: {url}") from exc
else:
raise GitLinkError(f"响应非 JSON可能是 HTML: {url}")
if cache_path is not None:
cache_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return data
# ------------------------------------------------------------------
# 资源访问(高层封装)
# ------------------------------------------------------------------
def repo_info(self, owner: str, repo: str) -> dict[str, Any]:
"""仓库元信息。"""
data = self.get(f"{owner}/{repo}.json")
return data if isinstance(data, dict) else {}
def issues(self, owner: str, repo: str, limit: int = 50,
page: int = 1) -> list[dict[str, Any]]:
"""Issue 列表。"""
data = self.get(f"{owner}/{repo}/issues.json", {"page": page, "limit": limit})
return _extract_list(data, ("issues",))
def issue_detail(self, owner: str, repo: str, number: int) -> dict[str, Any]:
"""单个 Issue 详情(含完整字段)。"""
data = self.get(f"{owner}/{repo}/issues/{number}.json")
return data if isinstance(data, dict) else {}
def pulls(self, owner: str, repo: str, limit: int = 50,
page: int = 1) -> list[dict[str, Any]]:
"""PR 列表。"""
data = self.get(f"{owner}/{repo}/pulls.json", {"page": page, "limit": limit})
return _extract_list(data, ("issues", "pulls"))
def contributors(self, owner: str, repo: str) -> list[dict[str, Any]]:
"""贡献者列表。"""
data = self.get(f"{owner}/{repo}/contributors.json")
return _extract_list(data, ("list",))
def commits(self, owner: str, repo: str, max_pages: int = 4) -> list[dict[str, Any]]:
"""提交列表(按需翻页,每页 50 条,以 total_count 为终止依据)。"""
out: list[dict[str, Any]] = []
total: int | None = None
for page in range(1, max(1, max_pages) + 1):
data = self.get(f"{owner}/{repo}/commits.json",
{"page": page, "limit": COMMIT_PAGE_SIZE})
if total is None and isinstance(data, dict):
total = _safe_int(data.get("total_count")) or None
page_items = _extract_list(data, ("commits",))
if not page_items:
break
out.extend(page_items)
if total is not None and len(out) >= total:
break
return out
def list_dir(self, owner: str, repo: str, path: str = "",
ref: str = "master") -> list[dict[str, Any]]:
"""列出目录下的条目(文件与子目录)。
返回的每个 entry name / path / type(file|dir) / sha / size
文件类型的 entry 还可能直接带明文 content
"""
data = self.get(f"{owner}/{repo}/sub_entries.json",
{"filepath": path, "ref": ref})
# 查询目录时 entries 为 list查询单文件时 entries 为单个 dict。
# 统一归一化为 list便于下游处理。
if isinstance(data, dict):
entries = data.get("entries")
if isinstance(entries, dict):
return [entries]
if isinstance(entries, list):
return entries
return _extract_list(data, ("entries",))
def file_content(self, owner: str, repo: str, filepath: str,
ref: str = "master") -> str | None:
"""读取单个文件的文本内容。
GitLink sub_entries 接口对单文件查询会在 entries 中返回明文 content
据此取出文件不存在或无内容时返回 None
"""
entries = self.list_dir(owner, repo, filepath, ref)
target = filepath.rsplit("/", 1)[-1]
for entry in entries:
if entry.get("type") == "file" and entry.get("name") == target:
content = entry.get("content")
if isinstance(content, str):
return content
# 回退:部分情况下单文件查询 entries 仅一项
if len(entries) == 1 and entries[0].get("type") == "file":
content = entries[0].get("content")
if isinstance(content, str):
return content
return None
def readme(self, owner: str, repo: str, ref: str = "master") -> str | None:
"""读取仓库 README自动 base64 解码)。"""
data = self.get(f"{owner}/{repo}/readme.json", {"ref": ref})
if not isinstance(data, dict):
return None
content = data.get("content")
if not isinstance(content, str):
return None
# 注意GitLink 的 readme.json 虽然 encoding 标为 base64
# 实测 content 多为明文 Markdown。先探测明文特征命中则直接返回
# 否则再尝试 base64 解码。
stripped = content.lstrip()
if stripped.startswith(("#", "<", "[", "-", "*", "", "", "")) or "\n" in content[:200]:
return content
try:
raw = base64.b64decode(content.encode("ascii", "ignore"))
decoded = raw.decode("utf-8", errors="replace")
# 解码结果若不像文本(大量替换符),回退为原文
if decoded.count("\ufffd") > len(decoded) * 0.1:
return content
return decoded
except (ValueError, TypeError):
return content
# ----------------------------------------------------------------------------
# 辅助
# ----------------------------------------------------------------------------
def _extract_list(payload: Any, keys: tuple[str, ...]) -> list[Any]:
"""从可能嵌套的响应中提取第一个匹配键的列表。"""
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in keys:
value = payload.get(key)
if isinstance(value, list):
return value
return []
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def split_owner_repo(slug: str) -> tuple[str, str]:
"""'owner/repo' 或完整 URL 解析为 (owner, repo)。"""
s = slug.strip()
if s.startswith("http"):
parts = urllib.parse.urlparse(s).path.strip("/").split("/")
if len(parts) >= 2:
return parts[0], parts[1].replace(".git", "")
raise GitLinkError(f"无法从 URL 解析 owner/repo: {slug}")
if "/" in s:
owner, repo = s.split("/", 1)
return owner, repo.replace(".git", "")
raise GitLinkError(f"格式应为 owner/repo: {slug}")

View File

@ -0,0 +1,424 @@
"""gitlink-scaffold社区健康文件体检与模板生成。
扫描一个 GitLink 仓库检测开源社区推荐的健康文件是否齐全
README / LICENSE / CONTRIBUTING / CODE_OF_CONDUCT / SECURITY /
Issue 模板 / PR 模板 / CHANGELOG 给出健康度评分
并为缺失的文件生成可直接使用的中文模板
数据来自 GitLink 公开 API只读无需登录生成的模板仅输出到本地
是否提交到仓库由用户决定
用法
python scaffold.py --owner Gitlink --repo gitlink-cli
python scaffold.py --owner Gitlink --repo gitlink-cli --format json
python scaffold.py --owner Gitlink --repo gitlink-cli --generate --output-dir out
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
from glapi import GitLinkClient, GitLinkError, split_owner_repo
# Windows 控制台默认 GBK直接打印含 emoji 的 Markdown 会抛 UnicodeEncodeError。
# 重配置 stdout 为 UTF-8确保跨平台正常输出。
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
# ---------------------------------------------------------------------------
# 社区健康文件清单
# ---------------------------------------------------------------------------
# 每项:键、展示名、候选文件名(不区分大小写)、候选所在目录、权重、是否关键
HEALTH_FILES = [
{
"key": "readme", "name": "README", "weight": 20, "critical": True,
"candidates": ["readme.md", "readme.rst", "readme.txt", "readme"],
"dirs": [""],
},
{
"key": "license", "name": "LICENSE", "weight": 20, "critical": True,
"candidates": ["license", "license.md", "license.txt", "copying", "license-mulanpsl2"],
"dirs": [""],
},
{
"key": "contributing", "name": "CONTRIBUTING", "weight": 15, "critical": False,
"candidates": ["contributing.md", "contributing.rst", "contributing"],
"dirs": ["", ".gitlink", ".github", "docs"],
},
{
"key": "code_of_conduct", "name": "CODE_OF_CONDUCT", "weight": 10, "critical": False,
"candidates": ["code_of_conduct.md", "code-of-conduct.md"],
"dirs": ["", ".gitlink", ".github", "docs"],
},
{
"key": "security", "name": "SECURITY", "weight": 10, "critical": False,
"candidates": ["security.md", "security"],
"dirs": ["", ".gitlink", ".github", "docs"],
},
{
"key": "issue_template", "name": "Issue 模板", "weight": 10, "critical": False,
"candidates": ["issue_template.md", "issue_template"],
"dirs": ["", ".gitlink", ".github", ".gitlink/issue_template", ".github/ISSUE_TEMPLATE"],
},
{
"key": "pr_template", "name": "PR 模板", "weight": 10, "critical": False,
"candidates": ["pull_request_template.md", "pull_request_template", "merge_request_template.md"],
"dirs": ["", ".gitlink", ".github"],
},
{
"key": "changelog", "name": "CHANGELOG", "weight": 5, "critical": False,
"candidates": ["changelog.md", "changelog", "changes.md", "history.md"],
"dirs": [""],
},
]
def _names_in_dir(client: GitLinkClient, owner: str, repo: str,
path: str, ref: str, cache: dict[str, set[str]]) -> set[str]:
"""列出某目录下所有条目名(小写),带本次运行内缓存。"""
if path in cache:
return cache[path]
try:
entries = client.list_dir(owner, repo, path, ref)
except GitLinkError:
entries = []
names = {str(e.get("name", "")).lower() for e in entries if e.get("name")}
cache[path] = names
return names
def check_repo(owner: str, repo: str, ref: str = "master",
client: GitLinkClient | None = None) -> dict[str, Any]:
"""检测仓库的社区健康文件齐全度。"""
client = client or GitLinkClient()
dir_cache: dict[str, set[str]] = {}
present: list[dict[str, Any]] = []
missing: list[dict[str, Any]] = []
score = 0
max_score = 0
for spec in HEALTH_FILES:
max_score += spec["weight"]
found_at = None
for d in spec["dirs"]:
names = _names_in_dir(client, owner, repo, d, ref, dir_cache)
hit = next((c for c in spec["candidates"] if c in names), None)
if hit:
found_at = f"{d}/{hit}" if d else hit
break
record = {"key": spec["key"], "name": spec["name"],
"weight": spec["weight"], "critical": spec["critical"]}
if found_at:
record["path"] = found_at
present.append(record)
score += spec["weight"]
else:
missing.append(record)
health = round(score / max_score * 100) if max_score else 0
return {
"owner": owner, "repo": repo,
"health_score": health,
"present": present,
"missing": missing,
"missing_critical": [m for m in missing if m["critical"]],
}
# ---------------------------------------------------------------------------
# 模板生成
# ---------------------------------------------------------------------------
def _tpl_contributing(owner: str, repo: str) -> str:
return f"""# 贡献指南
感谢你考虑为 {owner}/{repo} 做贡献本指南帮助你顺利参与
## 如何贡献
1. Fork 本仓库并克隆你的 Fork
2. 新建分支`git checkout -b feat/your-feature`
3. 进行修改并确保通过现有测试
4. 提交时使用清晰的提交信息推荐 Conventional Commits `feat:``fix:``docs:`
5. 推送到你的 Fork并向本仓库发起 Pull Request
## 提交 PR 前的检查清单
- [ ] 代码可以正常构建/运行
- [ ] 新增或修改的功能有对应测试
- [ ] 文档已同步更新
- [ ] PR 描述清楚说明了变更内容与动机
## 报告问题
通过 Issue 报告 Bug 或提出建议时请尽量包含复现步骤期望行为实际行为运行环境
## 行为准则
参与本项目即表示你同意遵守 [行为准则](CODE_OF_CONDUCT.md)
"""
def _tpl_code_of_conduct(owner: str, repo: str) -> str:
return """# 行为准则
## 我们的承诺
为营造开放友好的社区环境我们承诺无论年龄性别经验水平国籍个人外貌
种族或宗教参与本项目的每个人都能获得免受骚扰的体验
## 行为标准
有助于营造积极环境的行为包括
- 使用友好和包容的语言
- 尊重不同的观点和经验
- 优雅地接受建设性批评
- 关注对社区最有利的事情
不可接受的行为包括
- 使用性化的语言或图像
- 挑衅侮辱或贬损性评论
- 公开或私下骚扰
- 未经许可发布他人的私人信息
## 执行
如遇违反行为准则的情况可通过仓库 Issue 或维护者联系方式报告所有投诉都会被审查与处理
本准则改编自 Contributor Covenant
"""
def _tpl_security(owner: str, repo: str) -> str:
return f"""# 安全策略
## 报告漏洞
我们重视 {owner}/{repo} 的安全如果你发现安全漏洞**不要**直接在公开 Issue 中披露
请通过以下方式私下报告
- GitLink 上私信仓库维护者
- 或发送邮件至维护者邮箱见仓库主页
报告时请包含漏洞描述复现步骤可能的影响范围我们会尽快响应并在修复后致谢
## 支持的版本
| 版本 | 是否支持 |
|------|:--------:|
| 最新发布版 | |
| 历史版本 | 视情况 |
"""
def _tpl_issue_template(owner: str, repo: str) -> str:
return """---
name: Bug 报告 / 功能建议
about: 报告问题或提出新功能
---
## 类型
- [ ] Bug 报告
- [ ] 功能建议
- [ ] 问题咨询
## 描述
<!-- 清晰简洁地描述问题或建议 -->
## 复现步骤Bug
1.
2.
3.
## 期望行为
<!-- 你期望发生什么 -->
## 实际行为
<!-- 实际发生了什么 -->
## 运行环境
- 操作系统
- 版本
"""
def _tpl_pr_template(owner: str, repo: str) -> str:
return """## 变更说明
<!-- 简述本次 PR 做了什么为什么 -->
## 关联 Issue
<!-- Closes #123 -->
## 变更类型
- [ ] Bug 修复
- [ ] 新功能
- [ ] 文档更新
- [ ] 重构 / 性能优化
## 检查清单
- [ ] 代码可正常构建/运行
- [ ] 已添加或更新测试
- [ ] 已更新相关文档
- [ ] 提交信息清晰规范
"""
def _tpl_changelog(owner: str, repo: str) -> str:
return """# 更新日志
本项目所有重要变更都会记录在本文件中
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/)
版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)
## [Unreleased]
### 新增
### 修复
### 变更
"""
TEMPLATE_BUILDERS = {
"contributing": ("CONTRIBUTING.md", _tpl_contributing),
"code_of_conduct": ("CODE_OF_CONDUCT.md", _tpl_code_of_conduct),
"security": ("SECURITY.md", _tpl_security),
"issue_template": (".gitlink/issue_template.md", _tpl_issue_template),
"pr_template": (".gitlink/pull_request_template.md", _tpl_pr_template),
"changelog": ("CHANGELOG.md", _tpl_changelog),
}
def generate_templates(missing: list[dict[str, Any]], owner: str, repo: str,
out_dir: Path) -> list[str]:
"""为缺失且有模板的文件生成模板,返回生成的文件路径列表。"""
generated: list[str] = []
for m in missing:
builder = TEMPLATE_BUILDERS.get(m["key"])
if not builder:
continue
rel_path, fn = builder
target = out_dir / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(fn(owner, repo), encoding="utf-8")
generated.append(str(target))
return generated
def render_report(result: dict[str, Any]) -> str:
"""渲染社区健康文件体检报告Markdown"""
owner, repo = result["owner"], result["repo"]
health = result["health_score"]
bar = "" * (health // 10) + "·" * (10 - health // 10)
lines = [
f"# 社区健康文件体检 — {owner}/{repo}",
"",
f"健康度评分:**{health}/100** `{bar}`",
"",
"## 已具备",
"",
]
if result["present"]:
for p in result["present"]:
lines.append(f"- ✅ {p['name']}`{p.get('path')}`")
else:
lines.append("- (无)")
lines += ["", "## 缺失", ""]
if result["missing"]:
for m in result["missing"]:
mark = "" if m["critical"] else ""
tip = "(关键)" if m["critical"] else ""
has_tpl = ",可自动生成模板" if m["key"] in TEMPLATE_BUILDERS else ""
lines.append(f"- {mark} {m['name']}{tip}{has_tpl}")
else:
lines.append("- 🎉 全部齐全!")
lines += ["", "## 建议", ""]
if result["missing_critical"]:
names = "".join(m["name"] for m in result["missing_critical"])
lines.append(f"1. 优先补齐关键文件:**{names}**。")
gen_able = [m["name"] for m in result["missing"] if m["key"] in TEMPLATE_BUILDERS]
if gen_able:
lines.append(f"2. 以下文件可用本工具一键生成模板:{', '.join(gen_able)}")
lines.append(" 运行:`python scaffold.py --owner %s --repo %s --generate`" % (owner, repo))
if not result["missing"]:
lines.append("社区健康文件已齐全,继续保持。")
lines.append("")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="gitlink-scaffold",
description="社区健康文件体检与模板生成")
p.add_argument("--owner", help="仓库所有者")
p.add_argument("--repo", help="仓库名称")
p.add_argument("--slug", help="owner/repo 或完整 URL")
p.add_argument("--ref", default="master", help="分支或标签,默认 master")
p.add_argument("--generate", action="store_true", help="为缺失文件生成模板")
p.add_argument("--output-dir", type=Path, default=Path("scaffold_out"),
help="模板输出目录(配合 --generate")
p.add_argument("--format", choices=["markdown", "json"], default="markdown")
p.add_argument("--output", type=Path, help="报告输出文件")
args = p.parse_args(argv)
if args.slug:
owner, repo = split_owner_repo(args.slug)
elif args.owner and args.repo:
owner, repo = args.owner, args.repo
else:
print("错误:请用 --owner/--repo 或 --slug 指定仓库。", file=sys.stderr)
return 2
try:
result = check_repo(owner, repo, ref=args.ref)
except GitLinkError as exc:
print(f"采集失败:{exc}", file=sys.stderr)
return 1
generated: list[str] = []
if args.generate:
generated = generate_templates(result["missing"], owner, repo, args.output_dir)
if args.format == "json":
result["generated"] = generated
out = json.dumps(result, ensure_ascii=False, indent=2)
else:
out = render_report(result)
if generated:
out += "\n## 已生成模板\n\n" + "\n".join(f"- `{g}`" for g in generated) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(out, encoding="utf-8")
print(f"已写入 {args.output}")
else:
print(out)
if generated:
print(f"\n已生成 {len(generated)} 个模板到 {args.output_dir}/", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,105 @@
"""gitlink-scaffold 单元测试。"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import pytest
from scaffold import (
check_repo, generate_templates, render_report,
HEALTH_FILES, TEMPLATE_BUILDERS,
)
class FakeClient:
"""用预设目录内容模拟 GitLinkClient.list_dir。"""
def __init__(self, files_by_dir):
self.files_by_dir = files_by_dir
def list_dir(self, owner, repo, path, ref):
names = self.files_by_dir.get(path, [])
return [{"name": n, "type": "file"} for n in names]
class TestCheckRepo:
def test_all_missing(self):
client = FakeClient({"": []})
r = check_repo("o", "r", client=client)
assert r["health_score"] == 0
assert len(r["missing"]) == len(HEALTH_FILES)
def test_readme_license_present(self):
client = FakeClient({"": ["readme.md", "license"]})
r = check_repo("o", "r", client=client)
# README(20) + LICENSE(20) = 40
assert r["health_score"] == 40
present_keys = {p["key"] for p in r["present"]}
assert "readme" in present_keys
assert "license" in present_keys
def test_contributing_in_subdir(self):
client = FakeClient({"": [], ".gitlink": ["contributing.md"]})
r = check_repo("o", "r", client=client)
assert any(p["key"] == "contributing" for p in r["present"])
def test_missing_critical_flagged(self):
client = FakeClient({"": ["contributing.md"]})
r = check_repo("o", "r", client=client)
crit_keys = {m["key"] for m in r["missing_critical"]}
assert "readme" in crit_keys
assert "license" in crit_keys
def test_full_score(self):
allfiles = ["readme.md", "license", "contributing.md", "code_of_conduct.md",
"security.md", "issue_template.md", "pull_request_template.md",
"changelog.md"]
client = FakeClient({"": allfiles})
r = check_repo("o", "r", client=client)
assert r["health_score"] == 100
assert r["missing"] == []
class TestGenerate:
def test_generates_templates(self, tmp_path):
missing = [{"key": "contributing", "name": "CONTRIBUTING", "critical": False},
{"key": "security", "name": "SECURITY", "critical": False}]
gen = generate_templates(missing, "o", "r", tmp_path)
assert len(gen) == 2
assert (tmp_path / "CONTRIBUTING.md").exists()
assert (tmp_path / "SECURITY.md").exists()
def test_skips_no_template(self, tmp_path):
# readme/license 无模板生成器
missing = [{"key": "readme", "name": "README", "critical": True}]
gen = generate_templates(missing, "o", "r", tmp_path)
assert gen == []
def test_template_content_has_repo(self, tmp_path):
missing = [{"key": "contributing", "name": "CONTRIBUTING", "critical": False}]
generate_templates(missing, "MyOrg", "MyRepo", tmp_path)
text = (tmp_path / "CONTRIBUTING.md").read_text(encoding="utf-8")
assert "MyOrg/MyRepo" in text
class TestReport:
def test_report_renders(self):
client = FakeClient({"": ["readme.md"]})
r = check_repo("o", "r", client=client)
report = render_report(r)
assert "社区健康文件体检" in report
assert "健康度评分" in report
def test_all_present_message(self):
allfiles = ["readme.md", "license", "contributing.md", "code_of_conduct.md",
"security.md", "issue_template.md", "pull_request_template.md", "changelog.md"]
r = check_repo("o", "r", client=FakeClient({"": allfiles}))
assert "全部齐全" in render_report(r)
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))