Compare commits
2 Commits
master
...
skill/gitl
| Author | SHA1 | Date |
|---|---|---|
|
|
ee3c6426d5 | |
|
|
cef1f282e1 |
|
|
@ -0,0 +1,105 @@
|
||||||
|
---
|
||||||
|
name: gitlink-contributor
|
||||||
|
version: 1.0.0
|
||||||
|
description: "贡献者致谢与成长:基于仓库贡献者数据生成 all-contributors 风格致谢墙、贡献排行榜、徽章体系与成长档案,让每份贡献被看见,激励社区参与。当用户提到「贡献者」「致谢」「contributors」「排行榜」「贡献榜」「徽章」「贡献者成长」「贡献墙」时触发。"
|
||||||
|
metadata:
|
||||||
|
requires:
|
||||||
|
bins: ["gitlink-cli"]
|
||||||
|
optional_bins: ["python"]
|
||||||
|
cliHelp: "gitlink-cli repo --help"
|
||||||
|
---
|
||||||
|
|
||||||
|
# gitlink-contributor(贡献者致谢与成长)
|
||||||
|
|
||||||
|
**CRITICAL — 开始前先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||||
|
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||||
|
**CRITICAL — 把致谢墙写入仓库 README 属于写操作,执行前必须征得用户确认。本技能默认只生成内容。**
|
||||||
|
|
||||||
|
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。
|
||||||
|
|
||||||
|
## 何时使用本技能
|
||||||
|
|
||||||
|
- 维护者想在 README/Wiki 里展示贡献者致谢墙
|
||||||
|
- 用户问「这个项目有哪些贡献者 / 谁贡献最多」
|
||||||
|
- 想建立贡献者激励机制(徽章、排行、成长档案)
|
||||||
|
- 社区运营需要定期表彰贡献者
|
||||||
|
|
||||||
|
## 何时不使用
|
||||||
|
|
||||||
|
- 协作网络/活跃度等深度分析 → 用 `gitlink-insight`
|
||||||
|
- 仅查看贡献者原始列表 → 用 `gitlink-repo`
|
||||||
|
|
||||||
|
## 能力概览
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 致谢墙 | all-contributors 风格的 Markdown 表格,含头像链接与贡献次数 |
|
||||||
|
| 排行榜 | 按贡献量排序,前三名金银铜牌,含占比 |
|
||||||
|
| 徽章体系 | 按贡献次数分配 5 级徽章(贡献者→核心维护者) |
|
||||||
|
| 成长档案 | 分层分布统计 + 临近晋级提示,用于激励参与 |
|
||||||
|
|
||||||
|
## 工作流:生成贡献者致谢与成长内容
|
||||||
|
|
||||||
|
### 方式 A:用配套脚本(推荐)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成完整内容(致谢墙 + 排行榜 + 成长档案)
|
||||||
|
python scripts/contributor.py --owner Gitlink --repo gitlink-cli
|
||||||
|
|
||||||
|
# 只要致谢墙(可贴进 README)
|
||||||
|
python scripts/contributor.py --owner Gitlink --repo gitlink-cli --section wall
|
||||||
|
|
||||||
|
# 只要排行榜 / 成长档案
|
||||||
|
python scripts/contributor.py --owner Gitlink --repo gitlink-cli --section ranking
|
||||||
|
python scripts/contributor.py --owner Gitlink --repo gitlink-cli --section growth
|
||||||
|
|
||||||
|
# JSON 输出
|
||||||
|
python scripts/contributor.py --owner Gitlink --repo gitlink-cli --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
参数说明:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|:----:|------|
|
||||||
|
| `--owner` | string | 是* | 仓库所有者(*或用 `--slug`) |
|
||||||
|
| `--repo` | string | 是* | 仓库名称 |
|
||||||
|
| `--slug` | string | 否 | `owner/repo` 或完整 URL |
|
||||||
|
| `--section` | string | 否 | `all`(默认)/ `wall` / `ranking` / `growth` |
|
||||||
|
| `--format` | string | 否 | `markdown`(默认)或 `json` |
|
||||||
|
| `--output` | string | 否 | 输出文件 |
|
||||||
|
|
||||||
|
### 方式 B:用 gitlink-cli 获取贡献者数据
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 获取贡献者列表
|
||||||
|
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||||
|
|
||||||
|
# 查看仓库信息(含贡献者数)
|
||||||
|
gitlink-cli repo +info --owner Gitlink --repo gitlink-cli --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 徽章规则
|
||||||
|
|
||||||
|
| 徽章 | 称号 | 门槛 |
|
||||||
|
|:----:|------|:----:|
|
||||||
|
| 🏆 | 核心维护者 | ≥ 500 |
|
||||||
|
| 🥇 | 资深贡献者 | ≥ 100 |
|
||||||
|
| 🥈 | 活跃贡献者 | ≥ 30 |
|
||||||
|
| 🥉 | 常规贡献者 | ≥ 10 |
|
||||||
|
| 🌱 | 贡献者 | ≥ 1 |
|
||||||
|
|
||||||
|
## API 注意事项
|
||||||
|
|
||||||
|
- 贡献数据来自 `contributors` 接口,按 commit 贡献次数统计;同一人若用多个邮箱提交,平台可能拆分为多条记录。
|
||||||
|
- 把致谢墙写入仓库 README 是写操作,需用户确认后再执行。
|
||||||
|
- 数据采集全程只读。
|
||||||
|
|
||||||
|
## 输出示例
|
||||||
|
|
||||||
|
参见 [`examples/`](examples/) 的真实致谢墙、排行榜与成长档案。
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [api-reference.md](references/api-reference.md) — 采集接口、字段与输出结构
|
||||||
|
- [badges.md](references/badges.md) — 徽章体系、致谢墙与成长档案规则
|
||||||
|
- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证、全局参数、安全规则
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# gitlink-contributor API 参考
|
||||||
|
|
||||||
|
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md)。
|
||||||
|
|
||||||
|
本技能生成致谢与成长内容所依赖的接口与字段。全程只读。
|
||||||
|
|
||||||
|
## 采集的接口
|
||||||
|
|
||||||
|
### 贡献者列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /:owner/:repo/contributors.json
|
||||||
|
# 或经 gitlink-cli:
|
||||||
|
gitlink-cli api GET /:owner/:repo/contributors --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
返回 `list[]`,本技能使用的字段:
|
||||||
|
|
||||||
|
| 字段 | 说明 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| `login` | 登录名 | 主页链接、展示 |
|
||||||
|
| `name` | 显示名 | 展示 |
|
||||||
|
| `contributions` | 贡献次数(commit 数) | 排序、徽章、占比 |
|
||||||
|
| `contribution_perc` | 贡献占比(平台计算) | 排行榜占比 |
|
||||||
|
| `image_url` | 头像 | 致谢墙(可选) |
|
||||||
|
|
||||||
|
### 仓库信息(可选)
|
||||||
|
|
||||||
|
```
|
||||||
|
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
|
||||||
|
```
|
||||||
|
|
||||||
|
`contributor_users_count` 可用于核对贡献者总数。
|
||||||
|
|
||||||
|
## 输出字段(JSON)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"owner": "...", "repo": "...",
|
||||||
|
"total_contributors": 20,
|
||||||
|
"total_contributions": 378,
|
||||||
|
"profiles": [
|
||||||
|
{"login": "...", "name": "...", "contributions": 112,
|
||||||
|
"badge": "资深贡献者", "emoji": "🥇", "percentage": "28.28%"}
|
||||||
|
],
|
||||||
|
"tiers": {"资深贡献者": 1, "活跃贡献者": 2}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 写操作(写入 README)
|
||||||
|
|
||||||
|
把致谢墙写入仓库 README 属于写操作,需读取现有 README、合并后通过文件更新接口提交(见 gitlink-shared 的文件操作说明),且必须征得用户确认。本技能默认只生成内容到本地。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
沿用 gitlink-shared 错误码。无贡献者数据时输出空致谢墙提示,不报错。
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# 徽章体系与展示规则
|
||||||
|
|
||||||
|
## 徽章规则
|
||||||
|
|
||||||
|
按贡献次数(commit 数)从高到低匹配,命中第一个满足的档位:
|
||||||
|
|
||||||
|
| 徽章 | 称号 | 门槛(贡献次数) |
|
||||||
|
|:----:|------|:----------------:|
|
||||||
|
| 🏆 | 核心维护者 | ≥ 500 |
|
||||||
|
| 🥇 | 资深贡献者 | ≥ 100 |
|
||||||
|
| 🥈 | 活跃贡献者 | ≥ 30 |
|
||||||
|
| 🥉 | 常规贡献者 | ≥ 10 |
|
||||||
|
| 🌱 | 贡献者 | ≥ 1 |
|
||||||
|
|
||||||
|
阈值集中在 `scripts/contributor.py` 的 `BADGES` 常量,可按项目规模调整。
|
||||||
|
|
||||||
|
## 致谢墙
|
||||||
|
|
||||||
|
- 采用 all-contributors 风格的多列表格(默认 6 列)。
|
||||||
|
- 每个单元格含:徽章 emoji + 贡献者名(链接到 GitLink 主页)+ 贡献次数。
|
||||||
|
- 链接形如 `https://www.gitlink.org.cn/{login}`。
|
||||||
|
|
||||||
|
## 排行榜
|
||||||
|
|
||||||
|
- 按贡献次数降序,前三名标金/银/铜牌。
|
||||||
|
- 占比优先用接口返回的 `contribution_perc`,缺失时按总贡献现算。
|
||||||
|
|
||||||
|
## 成长档案
|
||||||
|
|
||||||
|
- 统计各徽章档位的人数分布。
|
||||||
|
- "临近晋级"提示:找出距离下一档位 ≤ 5 次贡献的贡献者,作为激励运营的抓手。
|
||||||
|
|
||||||
|
## 数据说明
|
||||||
|
|
||||||
|
- 贡献数据来自 `contributors` 接口的 `contributions` 字段(按 commit 计)。
|
||||||
|
- 同一人若用多个邮箱提交,平台可能拆分为多条记录;本工具按接口返回如实展示,不做合并(避免错误归并不同人)。
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
"""gitlink-contributor:贡献者致谢与成长。
|
||||||
|
|
||||||
|
基于一个 GitLink 仓库的贡献者与提交数据,生成:
|
||||||
|
- 贡献者致谢墙(all-contributors 风格的 Markdown)
|
||||||
|
- 贡献排行榜
|
||||||
|
- 贡献者成长档案(分层与徽章)
|
||||||
|
|
||||||
|
让开源项目的每一份贡献都被看见,激励社区参与。
|
||||||
|
|
||||||
|
数据来自 GitLink 公开 API(只读),无需登录。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python contributor.py --owner Gitlink --repo gitlink-cli
|
||||||
|
python contributor.py --owner Gitlink --repo gitlink-cli --section wall
|
||||||
|
python contributor.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
|
||||||
|
|
||||||
|
# Windows 控制台默认 GBK,直接打印含 emoji 的 Markdown 会抛 UnicodeEncodeError。
|
||||||
|
# 重配置 stdout 为 UTF-8,确保跨平台正常输出。
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 贡献徽章规则:(阈值, 徽章名, emoji)
|
||||||
|
BADGES = [
|
||||||
|
(500, "核心维护者", "🏆"),
|
||||||
|
(100, "资深贡献者", "🥇"),
|
||||||
|
(30, "活跃贡献者", "🥈"),
|
||||||
|
(10, "常规贡献者", "🥉"),
|
||||||
|
(1, "贡献者", "🌱"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def assign_badge(contributions: int) -> dict[str, str]:
|
||||||
|
"""根据贡献次数分配徽章。"""
|
||||||
|
for threshold, name, emoji in BADGES:
|
||||||
|
if contributions >= threshold:
|
||||||
|
return {"badge": name, "emoji": emoji, "threshold": threshold}
|
||||||
|
return {"badge": "贡献者", "emoji": "🌱", "threshold": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def build_profiles(contributors: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""构建贡献者档案,按贡献量排序并分配徽章。"""
|
||||||
|
profiles: list[dict[str, Any]] = []
|
||||||
|
for c in contributors:
|
||||||
|
name = c.get("login") or c.get("name") or "unknown"
|
||||||
|
contributions = int(c.get("contributions") or 0)
|
||||||
|
badge = assign_badge(contributions)
|
||||||
|
profiles.append({
|
||||||
|
"login": c.get("login"),
|
||||||
|
"name": c.get("name") or name,
|
||||||
|
"contributions": contributions,
|
||||||
|
"badge": badge["badge"],
|
||||||
|
"emoji": badge["emoji"],
|
||||||
|
"image_url": c.get("image_url"),
|
||||||
|
"percentage": c.get("contribution_perc"),
|
||||||
|
})
|
||||||
|
profiles.sort(key=lambda p: p["contributions"], reverse=True)
|
||||||
|
return profiles
|
||||||
|
|
||||||
|
|
||||||
|
def analyze(owner: str, repo: str, client: GitLinkClient | None = None) -> dict[str, Any]:
|
||||||
|
"""汇总贡献者数据。"""
|
||||||
|
client = client or GitLinkClient()
|
||||||
|
contributors = client.contributors(owner, repo)
|
||||||
|
profiles = build_profiles(contributors)
|
||||||
|
total = sum(p["contributions"] for p in profiles)
|
||||||
|
|
||||||
|
# 分层统计
|
||||||
|
tiers: dict[str, int] = {}
|
||||||
|
for p in profiles:
|
||||||
|
tiers[p["badge"]] = tiers.get(p["badge"], 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"owner": owner, "repo": repo,
|
||||||
|
"total_contributors": len(profiles),
|
||||||
|
"total_contributions": total,
|
||||||
|
"profiles": profiles,
|
||||||
|
"tiers": tiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_wall(result: dict[str, Any], cols: int = 6) -> str:
|
||||||
|
"""渲染贡献者致谢墙(all-contributors 风格表格)。"""
|
||||||
|
owner, repo = result["owner"], result["repo"]
|
||||||
|
profiles = result["profiles"]
|
||||||
|
lines = [
|
||||||
|
f"# 贡献者致谢墙 — {owner}/{repo}",
|
||||||
|
"",
|
||||||
|
f"感谢以下 **{result['total_contributors']}** 位贡献者让 {owner}/{repo} 变得更好!",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
if not profiles:
|
||||||
|
lines.append("(暂无贡献者数据)")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# 表格头
|
||||||
|
lines.append("| " + " | ".join(["贡献者"] * cols) + " |")
|
||||||
|
lines.append("|" + ":---:|" * cols)
|
||||||
|
row: list[str] = []
|
||||||
|
base = f"https://www.gitlink.org.cn"
|
||||||
|
for p in profiles:
|
||||||
|
login = p["login"] or p["name"]
|
||||||
|
cell = f"{p['emoji']} [{p['name']}]({base}/{login})<br><sub>{p['contributions']} 次</sub>"
|
||||||
|
row.append(cell)
|
||||||
|
if len(row) == cols:
|
||||||
|
lines.append("| " + " | ".join(row) + " |")
|
||||||
|
row = []
|
||||||
|
if row:
|
||||||
|
# 补齐最后一行
|
||||||
|
row += [""] * (cols - len(row))
|
||||||
|
lines.append("| " + " | ".join(row) + " |")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("> 本致谢墙由 gitlink-contributor 自动生成,遵循 all-contributors 精神。")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_ranking(result: dict[str, Any], top: int = 20) -> str:
|
||||||
|
"""渲染贡献排行榜。"""
|
||||||
|
owner, repo = result["owner"], result["repo"]
|
||||||
|
profiles = result["profiles"]
|
||||||
|
lines = [
|
||||||
|
f"# 贡献排行榜 — {owner}/{repo}",
|
||||||
|
"",
|
||||||
|
f"总贡献 {result['total_contributions']} 次,共 {result['total_contributors']} 位贡献者。",
|
||||||
|
"",
|
||||||
|
"| 排名 | 贡献者 | 徽章 | 贡献次数 | 占比 |",
|
||||||
|
"|:----:|--------|------|:--------:|:----:|",
|
||||||
|
]
|
||||||
|
total = result["total_contributions"] or 1
|
||||||
|
for i, p in enumerate(profiles[:top], 1):
|
||||||
|
medal = {1: "🥇", 2: "🥈", 3: "🥉"}.get(i, str(i))
|
||||||
|
perc = p["percentage"] or f"{p['contributions'] / total * 100:.1f}%"
|
||||||
|
lines.append(f"| {medal} | {p['name']} | {p['emoji']} {p['badge']} | {p['contributions']} | {perc} |")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_growth(result: dict[str, Any]) -> str:
|
||||||
|
"""渲染贡献者成长档案(分层 + 徽章体系)。"""
|
||||||
|
owner, repo = result["owner"], result["repo"]
|
||||||
|
lines = [
|
||||||
|
f"# 贡献者成长档案 — {owner}/{repo}",
|
||||||
|
"",
|
||||||
|
"## 徽章体系",
|
||||||
|
"",
|
||||||
|
"| 徽章 | 称号 | 门槛(贡献次数) |",
|
||||||
|
"|:----:|------|:----------------:|",
|
||||||
|
]
|
||||||
|
for threshold, name, emoji in BADGES:
|
||||||
|
lines.append(f"| {emoji} | {name} | ≥ {threshold} |")
|
||||||
|
lines += ["", "## 当前分层分布", "", "| 称号 | 人数 |", "|------|:----:|"]
|
||||||
|
# 按徽章门槛从高到低展示
|
||||||
|
for threshold, name, emoji in BADGES:
|
||||||
|
count = result["tiers"].get(name, 0)
|
||||||
|
lines.append(f"| {emoji} {name} | {count} |")
|
||||||
|
lines += ["", "## 成长建议", ""]
|
||||||
|
profiles = result["profiles"]
|
||||||
|
# 找出接近下一徽章的贡献者
|
||||||
|
near: list[str] = []
|
||||||
|
thresholds = sorted(t for t, _, _ in BADGES)
|
||||||
|
for p in profiles:
|
||||||
|
c = p["contributions"]
|
||||||
|
for t in thresholds:
|
||||||
|
if 0 < t - c <= 5:
|
||||||
|
next_badge = assign_badge(t)
|
||||||
|
near.append(f"{p['name']}(还差 {t - c} 次即可获得「{next_badge['badge']}」)")
|
||||||
|
break
|
||||||
|
if near:
|
||||||
|
lines.append("以下贡献者即将晋级,可鼓励 ta 们继续参与:")
|
||||||
|
lines.append("")
|
||||||
|
for n in near[:10]:
|
||||||
|
lines.append(f"- {n}")
|
||||||
|
else:
|
||||||
|
lines.append("暂无临近晋级的贡献者。可通过 good-first-issue 引导更多新人参与。")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_all(result: dict[str, Any]) -> str:
|
||||||
|
"""合并三部分为完整报告。"""
|
||||||
|
return "\n\n---\n\n".join([
|
||||||
|
render_wall(result),
|
||||||
|
render_ranking(result),
|
||||||
|
render_growth(result),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
p = argparse.ArgumentParser(prog="gitlink-contributor",
|
||||||
|
description="贡献者致谢与成长")
|
||||||
|
p.add_argument("--owner", help="仓库所有者")
|
||||||
|
p.add_argument("--repo", help="仓库名称")
|
||||||
|
p.add_argument("--slug", help="owner/repo 或完整 URL")
|
||||||
|
p.add_argument("--section", choices=["all", "wall", "ranking", "growth"],
|
||||||
|
default="all", help="输出哪一部分(默认 all)")
|
||||||
|
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 = analyze(owner, repo)
|
||||||
|
except GitLinkError as exc:
|
||||||
|
print(f"采集失败:{exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.format == "json":
|
||||||
|
out = json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
else:
|
||||||
|
out = {
|
||||||
|
"all": render_all, "wall": render_wall,
|
||||||
|
"ranking": render_ranking, "growth": render_growth,
|
||||||
|
}[args.section](result)
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
@ -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,111 @@
|
||||||
|
"""gitlink-contributor 单元测试。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from contributor import (
|
||||||
|
assign_badge, build_profiles, analyze,
|
||||||
|
render_wall, render_ranking, render_growth, BADGES,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, contribs):
|
||||||
|
self._c = contribs
|
||||||
|
|
||||||
|
def contributors(self, owner, repo):
|
||||||
|
return self._c
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE = [
|
||||||
|
{"login": "alice", "name": "Alice", "contributions": 600},
|
||||||
|
{"login": "bob", "name": "Bob", "contributions": 120},
|
||||||
|
{"login": "carol", "name": "Carol", "contributions": 35},
|
||||||
|
{"login": "dave", "name": "Dave", "contributions": 12},
|
||||||
|
{"login": "eve", "name": "Eve", "contributions": 3},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBadge:
|
||||||
|
def test_core_maintainer(self):
|
||||||
|
assert assign_badge(600)["badge"] == "核心维护者"
|
||||||
|
|
||||||
|
def test_senior(self):
|
||||||
|
assert assign_badge(120)["badge"] == "资深贡献者"
|
||||||
|
|
||||||
|
def test_seedling(self):
|
||||||
|
assert assign_badge(2)["badge"] == "贡献者"
|
||||||
|
|
||||||
|
def test_boundary(self):
|
||||||
|
# 恰好达到阈值
|
||||||
|
assert assign_badge(500)["badge"] == "核心维护者"
|
||||||
|
assert assign_badge(499)["badge"] == "资深贡献者"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfiles:
|
||||||
|
def test_sorted_desc(self):
|
||||||
|
profiles = build_profiles(SAMPLE)
|
||||||
|
contribs = [p["contributions"] for p in profiles]
|
||||||
|
assert contribs == sorted(contribs, reverse=True)
|
||||||
|
|
||||||
|
def test_badge_assigned(self):
|
||||||
|
profiles = build_profiles(SAMPLE)
|
||||||
|
alice = profiles[0]
|
||||||
|
assert alice["name"] == "Alice"
|
||||||
|
assert alice["badge"] == "核心维护者"
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert build_profiles([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyze:
|
||||||
|
def test_totals(self):
|
||||||
|
r = analyze("o", "r", client=FakeClient(SAMPLE))
|
||||||
|
assert r["total_contributors"] == 5
|
||||||
|
assert r["total_contributions"] == 600 + 120 + 35 + 12 + 3
|
||||||
|
|
||||||
|
def test_tiers(self):
|
||||||
|
r = analyze("o", "r", client=FakeClient(SAMPLE))
|
||||||
|
assert r["tiers"]["核心维护者"] == 1
|
||||||
|
assert r["tiers"]["资深贡献者"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRender:
|
||||||
|
def test_wall(self):
|
||||||
|
r = analyze("o", "r", client=FakeClient(SAMPLE))
|
||||||
|
wall = render_wall(r)
|
||||||
|
assert "贡献者致谢墙" in wall
|
||||||
|
assert "Alice" in wall
|
||||||
|
|
||||||
|
def test_wall_empty(self):
|
||||||
|
r = analyze("o", "r", client=FakeClient([]))
|
||||||
|
assert "暂无贡献者" in render_wall(r)
|
||||||
|
|
||||||
|
def test_ranking(self):
|
||||||
|
r = analyze("o", "r", client=FakeClient(SAMPLE))
|
||||||
|
rank = render_ranking(r)
|
||||||
|
assert "贡献排行榜" in rank
|
||||||
|
assert "🥇" in rank
|
||||||
|
|
||||||
|
def test_growth(self):
|
||||||
|
r = analyze("o", "r", client=FakeClient(SAMPLE))
|
||||||
|
growth = render_growth(r)
|
||||||
|
assert "成长档案" in growth
|
||||||
|
assert "徽章体系" in growth
|
||||||
|
|
||||||
|
def test_growth_near_promotion(self):
|
||||||
|
# dave 12 次,距离 30(活跃)还差 18,不算临近;造一个差 3 次的
|
||||||
|
near_sample = [{"login": "x", "name": "X", "contributions": 27}] # 距 30 差 3
|
||||||
|
r = analyze("o", "r", client=FakeClient(near_sample))
|
||||||
|
growth = render_growth(r)
|
||||||
|
assert "即将晋级" in growth or "临近晋级" in growth or "还差" in growth
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(pytest.main([__file__, "-v"]))
|
||||||
Loading…
Reference in New Issue