diff --git a/skills/gitlink-onboard/SKILL.md b/skills/gitlink-onboard/SKILL.md new file mode 100644 index 0000000..c9feee9 --- /dev/null +++ b/skills/gitlink-onboard/SKILL.md @@ -0,0 +1,81 @@ +--- +name: gitlink-onboard +version: 1.0.0 +description: "新贡献者上手指南:一站式生成项目简介、技术栈、核心目录导航、社区文件检查、good-first-issue、核心贡献者联系与上手步骤,帮助新人快速参与项目。当用户提到「上手指南」「怎么参与这个项目」「新人指南」「onboarding」「接手项目」「从哪开始贡献」时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + optional_bins: ["python"] + cliHelp: "gitlink-cli repo --help" +--- + +# gitlink-onboard(新贡献者上手指南生成器) + +**CRITICAL — 开始前先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** +**CRITICAL — 本技能全程只读,不修改任何远程数据。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。 + +## 何时使用本技能 + +- 新人想参与一个项目,需要一份完整的上手指南 +- 用户问「这个项目怎么参与 / 从哪开始 / 代码在哪 / 找谁问」 +- 接手一个陌生仓库前先了解全貌 + +## 与 gitlink-newcomer 的区别 + +`gitlink-newcomer` 聚焦「识别 good-first-issue 并生成引导评论」(面向维护者整理任务); +本技能输出的是**面向新人的完整上手指南**:项目是什么、技术栈、代码在哪、社区文件、 +从哪个 Issue 开始、找谁问、怎么提 PR——一站式覆盖。 + +## 能力概览 + +| 能力 | 说明 | +|------|------| +| 项目概览 | 简介、社区指标、默认分支 | +| 技术栈识别 | 从依赖文件推断(go.mod/package.json…) | +| 核心目录导航 | 列出目录并标注语义(cmd/src/internal…) | +| 社区文件检查 | README/CONTRIBUTING/行为准则是否齐全 | +| good-first-issue | 适合新手上手的 Issue | +| 核心贡献者 | 遇到问题找谁 | +| 上手步骤 | 标准 Fork → 改 → PR 流程 | + +## 工作流:生成上手指南 + +### 方式 A:配套脚本(推荐) + +```bash +python scripts/onboard.py --owner Gitlink --repo gitlink-cli +python scripts/onboard.py --owner Gitlink --repo gitlink-cli --format json +``` + +参数说明: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|:----:|------| +| `--owner` / `--repo` | string | 是* | 仓库(*或用 `--slug`) | +| `--slug` | string | 否 | `owner/repo` 或完整 URL | +| `--format` | string | 否 | `markdown`(默认)或 `json` | +| `--output` | string | 否 | 输出文件 | + +### 方式 B:用 gitlink-cli 命令 + +```bash +gitlink-cli repo +info --owner Gitlink --repo gitlink-cli --format json +gitlink-cli api GET /:owner/:repo/sub_entries --query 'filepath=&ref=master' --format json +gitlink-cli issue +list --owner Gitlink --repo gitlink-cli --state open --format json +gitlink-cli api GET /:owner/:repo/contributors --format json +``` + +## API 注意事项 + +- 技术栈依据根目录依赖文件推断;核心目录导航依据 `sub_entries` 返回的目录。 +- good-first-issue 用关键词启发式识别(typo/docs/test/翻译 等)。 +- 数据采集全程只读。 + +## References + +- [api-reference.md](references/api-reference.md) — 采集接口与字段 +- [guide-structure.md](references/guide-structure.md) — 上手指南结构与识别规则 +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证、全局参数、安全规则 diff --git a/skills/gitlink-onboard/references/api-reference.md b/skills/gitlink-onboard/references/api-reference.md new file mode 100644 index 0000000..e039003 --- /dev/null +++ b/skills/gitlink-onboard/references/api-reference.md @@ -0,0 +1,38 @@ +# gitlink-onboard API 参考 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md)。 + +本技能全程只读。 + +## 采集的接口 + +| 接口 | 用途 | +|------|------| +| `GET /:owner/:repo.json` | 项目简介、默认分支、Star/Fork | +| `GET /:owner/:repo/sub_entries.json?filepath=&ref=` | 根目录条目(技术栈识别 + 目录导航) | +| `GET /:owner/:repo/issues.json` | 识别 good-first-issue | +| `GET /:owner/:repo/contributors.json` | 核心贡献者 | + +## 使用的字段 + +- 仓库:`description` / `default_branch` / `praises_count` / `forked_count` +- 目录条目:`name` / `type`(file|dir) +- Issue:`name`/`subject` / `description` / `issue_status` +- 贡献者:`login`/`name` / `contributions` + +## 输出字段(JSON) + +```json +{ + "owner","repo","description","default_branch","stars","forks", + "stacks": ["Go","Make"], + "navigation": [{"name":"cmd","hint":"命令行入口"}], + "good_first": [{"id","title"}], + "health": {"README":true,"CONTRIBUTING":false,"行为准则":false}, + "core_contributors": [{"name","contributions"}] +} +``` + +## 错误处理 + +目录采集失败时降级为空导航,不中断其他部分。沿用 gitlink-shared 错误码。 diff --git a/skills/gitlink-onboard/references/guide-structure.md b/skills/gitlink-onboard/references/guide-structure.md new file mode 100644 index 0000000..d375c19 --- /dev/null +++ b/skills/gitlink-onboard/references/guide-structure.md @@ -0,0 +1,46 @@ +# 上手指南结构与识别规则 + +## 指南六段结构 + +1. **项目是什么**:简介 + 技术栈 + 社区指标 +2. **代码在哪**:核心目录导航 +3. **社区文件是否齐全**:README/CONTRIBUTING/行为准则 +4. **从哪个 Issue 开始**:good-first-issue +5. **遇到问题找谁**:核心贡献者 +6. **上手步骤**:Fork → 改 → PR + +## 技术栈识别 + +依据根目录依赖/配置文件推断: + +| 文件 | 技术栈 | +|------|--------| +| go.mod | Go | +| package.json | Node.js / JavaScript | +| requirements.txt / pyproject.toml | Python | +| Cargo.toml | Rust | +| pom.xml / build.gradle | Java | +| composer.json | PHP | +| Gemfile | Ruby | +| Dockerfile | Docker | +| Makefile | Make | + +## 核心目录语义提示 + +| 目录 | 含义 | +|------|------| +| src / lib | 源码 / 库 | +| cmd | 命令行入口 | +| internal / pkg | 内部包 / 公共包 | +| app / core | 应用 / 核心模块 | +| docs | 文档 | +| test(s) | 测试 | +| examples | 示例 | +| skills | Agent Skills | + +带语义提示的目录优先展示,帮助新人快速定位代码入口。 + +## good-first-issue 识别 + +开放 Issue 中,标题/正文含 typo/docs/readme/test/翻译/示例 等关键词且描述较短(<800 字) +的,判定为新手友好,最多取 8 个。 diff --git a/skills/gitlink-onboard/scripts/glapi.py b/skills/gitlink-onboard/scripts/glapi.py new file mode 100644 index 0000000..41db09d --- /dev/null +++ b/skills/gitlink-onboard/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-onboard/scripts/onboard.py b/skills/gitlink-onboard/scripts/onboard.py new file mode 100644 index 0000000..e5930a0 --- /dev/null +++ b/skills/gitlink-onboard/scripts/onboard.py @@ -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()) diff --git a/skills/gitlink-onboard/tests/test_onboard.py b/skills/gitlink-onboard/tests/test_onboard.py new file mode 100644 index 0000000..8e9386a --- /dev/null +++ b/skills/gitlink-onboard/tests/test_onboard.py @@ -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"]))