diff --git a/skills/gitlink-standup/SKILL.md b/skills/gitlink-standup/SKILL.md new file mode 100644 index 0000000..7f6e635 --- /dev/null +++ b/skills/gitlink-standup/SKILL.md @@ -0,0 +1,85 @@ +--- +name: gitlink-standup +version: 1.0.0 +description: "个人/团队日报周报:汇总成员的提交、Issue、PR、版本发布活动,按类型统计生成团队同步日报/周报。当用户提到「日报」「周报」「standup」「团队同步」「某人最近做了什么」「成员活动」「team report」时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + optional_bins: ["python"] + cliHelp: "gitlink-cli user --help" +--- + +# gitlink-standup(个人/团队日报周报) + +**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-insight` / `gitlink-health` +- 单个仓库的 Issue/PR 列表 → 用 `gitlink-issue` / `gitlink-pr` + +## 能力概览 + +| 能力 | 说明 | +|------|------| +| 活动汇总 | 按 commit / Issue / PR / 版本发布分类统计 | +| 多成员 | 一次汇总整个团队 | +| 样例展示 | 每类活动展示代表性标题 | + +## 工作流:生成日报/周报 + +### 方式 A:配套脚本(推荐) + +```bash +# 单个成员 +python scripts/standup.py --user wbtiger + +# 整个团队 +python scripts/standup.py --users wbtiger,wangyue789 --period 周 + +# JSON 输出 +python scripts/standup.py --user wbtiger --format json +``` + +参数说明: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|:----:|------| +| `--user` | string | 是* | 单个成员 login(*或 `--users`) | +| `--users` | string | 否 | 多个成员 login,逗号分隔 | +| `--limit` | int | 否 | 每人采集的活动条数上限,默认 50 | +| `--period` | string | 否 | 周期标注(如 日 / 周) | +| `--format` | string | 否 | `markdown`(默认)或 `json` | +| `--output` | string | 否 | 输出文件 | + +### 方式 B:用 gitlink-cli 命令 + +```bash +# 查看用户信息 +gitlink-cli user +info --login wbtiger --format json + +# 用户动态(Raw API) +gitlink-cli api GET /users/wbtiger/project_trends --query 'page=1&limit=50' --format json +``` + +## API 注意事项 + +- 活动数据来自 `users/:login/project_trends`,`action_time` 为相对时间(如"3天前"), + 因此本技能按活动条数与类型汇总,而非精确时间区间过滤。 +- 仅统计公开活动;私有仓库活动不在其中。 + +## References + +- [api-reference.md](references/api-reference.md) — 采集接口与字段 +- [activity-types.md](references/activity-types.md) — 活动类型说明 +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证、全局参数、安全规则 diff --git a/skills/gitlink-standup/references/activity-types.md b/skills/gitlink-standup/references/activity-types.md new file mode 100644 index 0000000..71a030b --- /dev/null +++ b/skills/gitlink-standup/references/activity-types.md @@ -0,0 +1,24 @@ +# 活动类型说明 + +GitLink 用户动态(project_trends)的 `trend_type` 字段取值与本技能的呈现映射: + +| trend_type | 含义 | 报告标签 | +|------------|------|----------| +| CommitLog | 代码提交 | 💻 代码提交 | +| Issue | 创建/更新 Issue | 🐛 Issue | +| PullRequest | 创建/合并/关闭 PR | 🔀 合并请求 | +| VersionRelease | 版本发布 | 🏷️ 版本发布 | + +未识别的类型按原值展示。 + +## 汇总逻辑 + +- 对每个成员,按 `trend_type` 统计活动条数。 +- 每类活动保留最多 5 条代表性标题(取 `name` 首行,截断 70 字符)。 +- 多成员时逐一汇总并合计总活动数。 + +## 适用场景 + +- **个人日报**:`--user `,看某人最近做了什么。 +- **团队周报**:`--users a,b,c --period 周`,团队同步会前快速生成。 +- **管理视角**:观察成员活动类型分布(偏开发 / 偏协作 / 偏发版)。 diff --git a/skills/gitlink-standup/references/api-reference.md b/skills/gitlink-standup/references/api-reference.md new file mode 100644 index 0000000..7a8c769 --- /dev/null +++ b/skills/gitlink-standup/references/api-reference.md @@ -0,0 +1,47 @@ +# gitlink-standup API 参考 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md)。 + +本技能全程只读。 + +## 采集的接口 + +### 用户动态 + +``` +GET /users/:login/project_trends.json?page=&limit= +``` + +返回 `project_trends[]`,使用字段: + +| 字段 | 说明 | +|------|------| +| `trend_type` | 活动类型:CommitLog / Issue / PullRequest / VersionRelease | +| `action_type` | 动作描述(如"创建了代码提交(Commit)") | +| `action_time` | 相对时间(如"3天前") | +| `name` | 活动标题(提交信息 / Issue 标题 / PR 标题) | + +### 用户信息(可选) + +``` +gitlink-cli user +info --login --format json +``` + +## 时间说明 + +`action_time` 为相对时间字符串,非精确时间戳,因此本技能按**活动条数 + 类型**汇总, +通过 `--limit` 控制采集的近期活动量,而非按精确日期区间过滤。 + +## 输出字段(JSON) + +```json +[ + {"login": "wbtiger", "total_activities": 30, + "by_type": {"CommitLog": 26, "PullRequest": 4}, + "samples": {"CommitLog": ["..."], "PullRequest": ["..."]}} +] +``` + +## 错误处理 + +某成员动态采集失败时,记为 0 活动,不中断其他成员。 diff --git a/skills/gitlink-standup/scripts/glapi.py b/skills/gitlink-standup/scripts/glapi.py new file mode 100644 index 0000000..c6c94c0 --- /dev/null +++ b/skills/gitlink-standup/scripts/glapi.py @@ -0,0 +1,250 @@ +"""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 user_trends(self, login: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]: + """用户动态:commit/issue/pr/release 活动流。 + + 每条含 trend_type(CommitLog/Issue/PullRequest/VersionRelease)、 + action_type、action_time(相对时间)、name(标题)。 + """ + data = self.get(f"users/{login}/project_trends.json", {"page": page, "limit": limit}) + return _extract_list(data, ("project_trends",)) + + 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-standup/scripts/standup.py b/skills/gitlink-standup/scripts/standup.py new file mode 100644 index 0000000..4431a1f --- /dev/null +++ b/skills/gitlink-standup/scripts/standup.py @@ -0,0 +1,129 @@ +"""gitlink-standup:个人/团队日报周报。 + +汇总一个或多个成员的近期活动(提交、Issue、PR、版本发布),按活动类型统计, +生成适合团队同步(standup)的日报/周报。 + +数据来自 GitLink 用户动态接口(公开,只读),无需登录。 + +用法: + python standup.py --user wbtiger + python standup.py --users wbtiger,wangyue789 --limit 50 + python standup.py --user wbtiger --format json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections import Counter +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from glapi import GitLinkClient, GitLinkError + +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + +TYPE_LABEL = { + "CommitLog": "💻 代码提交", + "Issue": "🐛 Issue", + "PullRequest": "🔀 合并请求", + "VersionRelease": "🏷️ 版本发布", +} + + +def summarize_user(login: str, trends: list[dict[str, Any]]) -> dict[str, Any]: + """汇总单个成员的活动。""" + by_type: Counter[str] = Counter() + samples: dict[str, list[str]] = {} + for t in trends: + tt = t.get("trend_type") or "Other" + by_type[tt] += 1 + name = (t.get("name") or "").splitlines()[0][:70] if t.get("name") else "" + if name: + samples.setdefault(tt, []) + if len(samples[tt]) < 5: + samples[tt].append(name) + return { + "login": login, + "total_activities": len(trends), + "by_type": dict(by_type), + "samples": samples, + } + + +def render_report(summaries: list[dict[str, Any]], period: str = "近期") -> str: + """渲染日报/周报(Markdown)。""" + lines = [f"# 团队活动{period}报", "", + f"覆盖 {len(summaries)} 位成员,按 GitLink 活动动态汇总。", ""] + total_all = sum(s["total_activities"] for s in summaries) + lines.append(f"总活动数:**{total_all}**") + lines.append("") + for s in summaries: + lines.append(f"## @{s['login']}({s['total_activities']} 项活动)") + lines.append("") + if not s["total_activities"]: + lines.append("- 该时间范围内暂无公开活动") + lines.append("") + continue + for tt, n in s["by_type"].items(): + label = TYPE_LABEL.get(tt, tt) + lines.append(f"### {label}:{n}") + for name in s["samples"].get(tt, []): + lines.append(f"- {name}") + lines.append("") + lines.append("---\n\n由 gitlink-standup 生成。活动数据来自 GitLink 用户动态,仅统计公开活动。") + return "\n".join(lines) + + +def analyze(users: list[str], limit: int = 50, + client: GitLinkClient | None = None) -> list[dict[str, Any]]: + client = client or GitLinkClient() + out = [] + for u in users: + try: + trends = client.user_trends(u, limit=limit) + except GitLinkError: + trends = [] + out.append(summarize_user(u, trends)) + return out + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(prog="gitlink-standup", description="个人/团队日报周报") + p.add_argument("--user", help="单个成员 login") + p.add_argument("--users", help="多个成员 login,逗号分隔") + p.add_argument("--limit", type=int, default=50, help="每人采集的活动条数上限") + p.add_argument("--period", default="近期", help="报告周期标注,如 日 / 周") + p.add_argument("--format", choices=["markdown", "json"], default="markdown") + p.add_argument("--output", type=Path) + args = p.parse_args(argv) + + users: list[str] = [] + if args.users: + users = [u.strip() for u in args.users.split(",") if u.strip()] + elif args.user: + users = [args.user] + else: + print("错误:请用 --user 或 --users 指定成员。", file=sys.stderr) + return 2 + + summaries = analyze(users, limit=args.limit) + out = (json.dumps(summaries, ensure_ascii=False, indent=2) if args.format == "json" + else render_report(summaries, period=args.period)) + 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-standup/tests/test_standup.py b/skills/gitlink-standup/tests/test_standup.py new file mode 100644 index 0000000..36be10c --- /dev/null +++ b/skills/gitlink-standup/tests/test_standup.py @@ -0,0 +1,61 @@ +"""gitlink-standup 单元测试。""" +from __future__ import annotations +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) +import pytest +from standup import summarize_user, render_report, analyze + + +def trend(tt, name="something"): + return {"trend_type": tt, "action_type": "x", "action_time": "1天前", "name": name} + + +class TestSummarize: + def test_counts_by_type(self): + s = summarize_user("u", [trend("CommitLog"), trend("CommitLog"), trend("PullRequest")]) + assert s["total_activities"] == 3 + assert s["by_type"]["CommitLog"] == 2 + assert s["by_type"]["PullRequest"] == 1 + + def test_samples_capped(self): + s = summarize_user("u", [trend("CommitLog", f"c{i}") for i in range(10)]) + assert len(s["samples"]["CommitLog"]) == 5 # 每类最多 5 条样例 + + def test_empty(self): + s = summarize_user("u", []) + assert s["total_activities"] == 0 + assert s["by_type"] == {} + + def test_multiline_name_first_line(self): + s = summarize_user("u", [trend("Issue", "标题\n正文第二行")]) + assert s["samples"]["Issue"][0] == "标题" + + +class TestRender: + def test_report(self): + summaries = [summarize_user("alice", [trend("CommitLog")])] + md = render_report(summaries, period="周") + assert "团队活动周报" in md + assert "@alice" in md + assert "代码提交" in md + + def test_empty_member(self): + md = render_report([summarize_user("bob", [])]) + assert "暂无公开活动" in md + + +class TestAnalyze: + class FakeClient: + def user_trends(self, login, limit=50): + return [trend("CommitLog"), trend("Issue")] if login == "alice" else [] + + def test_multi_user(self): + r = analyze(["alice", "bob"], client=self.FakeClient()) + assert len(r) == 2 + assert r[0]["total_activities"] == 2 + assert r[1]["total_activities"] == 0 + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"]))