feat(skills): 补充 gitlink-scaffold 配套脚本与测试
依审阅意见,将 SKILL.md 方式A引用的脚本(scripts/)与单元测试(tests/)一并纳入,使 PR 自包含可运行。
This commit is contained in:
parent
178255b70c
commit
af7a8a2fa9
|
|
@ -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}")
|
||||
|
|
@ -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())
|
||||
|
|
@ -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"]))
|
||||
Loading…
Reference in New Issue