Merge pull request 'feat(skills): 新增 gitlink-standup 个人/团队日报周报 Skill' (#229) from Ct201314/gitlink-cli:skill/gitlink-standup into master
This commit is contained in:
commit
d92c598f25
|
|
@ -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) — 认证、全局参数、安全规则
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# 活动类型说明
|
||||
|
||||
GitLink 用户动态(project_trends)的 `trend_type` 字段取值与本技能的呈现映射:
|
||||
|
||||
| trend_type | 含义 | 报告标签 |
|
||||
|------------|------|----------|
|
||||
| CommitLog | 代码提交 | 💻 代码提交 |
|
||||
| Issue | 创建/更新 Issue | 🐛 Issue |
|
||||
| PullRequest | 创建/合并/关闭 PR | 🔀 合并请求 |
|
||||
| VersionRelease | 版本发布 | 🏷️ 版本发布 |
|
||||
|
||||
未识别的类型按原值展示。
|
||||
|
||||
## 汇总逻辑
|
||||
|
||||
- 对每个成员,按 `trend_type` 统计活动条数。
|
||||
- 每类活动保留最多 5 条代表性标题(取 `name` 首行,截断 70 字符)。
|
||||
- 多成员时逐一汇总并合计总活动数。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- **个人日报**:`--user <login>`,看某人最近做了什么。
|
||||
- **团队周报**:`--users a,b,c --period 周`,团队同步会前快速生成。
|
||||
- **管理视角**:观察成员活动类型分布(偏开发 / 偏协作 / 偏发版)。
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# gitlink-standup API 参考
|
||||
|
||||
> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md)。
|
||||
|
||||
本技能全程只读。
|
||||
|
||||
## 采集的接口
|
||||
|
||||
### 用户动态
|
||||
|
||||
```
|
||||
GET /users/:login/project_trends.json?page=<n>&limit=<n>
|
||||
```
|
||||
|
||||
返回 `project_trends[]`,使用字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `trend_type` | 活动类型:CommitLog / Issue / PullRequest / VersionRelease |
|
||||
| `action_type` | 动作描述(如"创建了代码提交(Commit)") |
|
||||
| `action_time` | 相对时间(如"3天前") |
|
||||
| `name` | 活动标题(提交信息 / Issue 标题 / PR 标题) |
|
||||
|
||||
### 用户信息(可选)
|
||||
|
||||
```
|
||||
gitlink-cli user +info --login <login> --format json
|
||||
```
|
||||
|
||||
## 时间说明
|
||||
|
||||
`action_time` 为相对时间字符串,非精确时间戳,因此本技能按**活动条数 + 类型**汇总,
|
||||
通过 `--limit` 控制采集的近期活动量,而非按精确日期区间过滤。
|
||||
|
||||
## 输出字段(JSON)
|
||||
|
||||
```json
|
||||
[
|
||||
{"login": "wbtiger", "total_activities": 30,
|
||||
"by_type": {"CommitLog": 26, "PullRequest": 4},
|
||||
"samples": {"CommitLog": ["..."], "PullRequest": ["..."]}}
|
||||
]
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
某成员动态采集失败时,记为 0 活动,不中断其他成员。
|
||||
|
|
@ -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}")
|
||||
|
|
@ -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())
|
||||
|
|
@ -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"]))
|
||||
Loading…
Reference in New Issue