From af7a8a2fa9cca11c749e2dc57a3f497cd5001f48 Mon Sep 17 00:00:00 2001 From: Ct201314 <1195214305@qq.com> Date: Fri, 12 Jun 2026 18:00:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(skills):=20=E8=A1=A5=E5=85=85=20gitlink-sc?= =?UTF-8?q?affold=20=E9=85=8D=E5=A5=97=E8=84=9A=E6=9C=AC=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依审阅意见,将 SKILL.md 方式A引用的脚本(scripts/)与单元测试(tests/)一并纳入,使 PR 自包含可运行。 --- skills/gitlink-scaffold/scripts/glapi.py | 241 ++++++++++ skills/gitlink-scaffold/scripts/scaffold.py | 424 ++++++++++++++++++ .../gitlink-scaffold/tests/test_scaffold.py | 105 +++++ 3 files changed, 770 insertions(+) create mode 100644 skills/gitlink-scaffold/scripts/glapi.py create mode 100644 skills/gitlink-scaffold/scripts/scaffold.py create mode 100644 skills/gitlink-scaffold/tests/test_scaffold.py diff --git a/skills/gitlink-scaffold/scripts/glapi.py b/skills/gitlink-scaffold/scripts/glapi.py new file mode 100644 index 0000000..41db09d --- /dev/null +++ b/skills/gitlink-scaffold/scripts/glapi.py @@ -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 请求,返回解析后的 JSON(dict/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}") diff --git a/skills/gitlink-scaffold/scripts/scaffold.py b/skills/gitlink-scaffold/scripts/scaffold.py new file mode 100644 index 0000000..b57e1e8 --- /dev/null +++ b/skills/gitlink-scaffold/scripts/scaffold.py @@ -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 """## 变更说明 + + + +## 关联 Issue + + + +## 变更类型 + +- [ ] 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()) diff --git a/skills/gitlink-scaffold/tests/test_scaffold.py b/skills/gitlink-scaffold/tests/test_scaffold.py new file mode 100644 index 0000000..32c9826 --- /dev/null +++ b/skills/gitlink-scaffold/tests/test_scaffold.py @@ -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"]))