gitlink-cli/skills/gitlink-newcomer/scripts/newcomer.py

334 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""gitlink-newcomer新人引导分析。
识别一个 GitLink 仓库中适合新贡献者上手的 Issue评估上手难度与友好度
为每个候选 Issue 生成个性化引导评论,并产出新手任务看板。
数据来自 GitLink 公开 API只读无需登录。生成的引导评论仅作为建议输出
是否发布由用户通过 gitlink-cli 自行决定。
用法:
python newcomer.py --owner Gitlink --repo gitlink-cli
python newcomer.py --owner Gitlink --repo gitlink-cli --format json
python newcomer.py --owner Gitlink --repo gitlink-cli --issue 12 # 只为某个 Issue 生成引导
"""
from __future__ import annotations
import argparse
import json
import re
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
# ---------------------------------------------------------------------------
# 识别规则
# ---------------------------------------------------------------------------
# good-first-issue 的标签信号(不同项目命名习惯)
GOOD_FIRST_LABELS = {
"good first issue", "good-first-issue", "goodfirstissue",
"good first", "first-timers-only", "first timers only",
"beginner", "beginner-friendly", "easy", "starter",
"新手", "新手友好", "新人", "入门", "简单",
}
# 标题/正文中暗示「适合新手」的关键词
EASY_KEYWORDS = [
"typo", "document", "docs", "readme", "comment", "translation", "translate",
"rename", "format", "lint", "test", "example", "i18n",
"文档", "注释", "拼写", "翻译", "示例", "格式", "重命名",
]
# 暗示「难度较高、不适合新手」的关键词
HARD_KEYWORDS = [
"refactor", "architecture", "performance", "concurrency", "race",
"security", "deadlock", "memory leak", "breaking change",
"重构", "架构", "性能", "并发", "安全", "死锁", "内存",
]
# 难度高的标签
HARD_LABELS = {"hard", "complex", "advanced", "epic", "困难", "复杂"}
def _text_of(issue: dict[str, Any]) -> str:
"""合并 Issue 的标题与正文用于关键词分析。"""
title = issue.get("name") or issue.get("subject") or issue.get("title") or ""
body = issue.get("description") or issue.get("body") or ""
return f"{title}\n{body}".lower()
def _labels_of(issue: dict[str, Any]) -> list[str]:
"""提取 Issue 标签名(兼容多种字段结构)。"""
labels: list[str] = []
raw = issue.get("issue_tags") or issue.get("labels") or issue.get("tags")
if isinstance(raw, list):
for item in raw:
if isinstance(item, dict):
name = item.get("name") or item.get("title")
if name:
labels.append(str(name).lower())
elif item:
labels.append(str(item).lower())
return labels
def _issue_number(issue: dict[str, Any]) -> int | None:
"""提取仓库内 Issue 序号web URL 中显示的编号)。
注意GitLink 的 Issue 列表接口通常只返回全局数据库 id不含 web 序号;
只有单 Issue 详情或带 number/index 字段时才有可靠序号。取不到返回 None
避免把全局 id 误当作 web 序号引用。
"""
for key in ("number", "index"):
val = issue.get(key)
if isinstance(val, int):
return val
return None
def _issue_gid(issue: dict[str, Any]) -> int | None:
"""全局数据库 id仅用于去重/展示,不可用于 web 链接 / PR 关联)。"""
val = issue.get("id")
return val if isinstance(val, int) else None
def score_issue(issue: dict[str, Any]) -> dict[str, Any]:
"""评估单个 Issue 的新手友好度。
返回友好度评分0-100、难度等级、命中的信号
评分越高越适合新人上手。
"""
text = _text_of(issue)
labels = _labels_of(issue)
signals: list[str] = []
score = 50 # 基准分
# 标签信号(最强)
if any(lb in GOOD_FIRST_LABELS for lb in labels):
score += 35
signals.append("带有新手友好标签")
if any(lb in HARD_LABELS for lb in labels):
score -= 30
signals.append("带有高难度标签")
# 关键词信号
easy_hits = [k for k in EASY_KEYWORDS if k in text]
if easy_hits:
score += min(20, len(easy_hits) * 7)
signals.append(f"内容涉及易上手主题({', '.join(easy_hits[:3])}")
hard_hits = [k for k in HARD_KEYWORDS if k in text]
if hard_hits:
score -= min(25, len(hard_hits) * 10)
signals.append(f"内容涉及高难度主题({', '.join(hard_hits[:3])}")
# 描述长度:太长往往复杂
body = issue.get("description") or issue.get("body") or ""
if len(body) > 1500:
score -= 8
signals.append("描述较长,可能较复杂")
elif 30 <= len(body) <= 600:
score += 5
signals.append("描述长度适中")
# 评论数:讨论太多可能有争议或难度大
comments = issue.get("comment_journals_count") or issue.get("journals_count") or 0
if isinstance(comments, int) and comments > 15:
score -= 8
signals.append("讨论较多,可能存在分歧")
score = max(0, min(100, score))
if score >= 75:
difficulty = "入门"
elif score >= 55:
difficulty = "较易"
elif score >= 40:
difficulty = "中等"
else:
difficulty = "进阶"
return {
"number": _issue_number(issue),
"gid": _issue_gid(issue),
"title": issue.get("name") or issue.get("subject") or issue.get("title") or "(无标题)",
"labels": labels,
"friendliness": score,
"difficulty": difficulty,
"signals": signals,
"author": issue.get("author_login") or issue.get("author_name"),
"comments": comments if isinstance(comments, int) else 0,
"is_good_first": score >= 55,
}
def build_guidance(scored: dict[str, Any], owner: str, repo: str) -> str:
"""为一个候选 Issue 生成个性化的新手引导评论。"""
num = scored.get("number")
title = scored["title"]
diff = scored["difficulty"]
# 有可靠 web 序号时用 #num 引用,否则用标题引用(不误用全局 id
ref = f"#{num}" if num else f"{title}"
lines = [
f"👋 欢迎!这个 Issue{ref})被 gitlink-newcomer 评估为 **{diff}** 难度,适合作为参与本项目的起点。",
"",
"如果你想认领它,建议按以下步骤上手:",
"",
f"1. 阅读项目的 `README` 和 `CONTRIBUTING`(如有),了解开发与提交规范。",
f"2. Fork 本仓库并克隆你的 Fork`gitlink-cli repo +fork --owner {owner} --repo {repo}`",
"3. 新建一个分支进行修改,保持改动聚焦于本 Issue。",
f"4. 完成后从你的 Fork 向 `{owner}/{repo}` 提交 PR并在描述里关联本 Issue。",
"",
]
if "文档" in str(scored["signals"]) or "docs" in str(scored["signals"]).lower():
lines.append("> 提示:这看起来是一个文档/文本类改动,通常不需要改动核心逻辑,很适合第一次贡献。")
if scored["comments"] > 8:
lines.append("> 提示:该 Issue 已有较多讨论,动手前建议先通读评论,确认当前结论与分工。")
lines.append("")
lines.append("有任何问题都可以在本 Issue 下留言,社区很乐意帮助新人。祝贡献顺利!🚀")
return "\n".join(lines)
def analyze(owner: str, repo: str, limit: int = 50,
client: GitLinkClient | None = None) -> dict[str, Any]:
"""分析仓库的 Issue识别并排序新手友好的候选。"""
client = client or GitLinkClient()
issues = client.issues(owner, repo, limit=limit)
scored = [score_issue(i) for i in issues]
candidates = [s for s in scored if s["is_good_first"]]
candidates.sort(key=lambda s: s["friendliness"], reverse=True)
return {
"owner": owner,
"repo": repo,
"total_issues": len(issues),
"candidate_count": len(candidates),
"candidates": candidates,
"all_scored": scored,
}
def render_board(result: dict[str, Any], owner: str, repo: str) -> str:
"""渲染新手任务看板Markdown"""
lines = [
f"# 新手任务看板 — {owner}/{repo}",
"",
f"由 gitlink-newcomer 生成。共扫描 {result['total_issues']} 个开放 Issue"
f"识别出 **{result['candidate_count']}** 个适合新贡献者上手的任务。",
"",
]
if not result["candidates"]:
lines += [
"暂未发现明显适合新手的 Issue。建议维护者",
"",
"- 为简单任务打上 `good first issue` 标签",
"- 在 Issue 描述里补充清晰的上手说明与验收标准",
"",
]
return "\n".join(lines)
lines += [
"| 推荐度 | 难度 | Issue | 标题 | 命中信号 |",
"|:------:|:----:|:-----:|------|----------|",
]
for c in result["candidates"]:
stars = "" * max(1, round(c["friendliness"] / 20))
signal = c["signals"][0] if c["signals"] else "-"
title = c["title"][:40]
# 有 web 序号用 #num否则标注全局 idgid以便定位
if c.get("number"):
ref = f"#{c['number']}"
elif c.get("gid"):
ref = f"id:{c['gid']}"
else:
ref = "-"
lines.append(
f"| {stars} | {c['difficulty']} | {ref} | {title} | {signal} |"
)
lines += [
"",
"## 建议行动",
"",
"1. 为上述 Issue 添加引导评论,欢迎新贡献者认领(见各 Issue 的引导文案)。",
"2. 确认这些 Issue 的描述包含足够的上手信息。",
"3. 可在仓库 README 中链接本看板,方便新人发现。",
"",
]
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(
prog="gitlink-newcomer",
description="识别 good-first-issue 并生成新手引导",
)
p.add_argument("--owner", help="仓库所有者,如 Gitlink")
p.add_argument("--repo", help="仓库名称,如 gitlink-cli")
p.add_argument("--slug", help="owner/repo 形式,或完整仓库 URL")
p.add_argument("--issue", type=int, help="只为指定 Issue 编号生成引导评论")
p.add_argument("--limit", type=int, default=50, help="扫描的 Issue 数量上限(默认 50")
p.add_argument("--format", choices=["markdown", "json"], default="markdown",
help="输出格式(默认 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
client = GitLinkClient()
try:
# 单 Issue 引导模式
if args.issue is not None:
detail = client.issue_detail(owner, repo, args.issue)
if not detail:
print(f"未找到 Issue #{args.issue}", file=sys.stderr)
return 1
scored = score_issue(detail)
guidance = build_guidance(scored, owner, repo)
if args.format == "json":
out = json.dumps({"issue": scored, "guidance": guidance},
ensure_ascii=False, indent=2)
else:
out = guidance
else:
result = analyze(owner, repo, limit=args.limit, client=client)
if args.format == "json":
# 为每个候选附上引导文案
for c in result["candidates"]:
c["guidance"] = build_guidance(c, owner, repo)
out = json.dumps(result, ensure_ascii=False, indent=2)
else:
out = render_board(result, owner, repo)
except GitLinkError as exc:
print(f"采集失败:{exc}", file=sys.stderr)
return 1
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())