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

将 SKILL.md 方式A引用的脚本(scripts/)与单元测试(tests/)纳入 PR,使其自包含可运行。
This commit is contained in:
Ct201314 2026-06-12 18:01:59 +08:00
parent bfa558cb15
commit 86d93655b3
3 changed files with 539 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,221 @@
"""gitlink-onboard新贡献者上手指南生成器。
为想参与一个 GitLink 项目的新人一站式生成完整的上手指南整合
- 项目简介与社区指标
- 技术栈识别从依赖文件推断
- 核心目录/文件导航帮新人定位代码入口
- 社区文件检查README/CONTRIBUTING/行为准则是否齐全
- 适合上手的 good-first-issue
- 核心贡献者遇到问题找谁
- 标准上手步骤Fork PR
相比 gitlink-newcomer聚焦"找新手 Issue"本技能输出的是一份覆盖"项目是什么、
代码在哪找谁问从哪个 Issue 开始怎么提 PR"的完整上手指南,功能更全面。
数据来自 GitLink 公开 API只读无需登录
用法
python onboard.py --owner Gitlink --repo gitlink-cli
python onboard.py --owner Gitlink --repo gitlink-cli --format json
"""
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
if hasattr(sys.stdout, "reconfigure"):
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
# 依赖文件 → 技术栈
STACK_MARKERS = {
"go.mod": "Go", "package.json": "Node.js / JavaScript", "requirements.txt": "Python",
"pyproject.toml": "Python", "Cargo.toml": "Rust", "pom.xml": "Java (Maven)",
"build.gradle": "Java (Gradle)", "composer.json": "PHP", "Gemfile": "Ruby",
"Dockerfile": "Docker", "Makefile": "Make",
}
# 核心目录提示
CORE_DIR_HINTS = {
"src": "源码主目录", "lib": "库代码", "cmd": "命令行入口", "internal": "内部包",
"pkg": "公共包", "app": "应用代码", "core": "核心模块", "docs": "文档",
"test": "测试", "tests": "测试", "examples": "示例", "skills": "Agent Skills",
}
GOOD_FIRST_HINTS = ["typo", "docs", "doc", "readme", "test", "translation", "example",
"文档", "注释", "翻译", "示例", "拼写"]
HEALTH_FILES = {"README": ["readme.md", "readme.rst", "readme"],
"CONTRIBUTING": ["contributing.md", "contributing"],
"行为准则": ["code_of_conduct.md"]}
def detect_stack(root_files: list[str]) -> list[str]:
names = {f.lower() for f in root_files}
stacks = []
for marker, lang in STACK_MARKERS.items():
if marker.lower() in names:
stacks.append(lang)
return sorted(set(stacks))
def navigate_dirs(entries: list[dict[str, Any]]) -> list[dict[str, str]]:
nav = []
for e in entries:
if e.get("type") == "dir":
name = str(e.get("name", ""))
hint = CORE_DIR_HINTS.get(name.lower(), "")
nav.append({"name": name, "hint": hint})
# 有提示的排前面
nav.sort(key=lambda x: (x["hint"] == "", x["name"]))
return nav
def pick_good_first(issues: list[dict[str, Any]]) -> list[dict[str, str]]:
out = []
for it in issues:
status = str(it.get("issue_status") or "")
if "" in status:
continue
title = str(it.get("name") or it.get("subject") or "")
body = str(it.get("description") or "")
if any(h in (title + body).lower() for h in GOOD_FIRST_HINTS) and len(body) < 800:
out.append({"id": str(it.get("id") or ""), "title": title[:60]})
return out[:8]
def check_health(root_files: list[str]) -> dict[str, bool]:
names = {f.lower() for f in root_files}
return {label: any(c in names for c in cands) for label, cands in HEALTH_FILES.items()}
def build_guide(owner: str, repo: str, info: dict[str, Any], root_files: list[str],
entries: list[dict[str, Any]], issues: list[dict[str, Any]],
contributors: list[dict[str, Any]]) -> dict[str, Any]:
stacks = detect_stack(root_files)
nav = navigate_dirs(entries)
good_first = pick_good_first(issues)
health = check_health(root_files)
core_contribs = sorted(
({"name": c.get("login") or c.get("name"), "contributions": int(c.get("contributions") or 0)}
for c in contributors), key=lambda x: x["contributions"], reverse=True)[:3]
return {
"owner": owner, "repo": repo,
"description": info.get("description") or "",
"default_branch": info.get("default_branch") or "master",
"stars": info.get("praises_count") or 0,
"forks": info.get("forked_count") or 0,
"stacks": stacks,
"navigation": nav,
"good_first": good_first,
"health": health,
"core_contributors": core_contribs,
}
def render_guide(g: dict[str, Any]) -> str:
owner, repo = g["owner"], g["repo"]
lines = [
f"# 新贡献者上手指南 — {owner}/{repo}",
"",
"欢迎参与本项目!这份指南由 gitlink-onboard 自动生成,带你快速上手。",
"",
"## 一、项目是什么",
"",
f"- 简介:{g['description'] or '(仓库未提供简介)'}",
f"- 技术栈:{(''.join(g['stacks'])) or '未自动识别'}",
f"- 社区:⭐ {g['stars']} / Fork {g['forks']} 默认分支 `{g['default_branch']}`",
"",
"## 二、代码在哪(核心目录导航)",
"",
]
if g["navigation"]:
for n in g["navigation"][:12]:
hint = f"{n['hint']}" if n["hint"] else ""
lines.append(f"- `{n['name']}/`{hint}")
else:
lines.append("- (未获取到目录结构)")
lines += ["", "## 三、社区文件是否齐全", ""]
for label, ok in g["health"].items():
lines.append(f"- {'' if ok else ''} {label}{'' if ok else '(建议先补充,新人可贡献)'}")
lines += ["", "## 四、从哪个 Issue 开始", ""]
if g["good_first"]:
lines.append("以下是适合新人上手的 Issue")
lines.append("")
for it in g["good_first"]:
lines.append(f"- #{it['id']} {it['title']}")
else:
lines.append("- 暂未发现明显的新手友好 Issue可在 Issue 区留言询问维护者。")
lines += ["", "## 五、遇到问题找谁", ""]
if g["core_contributors"]:
for c in g["core_contributors"]:
lines.append(f"- @{c['name']}(核心贡献者,{c['contributions']} 次贡献)")
else:
lines.append("- 在 Issue 区留言,社区会帮你。")
lines += [
"", "## 六、上手步骤", "",
f"1. 阅读 README" + ("、CONTRIBUTING" if g["health"].get("CONTRIBUTING") else "") + " 了解规范。",
f"2. Fork 本仓库:`gitlink-cli repo +fork --owner {owner} --repo {repo}`",
"3. 克隆你的 Fork新建分支进行修改。",
f"4. 从你的 Fork 向 `{owner}/{repo}` 的 `{g['default_branch']}` 分支提交 PR。",
"5. 在 PR 描述里关联你解决的 Issue等待 Review。",
"",
"---", "", "由 gitlink-onboard 生成。祝你贡献顺利!🚀",
]
return "\n".join(lines)
def analyze(owner: str, repo: str, client: GitLinkClient | None = None) -> dict[str, Any]:
client = client or GitLinkClient()
info = client.repo_info(owner, repo)
branch = info.get("default_branch") or "master"
try:
entries = client.list_dir(owner, repo, "", branch)
except GitLinkError:
entries = []
root_files = [str(e.get("name", "")) for e in entries]
issues = client.issues(owner, repo, limit=50)
contributors = client.contributors(owner, repo)
return build_guide(owner, repo, info, root_files, entries, issues, contributors)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="gitlink-onboard", description="新贡献者上手指南生成器")
p.add_argument("--owner"); p.add_argument("--repo"); p.add_argument("--slug")
p.add_argument("--format", choices=["markdown", "json"], default="markdown")
p.add_argument("--output", type=Path)
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:
g = analyze(owner, repo)
except GitLinkError as exc:
print(f"采集失败:{exc}", file=sys.stderr)
return 1
out = (json.dumps(g, ensure_ascii=False, indent=2) if args.format == "json"
else render_guide(g))
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)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,77 @@
"""gitlink-onboard 单元测试。"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import pytest
from onboard import detect_stack, navigate_dirs, pick_good_first, check_health, build_guide, render_guide
class TestDetectStack:
def test_go(self):
assert "Go" in detect_stack(["go.mod", "main.go"])
def test_multi(self):
s = detect_stack(["package.json", "Dockerfile"])
assert "Node.js / JavaScript" in s and "Docker" in s
def test_none(self):
assert detect_stack(["README.md"]) == []
class TestNavigate:
def test_dirs_with_hints(self):
entries = [{"name": "cmd", "type": "dir"}, {"name": "weird", "type": "dir"},
{"name": "file.go", "type": "file"}]
nav = navigate_dirs(entries)
names = [n["name"] for n in nav]
assert "cmd" in names and "weird" in names
assert "file.go" not in names # 文件不计入
# 有提示的排前
assert nav[0]["hint"] != ""
class TestGoodFirst:
def test_picks_docs(self):
issues = [{"id": "1", "name": "fix typo in docs", "description": "small", "issue_status": "open"}]
r = pick_good_first(issues)
assert len(r) == 1
def test_skips_closed(self):
issues = [{"id": "1", "name": "fix typo", "description": "x", "issue_status": "关闭"}]
assert pick_good_first(issues) == []
class TestHealth:
def test_detects(self):
h = check_health(["readme.md", "contributing.md"])
assert h["README"] is True
assert h["CONTRIBUTING"] is True
assert h["行为准则"] is False
class TestBuildAndRender:
def _data(self):
info = {"description": "测试项目", "default_branch": "master", "praises_count": 5, "forked_count": 2}
entries = [{"name": "cmd", "type": "dir"}, {"name": "src", "type": "dir"}]
root_files = ["go.mod", "readme.md"]
issues = [{"id": "1", "name": "docs typo", "description": "x", "issue_status": "open"}]
contributors = [{"login": "alice", "contributions": 100}]
return build_guide("o", "r", info, root_files, entries, issues, contributors)
def test_build(self):
g = self._data()
assert "Go" in g["stacks"]
assert g["core_contributors"][0]["name"] == "alice"
assert len(g["good_first"]) == 1
def test_render(self):
md = render_guide(self._data())
assert "新贡献者上手指南" in md
assert "技术栈" in md
assert "上手步骤" in md
assert "@alice" in md
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))