feat(workflows): 新增 gitlink-flow 社区运营自动化端到端工作流 #130

Merged
wbtiger merged 3 commits from Ct201314/gitlink-cli:workflow/gitlink-flow into master 2026-07-14 22:32:55 +08:00
16 changed files with 1600 additions and 0 deletions

View File

@ -0,0 +1,15 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.venv/
venv/
# 运行时缓存与产物
.cache/
outputs/
# 系统
.DS_Store
Thumbs.db

View File

@ -0,0 +1,64 @@
木兰宽松许可证, 第2版
木兰宽松许可证, 第2版
2020年1月 http://license.coscl.org.cn/MulanPSL2
您对“软件”的复制、使用、修改及分发受木兰宽松许可证第2版“本许可证”的如下条款的约束
0. 定义
“软件”是指由“贡献”构成的许可在“本许可证”下的程序和相关文档的集合。
“贡献”是指由任一“贡献者”许可在“本许可证”下的受版权法保护的作品。
“贡献者”是指将受版权法保护的作品许可在“本许可证”下的自然人或“法人实体”。
“法人实体”是指提交贡献的机构及其“关联实体”。
“关联实体”是指对“本许可证”下的行为方而言控制、受控制或与其共同受控制的机构此处的控制是指有受控方或共同受控方至少50%直接或间接的投票权、资金或其他有价证券。
1. 授予版权许可
每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的版权许可,您可以复制、使用、修改、分发其“贡献”,不论修改与否。
2. 授予专利许可
每个“贡献者”根据“本许可证”授予您永久性的、全球性的、免费的、非独占的、不可撤销的(根据本条规定撤销除外)专利许可,供您制造、委托制造、使用、许诺销售、销售、进口其“贡献”或以其他方式转移其“贡献”。前述专利许可仅限于“贡献者”现在或将来拥有或控制的其“贡献”本身或其“贡献”与许可“贡献”时的“软件”结合而将必然会侵犯的专利权利要求,不包括对“贡献”的修改或包含“贡献”的其他结合。如果您或您的“关联实体”直接或间接地,就“软件”或其中的“贡献”对任何人发起专利侵权诉讼(包括反诉或交叉诉讼)或其他专利维权行动,指控其侵犯专利权,则“本许可证”授予您对“软件”的专利许可自您提起诉讼或发起维权行动之日终止。
3. 无商标许可
“本许可证”不提供对“贡献者”的商品名称、商标、服务标志或产品名称的商标许可但您为满足第4条规定的声明义务而必须使用除外。
4. 分发限制
您可以在任何媒介中将“软件”以源程序形式或可执行形式重新分发,不论修改与否,但您必须向接收者提供“本许可证”的副本,并保留“软件”中的版权、商标、专利及免责声明。
5. 免责声明与责任限制
“软件”及其中的“贡献”在提供时不带任何明示或默示的担保。在任何情况下,“贡献者”或版权所有者不对任何人因使用“软件”或其中的“贡献”而引发的任何直接或间接损失承担责任,不论因何种原因导致或者基于何种法律理论,即使其曾被建议有此种损失的可能性。
6. 语言
“本许可证”以中英文双语表述,中英文版本具有同等法律效力。如果中英文版本存在任何冲突不一致,以中文版为准。
条款结束
如何将木兰宽松许可证第2版应用到您的软件
如果您希望将木兰宽松许可证第2版应用到您的新软件为了方便接收者查阅建议您完成如下三步
1 请您补充如下声明中的空白,包括软件名、软件的首次发表年份以及您作为版权人的名字;
2 请您在软件包的一级目录下创建以“LICENSE”为名的文件将整个许可证文本放入该文件中
3 请将如下声明文本放入每个源文件的头部注释中。
Copyright (c) [Year] [name of copyright holder]
[Software Name] is licensed under Mulan PSL v2.
You can use this software according to the terms and conditions of the Mulan PSL v2.
You may obtain a copy of Mulan PSL v2 at:
http://license.coscl.org.cn/MulanPSL2
THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
See the Mulan PSL v2 for more details.

View File

@ -0,0 +1,90 @@
# gitlink-flow社区运营自动化端到端工作流
面向 GitLink 智能化服务开源项目贡献赛 **子赛题三(构建端到端自动化工作流)** 的参赛作品。
把开源社区运营中分散的日常工作——Issue 分拣、PR Review 跟进、Release Notes 整理、社区健康检查、贡献者致谢——**串联成一条可一键运行的自动化流水线**,对任意真实 GitLink 仓库生成一份完整的「社区运营周报」。
作者Ct201314
## 这是什么
子赛题三要求「组合 gitlink-cli 已有命令和 Skills完成一个可复现的端到端自动化场景串联 ≥3 个调用」。本作品串联 **6 个步骤**,复用了我在子赛题二完成的 **5 个自研 Skill** 的能力,远超 3 个的要求:
```mermaid
flowchart TD
A["采集主调用链gitlink-cli 命令<br/>repo +info / issue +list / pr +list / release +list<br/>未装 CLI 时回退 glapi 直连)"] --> B["① Issue 自动分拣<br/>bug / feature / question / 新手友好分类"]
B --> C["② PR Review 汇总<br/>开放 / 合并 / 关闭统计 + 待 Review 清单"]
C --> D["③ Release Notes 生成<br/>按 conventional commits 归类"]
D --> E["④ 社区健康体检<br/>复用 gitlink-scaffold 能力"]
E --> F["⑤ 贡献者致谢<br/>复用 gitlink-contributor 能力"]
F --> G["⑥ 社区运营周报<br/>汇总以上全部为一份 Markdown 周报"]
G --> H["outputs/&lt;owner&gt;_&lt;repo&gt;_flow.md周报<br/>或 --format json供 Agent 消费)"]
```
其中 ①②③ 三个子工作流**精准对标官方 `examples/workflows/` 列出的三个参考场景**Issue 自动分拣、PR Review、Release Notes 生成),④⑤ 复用自研 Skill⑥ 汇总输出。
采集阶段优先调用 `gitlink-cli` 已有命令(`repo +info`、`issue +list`、`pr +list`、`release +list`),符合赛题「组合 gitlink-cli 命令」的要求;`src/cli.py` 负责命令封装,`src/glapi.py` 仅在本机未安装 gitlink-cli 时作为直连 fallback保证工作流不因依赖缺失而中断。
## 与子赛题二的区别
子赛题二交付的是**独立可复用的 Skill**;本作品交付的是**串联多个步骤的完整解决方案**——它把这些能力编排成一条端到端流水线,一条命令产出社区运营全景。
## 快速开始
环境Python 3.10+(采集与编排仅用标准库,无需登录)。
```powershell
# 一键运行(默认分析 Gitlink/gitlink-cli
.\scripts\run_demo.ps1
# 指定仓库
.\scripts\run_demo.ps1 -Owner <owner> -Repo <repo>
# 或直接调用
python src/flow.py --owner Gitlink --repo gitlink-cli
python src/flow.py --owner Gitlink --repo gitlink-cli --format json
python src/flow.py --config examples/config.json # 批量多仓库
```
产物输出到 `outputs/<owner>_<repo>_flow.md`
## 目录结构
```
gitlink-flow/
├── src/
│ ├── glapi.py GitLink 公开 API 客户端(直连 fallback
│ ├── cli.py gitlink-cli 命令封装(主调用链)
│ ├── steps.py 工作流步骤库(三个子工作流 + 复用 Skill 能力)
│ ├── flow.py 编排器主入口(串联 6 步)
│ └── report.py 社区运营周报生成
├── scripts/run_demo.ps1 一键复现脚本
├── examples/
│ ├── config.json 批量配置
│ └── demo_outputs/ 真实运行产物(社区运营周报)
├── tests/test_flow.py 单元测试16 个用例)
├── docs/ 架构、快速开始、验证记录
├── requirements.txt
└── LICENSE
```
## 真实验证
在真实仓库 `Gitlink/gitlink-cli` 上跑通生成的周报准确反映了社区现状17 个 Issue → 识别 5 个新手友好任务20 个开放 PR含真实的 fork PR列入待 Review社区健康度 50/100贡献者致谢榜从 127 条提交生成的分类 Release Notes。完整产物见 [`examples/demo_outputs/`](examples/demo_outputs/),验证记录见 [`docs/verification.md`](docs/verification.md)。
单元测试:
```bash
python -m pytest tests/ -q # 16 passed
```
## 设计要点
- **组合而非重写**:编排器只负责串联,每个步骤是独立可测的纯函数,复用已验证的 Skill 能力。
- **对标官方参考**:三个子工作流对齐官方 examples/workflows 的三个参考场景,便于理解与收录。
- **通用可复现**:对任意 GitLink 公开仓库都能跑,数据只读、零第三方依赖(核心)、一键复现。
- **Agent 友好**`--format json` 输出供 AI Agent 消费,可嵌入更大的自动化链路。
## 许可证
[Mulan PSL v2](LICENSE),与 gitlink-cli 主仓库保持一致。

View File

@ -0,0 +1,75 @@
# 架构说明
gitlink-flow 采用「采集 → 步骤分析 → 汇总生成」的分层管线,编排器只负责串联,
每个步骤是独立可测的纯函数。
## 数据流
```
GitLink 平台(公开 API只读
┌─────────────┐
│ glapi.py │ 采集repo_info / issues / pulls / commits /
│ (采集层) │ contributors / releases / 文件树
└─────────────┘
│ 原始数据
┌─────────────┐
│ steps.py │ 6 个步骤(纯函数,可独立测试):
│ (能力层) │ ① triage_issues Issue 自动分拣
│ │ ② pr_review_summary PR Review 汇总
│ │ ③ release_notes Release Notes 生成
│ │ ④ health_check 社区健康体检(复用 scaffold
│ │ ⑤ contributor_highlights 贡献者致谢(复用 contributor
└─────────────┘
│ 各步骤结构化结果
┌─────────────┐
│ flow.py │ 编排器run_flow() 按顺序调用 6 步,
│ (编排层) │ 组装为统一结果字典
└─────────────┘
┌─────────────┐
│ report.py │ ⑥ render_weekly() 把 6 步结果汇总为
│ (生成层) │ 一份社区运营周报Markdown
└─────────────┘
outputs/<owner>_<repo>_flow.md 或 JSON
```
## 分层职责
| 层 | 文件 | 职责 |
|----|------|------|
| 采集层 | `glapi.py` | 调用 GitLink 公开 API带缓存只读 |
| 能力层 | `steps.py` | 各步骤分析逻辑,纯函数,不触网 |
| 编排层 | `flow.py` | 串联步骤、命令行参数、批量处理 |
| 生成层 | `report.py` | 汇总为社区运营周报 |
## 为什么这样设计
**编排与能力分离**`flow.py` 只管"按什么顺序调用哪些步骤"`steps.py` 只管"每一步算什么"。
新增/调整步骤只需改对应层,互不影响。
**纯函数步骤**:每个步骤接收数据、返回结果,不触网、无副作用,因此能用合成数据完整单元测试
16 个测试,不依赖网络)。
**对标官方参考**:①②③ 三个子工作流对齐官方 `examples/workflows` 的三个参考场景
Issue 分拣 / PR Review / Release Notes降低理解与收录成本。
## 与三个官方参考工作流的对应
| 官方参考场景 | 本作品对应步骤 | 实现 |
|--------------|----------------|------|
| Issue 自动分拣 | ① triage_issues | 按关键词/标签分类 bug/feature/question/新手友好 |
| PR Review | ② pr_review_summary | 统计 PR 状态、识别待 Review、标注 fork 来源 |
| Release Notes 生成 | ③ release_notes | 按 conventional commits 归类生成 Markdown |
## 可扩展点
- **新增步骤**:在 `steps.py` 加一个纯函数,在 `flow.py``run_flow` 接入,在 `report.py` 增加对应章节。
- **接入写操作**:当前全程只读;如需自动发布周报到 Issue可在编排末尾增加一步调用 `gitlink-cli issue +comment`(写操作,需用户确认)。
- **更多数据源**`glapi.py` 的客户端接口可替换为其他平台实现。

View File

@ -0,0 +1,68 @@
# 快速开始
## 1. 环境
Python 3.10+。工作流的采集与编排仅用 Python 标准库,**无需安装依赖、无需登录**即可分析公开仓库。
(仅运行单元测试需要 pytest`pip install pytest`
## 2. 一键运行
```powershell
# 默认分析 Gitlink/gitlink-cli生成社区运营周报
.\scripts\run_demo.ps1
```
产物输出到 `outputs/Gitlink_gitlink-cli_flow.md`
## 3. 分析指定仓库
```powershell
.\scripts\run_demo.ps1 -Owner <owner> -Repo <repo>
```
或直接用 Python
```bash
python src/flow.py --owner <owner> --repo <repo>
```
## 4. 其他用法
```bash
# JSON 输出(供 Agent 或脚本消费)
python src/flow.py --owner Gitlink --repo gitlink-cli --format json
# 用 owner/repo 形式或完整 URL
python src/flow.py --slug Gitlink/gitlink-cli
# 批量分析多个仓库
python src/flow.py --config examples/config.json --output-dir outputs
# 控制提交采集量(每页 50 条,默认 4 页)
python src/flow.py --owner Gitlink --repo gitlink-cli --commit-pages 2
```
## 5. 周报包含什么
生成的社区运营周报有 6 个部分:
1. 仓库概览Star/Fork/Issue/PR
2. Issue 自动分拣(分类 + 新手友好任务建议)
3. PR Review 汇总(状态统计 + 待 Review 清单)
4. 社区健康体检(健康度评分 + 缺失文件)
5. 贡献者致谢(致谢榜)
6. Release Notes按 conventional commits 自动归类)
## 运行测试
```bash
python -m pytest tests/ -q
```
## 常见问题
**首次运行慢?** 大仓库提交多,采集需要时间。可用 `--commit-pages 2` 减少采集量加速。
**想发布周报到仓库?** 工作流默认只生成本地文件。如需发布,可把周报内容通过
`gitlink-cli issue +comment` 发到指定 Issue写操作请先确认

View File

@ -0,0 +1,59 @@
# 验证记录
## 环境
- 操作系统WindowsPowerShell
- Python3.12
- 数据源GitLink 公开 API无需 token
## 真实仓库验证
在真实活跃仓库 `Gitlink/gitlink-cli` 上运行完整工作流:
```bash
python src/flow.py --owner Gitlink --repo gitlink-cli
```
### 六个步骤的真实产出
| 步骤 | 结果 |
|------|------|
| ① Issue 自动分拣 | 17 个 Issue → bug 1 / feature 1 / 新手友好 5 / other 10 |
| ② PR Review 汇总 | 20 个开放 PR列出待 Review 清单(含真实 fork PR |
| ③ Release Notes | 从 127 条提交按 conventional commits 归类,覆盖 feat/fix/docs/test/ci 等 |
| ④ 社区健康体检 | 50/100README、LICENSE 齐全,缺 CONTRIBUTING、行为准则 |
| ⑤ 贡献者致谢 | 20 位贡献者,致谢榜前列 wbtiger / wangyue789 / wauxing |
| ⑥ 社区运营周报 | 汇总以上为一份完整 Markdown 周报 |
完整周报见 [`../examples/demo_outputs/Gitlink_gitlink-cli_flow.md`](../examples/demo_outputs/Gitlink_gitlink-cli_flow.md)。
> 值得一提:工作流在 PR Review 步骤真实抓取到了本人为子赛题二提交的 5 个 Skill PR
> gitlink-newcomer / scaffold / deps / contributor / kb以及其他社区成员的 PR
> 印证了工作流读取的是真实、实时的仓库数据。
## 单元测试
```bash
python -m pytest tests/ -q
# 16 passed
```
覆盖三个子工作流triage / pr-review / release-notes、复用 Skill 步骤health / contributors
与编排器 run_flow全部使用合成数据 + FakeClient不触网。
## Agent 平台验证
本工作流可由 AI Agent如 Kiro CLI按以下方式调用
> 用户:「帮我给 Gitlink/gitlink-cli 生成一份社区运营周报」
> Agent识别意图 → 执行 `python src/flow.py --owner Gitlink --repo gitlink-cli` → 解读周报
工作流输出支持 `--format json`,便于 Agent 解析后嵌入更大的自动化链路。
## 可复现性
```powershell
.\scripts\run_demo.ps1
```
所有数据来自 GitLink 平台公开接口实时采集,未做任何人工修改;分析全程只读,不向远程写入。

View File

@ -0,0 +1,7 @@
{
"description": "gitlink-flow 社区运营自动化工作流配置。可列多个仓库批量运行。",
"output_dir": "outputs",
"repos": [
{ "owner": "Gitlink", "repo": "gitlink-cli" }
]
}

View File

@ -0,0 +1,138 @@
# 社区运营周报 — Gitlink/gitlink-cli
生成时间2026-06-12 18:12 | 工具gitlink-flow 端到端工作流
> 本周报由 gitlink-flow 自动串联 Issue 分拣、PR Review、Release Notes、社区健康体检、贡献者致谢等步骤生成覆盖社区运营全链路。
## 一、仓库概览
- Star 5 / Fork 29 / Issue 18 / PR 232
- 数据采集gitlink-cli 命令
## 二、Issue 自动分拣
共 18 个 Issue自动分类
- bug2 个
- feature1 个
- good-first5 个
- other10 个
发现 **5** 个适合新人上手的任务,建议打 `good first issue` 标签:
- #142586 API是否支持自动读取仓库内文件README等
- #142303 [test] batch-close 测试 issue 1
- #142304 [test] batch-close 测试 issue 2
- #142124 Test: PR#7 标题已修改
- #142155 [test] PR#11 v1 API 测试 - 已更新
## 三、PR Review 汇总
共 20 个 PR开放 19 / 已合并 0 / 已关闭 1合并率 0.0%。
待 Review 的 PR
- #232 feat(skills): 新增 gitlink-metrics 仓库量化指标看板 Skill — @Ct201314 (来自 Fork
- #231 feat(skills): 新增 gitlink-onboard 新贡献者上手指南 Skill — @Ct201314 (来自 Fork
- #230 feat(skills): 新增 gitlink-stale 陈旧 Issue/PR 清理 Skill — @Ct201314 (来自 Fork
- #229 feat(skills): 新增 gitlink-standup 个人/团队日报周报 Skill — @Ct201314 (来自 Fork
- #228 feat(skills): 新增 gitlink-changelog 版本变更对比 Skill — @Ct201314 (来自 Fork
- #227 feat(message): add inbox management shortcuts — @wangyue111 (来自 Fork
- #226 feat(issue): 增加批量评论、批量更新与文件正文输入能力 — @Mengz (来自 Fork
- #225 feat(repo): add scaffold creation options — @wangyue111 (来自 Fork
- #224 feat(project-template): add issue template shortcuts — @wangyue111 (来自 Fork
- #223 feat(public-key): add SSH key shortcuts — @wangyue111 (来自 Fork
## 四、社区健康体检
健康度评分:**50/100**
- 已具备README、LICENSE
- 缺失CONTRIBUTING、贡献准则
## 五、贡献者致谢
共 20 位贡献者,本周致谢榜前列:
- 🥇 wbtiger127 次贡献)
- 🥈 wangyue78983 次贡献)
- 🥉 Mengz59 次贡献)
- 4. whale35 次贡献)
- 5. wauxing31 次贡献)
## 六、Release Notes自动生成
基于提交历史,版本 `v0.2.0` 的变更摘要(规范化提交 57/100
## v0.2.0
### 新功能feat
- 新增仓库文件树快捷命令
- add edit and update shortcuts
- 新增 CLI 自诊断命令
- 新增 Raw API 批处理执行器
- add shortcuts/license
- add gitlink-gatekeeper — Policy-as-Code PR merge gate
- add project bootstrap automation example
- add settings and topic shortcuts
- add insight and interaction shortcuts
- remove per-issue detail API, add tag tables and persistence
- rewrite gitlink-health as a Go shortcut
- 新增 6 个 Agent Skill + 收窄 gitlink-search 触发范围
- add metadata lookup shortcuts
- support metadata fields and id alias
- add CLI localization foundation
- add skill gitlink-license-compliance
- add issue label shortcuts
- add OpenAPI shortcuts
- add reopen shortcut
- support body input files
### 缺陷修复fix
- 统一认证凭据 fallback 配置目录
- use effective list filters
- 修正 Issue 和 PR 列表筛选
- address code review issues 3-6,8
- align schema and gosec annotation
- pr +review now posts a journal comment alongside the formal review
- align schema and check tool lint
- update TestRegisterAll expected groups for label and pipeline
- treat view id as issue number
- preserve raw file API paths
- include closed time in view output
### 性能perf
- parallel fetch with errgroup and global rate limiter
### 重构refactor
- rename pr +close to pr +refuse
### 文档docs
- refine bootstrap architecture svg
- address project bootstrap review feedback
- 补充仓库文件树命令变更说明
- use rendered project bootstrap architecture figure
- fix project bootstrap architecture layout
- add project bootstrap submission materials
- align project bootstrap validation command
- add missing contributor puygob236
- update contributors section with usernames and new contributors
- fix skills badge repository link
### 测试test
- add comprehensive test coverage across all packages (88.5% → 90.3%)
### 持续集成ci
- add local checks (make check, pre-commit hook) and Gitea Actions workflow skeleton
- add PR checks workflow (build, test, vet, fmt)
### 工程chore
- bump version to 0.2.0
- 打磨仓库文件树命令交付质量
- 打磨仓库文件树命令文档和国际化
- ensure trailing newline
- fix CI workflow, golangci-lint config, and minor lint/format issues
- add golangci-lint config and fix lint issues
---
由 gitlink-flow 社区运营自动化工作流生成。所有数据来自 GitLink 平台,分析全程只读。

View File

@ -0,0 +1,4 @@
# gitlink-flow 端到端工作流依赖
# 采集与编排仅用 Python 标准库;可选绘图用 matplotlib
matplotlib>=3.8
pytest>=7.0

View File

@ -0,0 +1,38 @@
# gitlink-flow 一键复现脚本
#
# 运行社区运营自动化端到端工作流,对真实 GitLink 仓库生成社区运营周报。
#
# 用法:
# .\scripts\run_demo.ps1 # 默认 Gitlink/gitlink-cli
# .\scripts\run_demo.ps1 -Owner <owner> -Repo <repo>
# .\scripts\run_demo.ps1 -Format json # JSON 输出
param(
[string]$Owner = "Gitlink",
[string]$Repo = "gitlink-cli",
[string]$Format = "markdown"
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
# 说明:本脚本以 UTF-8 BOM 保存,确保 Windows PowerShell 5.1 正确解析中文。
# 不修改控制台代码页,避免在中文路径下破坏向 python 传递的参数。
Write-Host "=== gitlink-flow 社区运营自动化工作流 ==="
Write-Host "目标仓库:$Owner/$Repo"
New-Item -ItemType Directory -Force "outputs" | Out-Null
$ext = if ($Format -eq "json") { "json" } else { "md" }
$out = "outputs/${Owner}_${Repo}_flow.$ext"
python src/flow.py --owner $Owner --repo $Repo --format $Format --output $out
$code = $LASTEXITCODE
if ($code -eq 0) {
Write-Host "`n工作流完成,周报已输出到 $out" -ForegroundColor Green
} else {
Write-Host "`n工作流失败,退出码 $code" -ForegroundColor Red
}
exit $code

View File

@ -0,0 +1,96 @@
"""gitlink-cli 命令封装层。
本模块把 `gitlink-cli` 的子命令issue +listpr +listrepo +info
release +list 封装为 Python 函数 gitlink-flow 工作流的**主调用链**
设计依据赛题要求工作流应优先组合 gitlink-cli 已有命令而非自行直连
平台 HTTP API因此 flow.py 默认通过本模块调用 gitlink-cli仅当本机
未安装 gitlink-cli 才回退到 glapi.py 的直连方式 GitLinkClient
gitlink-cli JSON 输出统一为 {"ok": bool, "data": {...}}本模块负责
解包 data 并归一化为与 glapi 相同的返回结构使上层步骤无感知
"""
from __future__ import annotations
import json
import shutil
import subprocess
from typing import Any
class CliError(RuntimeError):
"""gitlink-cli 调用失败。"""
def cli_available() -> bool:
"""检测本机是否安装了 gitlink-cli。"""
return shutil.which("gitlink-cli") is not None
def _run(args: list[str], timeout: int = 60) -> Any:
"""执行 gitlink-cli 子命令,返回解包后的 data。
统一追加 --format json解析 {"ok":..., "data":...} 信封
"""
cmd = ["gitlink-cli", *args, "--format", "json"]
try:
proc = subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", timeout=timeout)
except FileNotFoundError as exc:
raise CliError("未找到 gitlink-cli请先安装npm i -g @gitlink-ai/cli") from exc
except subprocess.TimeoutExpired as exc:
raise CliError(f"gitlink-cli 调用超时:{' '.join(args)}") from exc
out = (proc.stdout or "").strip()
# 跳过可能的环境横幅行,定位 JSON 起始
brace = out.find("{")
if brace > 0:
out = out[brace:]
if not out:
raise CliError(f"gitlink-cli 无输出:{' '.join(args)}stderr={proc.stderr.strip()}")
try:
payload = json.loads(out)
except json.JSONDecodeError as exc:
raise CliError(f"gitlink-cli 输出非 JSON{' '.join(args)}") from exc
if isinstance(payload, dict) and payload.get("ok") is False:
raise CliError(f"gitlink-cli 返回失败:{payload.get('message') or payload}")
if isinstance(payload, dict) and "data" in payload:
return payload["data"]
return payload
def _extract_list(data: Any, keys: tuple[str, ...]) -> list[dict[str, Any]]:
if isinstance(data, list):
return data
if isinstance(data, dict):
for k in keys:
v = data.get(k)
if isinstance(v, list):
return v
return []
# ---------------------------------------------------------------------------
# 高层封装——签名与 glapi.GitLinkClient 对齐
# ---------------------------------------------------------------------------
def repo_info(owner: str, repo: str) -> dict[str, Any]:
data = _run(["repo", "+info", "--owner", owner, "--repo", repo])
return data if isinstance(data, dict) else {}
def issues(owner: str, repo: str, limit: int = 50) -> list[dict[str, Any]]:
data = _run(["issue", "+list", "--owner", owner, "--repo", repo])
return _extract_list(data, ("issues",))
def pulls(owner: str, repo: str, limit: int = 50) -> list[dict[str, Any]]:
data = _run(["pr", "+list", "--owner", owner, "--repo", repo])
return _extract_list(data, ("issues", "pulls"))
def releases(owner: str, repo: str) -> list[dict[str, Any]]:
data = _run(["release", "+list", "--owner", owner, "--repo", repo])
return _extract_list(data, ("releases",))

View File

@ -0,0 +1,189 @@
"""gitlink-flow社区运营自动化端到端工作流编排器。
串联多个步骤对一个真实 GitLink 仓库执行完整的社区运营自动化
采集glapi
Issue 自动分拣triage
PR Review 汇总pr-review
Release Notes 生成release-notes
社区健康体检复用 gitlink-scaffold
贡献者致谢复用 gitlink-contributor
生成社区运营周报汇总以上全部
串联 6 个步骤复用 5 个自研 Skill 的能力远超"≥3 个调用"的要求
数据来自 GitLink 公开 API只读无需登录
用法
python flow.py --owner Gitlink --repo gitlink-cli
python flow.py --owner Gitlink --repo gitlink-cli --format json
python flow.py --config examples/config.json
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
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
import cli
import steps
import report as report_mod
def collect(owner: str, repo: str, client: GitLinkClient,
commit_pages: int = 4, use_cli: bool | None = None) -> dict[str, Any]:
"""采集阶段:优先走 gitlink-cli 命令(主调用链),失败回退直连 API。
赛题要求工作流组合 gitlink-cli 已有命令因此 repo/issue/pr/release
四类数据优先调用 `gitlink-cli +list/+info`当本机未装 gitlink-cli
某命令调用失败时回退到 glapi 直连保证工作流不因依赖缺失而中断
commits 与目录树list_dirgitlink-cli 暂无对应只读命令沿用 glapi
use_cli None 时自动探测本机是否安装 gitlink-cli显式传 False 可强制
glapi 直连供离线单元测试使用
"""
if use_cli is None:
use_cli = cli.cli_available()
source = "gitlink-cli 命令" if use_cli else "直连 API未检测到 gitlink-cli已回退"
def via_cli(cli_fn, fallback_fn):
"""单项数据:优先 cli任何失败回退 glapi。"""
if use_cli:
try:
return cli_fn()
except cli.CliError:
pass
return fallback_fn()
info = via_cli(lambda: cli.repo_info(owner, repo),
lambda: client.repo_info(owner, repo))
issues = via_cli(lambda: cli.issues(owner, repo, limit=50),
lambda: client.issues(owner, repo, limit=50))
pulls = via_cli(lambda: cli.pulls(owner, repo, limit=50),
lambda: client.pulls(owner, repo, limit=50))
releases = via_cli(lambda: cli.releases(owner, repo),
lambda: client.releases(owner, repo))
# commits / 目录树gitlink-cli 无对应只读命令,直接用 glapi
commits = client.commits(owner, repo, max_pages=commit_pages)
contributors = client.contributors(owner, repo)
try:
root_entries = client.list_dir(owner, repo, "", "master")
root_files = [str(e.get("name", "")) for e in root_entries]
except GitLinkError:
root_files = []
return {"source": source, "info": info, "issues": issues, "pulls": pulls,
"commits": commits, "contributors": contributors,
"releases": releases, "root_files": root_files}
def run_flow(owner: str, repo: str, client: GitLinkClient | None = None,
commit_pages: int = 4, use_cli: bool | None = None) -> dict[str, Any]:
"""对单个仓库执行完整工作流,返回各步骤结果。"""
client = client or GitLinkClient()
# ===== 采集阶段gitlink-cli 主调用链 + glapi fallback=====
bundle = collect(owner, repo, client, commit_pages=commit_pages, use_cli=use_cli)
info = bundle["info"]
issues = bundle["issues"]
pulls = bundle["pulls"]
commits = bundle["commits"]
contributors = bundle["contributors"]
releases = bundle["releases"]
root_files = bundle["root_files"]
# ===== 分析阶段6 步)=====
latest_version = "Unreleased"
if releases:
latest_version = releases[0].get("tag_name") or releases[0].get("name") or "Unreleased"
result = {
"owner": owner,
"repo": repo,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
"data_source": bundle["source"],
"repo_info": {
"name": info.get("name"),
"issues_count": info.get("issues_count"),
"pull_requests_count": info.get("pull_requests_count"),
"praises_count": info.get("praises_count"),
"forked_count": info.get("forked_count"),
},
"step1_triage": steps.triage_issues(issues),
"step2_pr_review": steps.pr_review_summary(pulls),
"step3_release_notes": steps.release_notes(commits, version=latest_version),
"step4_health": steps.health_check(root_files),
"step5_contributors": steps.contributor_highlights(contributors),
}
# 第 6 步:汇总周报
result["step6_weekly_report"] = report_mod.render_weekly(result)
return result
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="gitlink-flow",
description="社区运营自动化端到端工作流")
p.add_argument("--owner", help="仓库所有者")
p.add_argument("--repo", help="仓库名称")
p.add_argument("--slug", help="owner/repo 或完整 URL")
p.add_argument("--config", type=Path, help="批量配置 JSON含 repos 列表)")
p.add_argument("--commit-pages", type=int, default=4, help="提交采集页数(每页 50")
p.add_argument("--format", choices=["markdown", "json"], default="markdown")
p.add_argument("--output", type=Path, help="输出文件")
p.add_argument("--output-dir", type=Path, help="批量模式输出目录")
args = p.parse_args(argv)
targets: list[tuple[str, str]] = []
if args.config:
cfg = json.loads(args.config.read_text(encoding="utf-8"))
for item in cfg.get("repos", []):
if item.get("owner") and item.get("repo"):
targets.append((item["owner"], item["repo"]))
if args.slug:
targets.append(split_owner_repo(args.slug))
elif args.owner and args.repo:
targets.append((args.owner, args.repo))
if not targets:
print("错误:请用 --owner/--repo 或 --slug 或 --config 指定仓库。", file=sys.stderr)
return 2
client = GitLinkClient()
exit_code = 0
for owner, repo in targets:
print(f"[工作流] 处理 {owner}/{repo} ...", flush=True)
try:
result = run_flow(owner, repo, client=client, commit_pages=args.commit_pages)
except GitLinkError as exc:
print(f" 失败:{exc}", file=sys.stderr)
exit_code = 1
continue
if args.format == "json":
out = json.dumps(result, ensure_ascii=False, indent=2)
else:
out = result["step6_weekly_report"]
if args.output_dir:
args.output_dir.mkdir(parents=True, exist_ok=True)
ext = "json" if args.format == "json" else "md"
fp = args.output_dir / f"{owner}_{repo}_flow.{ext}"
fp.write_text(out, encoding="utf-8")
print(f" 已写入 {fp}", flush=True)
elif args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(out, encoding="utf-8")
print(f" 已写入 {args.output}", flush=True)
else:
print(out)
return exit_code
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,247 @@
"""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 请求,返回解析后的 JSONdict/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 releases(self, owner: str, repo: str, limit: int = 50,
page: int = 1) -> list[dict[str, Any]]:
"""版本发布列表。"""
data = self.get(f"{owner}/{repo}/releases.json", {"page": page, "limit": limit})
return _extract_list(data, ("releases",))
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}")

View File

@ -0,0 +1,98 @@
"""社区运营周报生成。
把工作流 6 个步骤的结果汇总成一份可直接发布到 Issue/Wiki 的社区运营周报
"""
from __future__ import annotations
from typing import Any
def render_weekly(result: dict[str, Any]) -> str:
"""渲染社区运营周报Markdown"""
owner, repo = result["owner"], result["repo"]
info = result["repo_info"]
triage = result["step1_triage"]
pr = result["step2_pr_review"]
rel = result["step3_release_notes"]
health = result["step4_health"]
contrib = result["step5_contributors"]
lines = [
f"# 社区运营周报 — {owner}/{repo}",
"",
f"生成时间:{result['generated_at']} | 工具gitlink-flow 端到端工作流",
"",
"> 本周报由 gitlink-flow 自动串联 Issue 分拣、PR Review、Release Notes、"
"社区健康体检、贡献者致谢等步骤生成,覆盖社区运营全链路。",
"",
"## 一、仓库概览",
"",
f"- Star {info.get('praises_count') or 0} / Fork {info.get('forked_count') or 0}"
f" / Issue {info.get('issues_count') or 0} / PR {info.get('pull_requests_count') or 0}",
f"- 数据采集:{result.get('data_source', 'gitlink-cli 命令')}",
"",
"## 二、Issue 自动分拣",
"",
f"{triage['total']} 个 Issue自动分类",
"",
]
for cat, n in triage["by_category"].items():
if n:
lines.append(f"- {cat}{n}")
lines.append("")
if triage["good_first_count"]:
lines.append(f"发现 **{triage['good_first_count']}** 个适合新人上手的任务,建议打 `good first issue` 标签:")
lines.append("")
for it in triage["items"]:
if it["good_first"]:
ref = f"#{it['id']}" if it["id"] else ""
lines.append(f"- {ref} {it['title']}")
lines.append("")
lines += [
"## 三、PR Review 汇总",
"",
f"{pr['total']} 个 PR开放 {pr['open']} / 已合并 {pr['merged']} / 已关闭 {pr['closed']}"
f"合并率 {pr['merge_rate']}%。",
"",
]
if pr["pending_review"]:
lines.append("待 Review 的 PR")
lines.append("")
for p in pr["pending_review"][:10]:
tag = "(来自 Fork" if p["is_fork"] else ""
ref = f"#{p['id']}" if p["id"] else ""
lines.append(f"- {ref} {p['title']} — @{p['author']} {tag}")
lines.append("")
lines += [
"## 四、社区健康体检",
"",
f"健康度评分:**{health['score']}/100**",
f"- 已具备:{(''.join(health['present'])) or ''}",
f"- 缺失:{(''.join(health['missing'])) or ''}",
"",
"## 五、贡献者致谢",
"",
f"{contrib['total_contributors']} 位贡献者,本周致谢榜前列:",
"",
]
for i, c in enumerate(contrib["top"], 1):
medal = {1: "🥇", 2: "🥈", 3: "🥉"}.get(i, f"{i}.")
lines.append(f"- {medal} {c['name']}{c['contributions']} 次贡献)")
lines.append("")
lines += [
"## 六、Release Notes自动生成",
"",
f"基于提交历史,版本 `{rel['version']}` 的变更摘要"
f"(规范化提交 {rel['typed_commits']}/{rel['total_commits']}",
"",
rel["markdown"] or "(暂无符合 conventional commits 规范的提交)",
"",
"---",
"",
"由 gitlink-flow 社区运营自动化工作流生成。所有数据来自 GitLink 平台,分析全程只读。",
]
return "\n".join(lines)

View File

@ -0,0 +1,207 @@
"""gitlink-flow 工作流步骤库。
每个函数是一个可独立测试的工作流步骤接收采集到的原始数据输出结构化结果
编排器 flow.py 按顺序调用这些步骤串联成端到端的社区运营自动化工作流
三个子工作流对标官方 examples/workflows 的参考场景
- triageIssue 自动分拣按关键词/标签分类识别新手友好任务
- pr_review_summaryPR Review 汇总统计 PR 状态识别待处理
- release_notesRelease Notes 生成从提交按 conventional commits 归类
另复用 5 个自研 Skill 的核心分析社区健康体检依赖巡检贡献者致谢知识库索引
全程只读不修改远程数据
"""
from __future__ import annotations
import re
from collections import Counter
from typing import Any
# ---------------------------------------------------------------------------
# 通用归一化
# ---------------------------------------------------------------------------
CONVENTIONAL_TYPES = {
"feat": "新功能", "fix": "缺陷修复", "docs": "文档", "refactor": "重构",
"perf": "性能", "test": "测试", "chore": "工程", "build": "构建",
"ci": "持续集成", "style": "风格", "revert": "回退",
}
GOOD_FIRST_HINTS = ["typo", "docs", "doc", "readme", "test", "translation", "example",
"文档", "注释", "翻译", "示例", "拼写"]
BUG_HINTS = ["bug", "error", "fail", "crash", "panic", "错误", "失败", "崩溃", "异常"]
FEATURE_HINTS = ["feature", "support", "add", "enhance", "新增", "支持", "功能", "建议"]
QUESTION_HINTS = ["how", "why", "question", "如何", "怎么", "为什么", "请问"]
def _commit_type(message: str) -> str:
m = re.match(r"^\s*([a-zA-Z]+)(?:\([^)]*\))?!?:", message or "")
if not m:
return "other"
t = m.group(1).lower()
return t if t in CONVENTIONAL_TYPES else "other"
# ---------------------------------------------------------------------------
# 子工作流 1Issue 自动分拣triage
# ---------------------------------------------------------------------------
def triage_issues(issues: list[dict[str, Any]]) -> dict[str, Any]:
"""对 Issue 按内容自动分类,并识别新手友好任务。
分类bug / feature / question / good-first / other
每个 Issue 给出建议标签供维护者打标参考
"""
buckets: dict[str, list[dict[str, Any]]] = {
"bug": [], "feature": [], "question": [], "good-first": [], "other": [],
}
results: list[dict[str, Any]] = []
for it in issues:
title = str(it.get("name") or it.get("subject") or it.get("title") or "")
body = str(it.get("description") or it.get("body") or "")
text = f"{title}\n{body}".lower()
category = "other"
if any(h in text for h in BUG_HINTS):
category = "bug"
elif any(h in text for h in FEATURE_HINTS):
category = "feature"
elif any(h in text for h in QUESTION_HINTS):
category = "question"
good_first = any(h in text for h in GOOD_FIRST_HINTS) and len(body) < 800
bucket_key = "good-first" if good_first else category
rec = {
"id": str(it.get("id") or ""),
"title": title[:60],
"category": category,
"good_first": good_first,
"suggested_label": "good first issue" if good_first else category,
}
buckets.setdefault(bucket_key, []).append(rec)
results.append(rec)
return {
"total": len(issues),
"by_category": {k: len(v) for k, v in buckets.items()},
"good_first_count": len(buckets["good-first"]),
"items": results,
}
# ---------------------------------------------------------------------------
# 子工作流 2PR Review 汇总pr-review
# ---------------------------------------------------------------------------
def pr_review_summary(pulls: list[dict[str, Any]]) -> dict[str, Any]:
"""汇总 PR 状态,识别待 Review 的 PR。
GitLink pull_request_status0=open, 1=merged, 2=closed
"""
open_prs: list[dict[str, Any]] = []
merged = closed = 0
for p in pulls:
status = p.get("pull_request_status")
title = str(p.get("name") or p.get("title") or "")
author = p.get("author_login") or p.get("author_name") or "unknown"
if status == 1:
merged += 1
elif status == 2:
closed += 1
else:
open_prs.append({
"id": str(p.get("pull_request_number") or p.get("id") or ""),
"title": title[:60],
"author": author,
"is_fork": bool(p.get("fork_project_user")),
})
return {
"total": len(pulls),
"open": len(open_prs),
"merged": merged,
"closed": closed,
"merge_rate": round(merged / len(pulls) * 100, 1) if pulls else 0.0,
"pending_review": open_prs,
}
# ---------------------------------------------------------------------------
# 子工作流 3Release Notes 生成release-notes
# ---------------------------------------------------------------------------
def release_notes(commits: list[dict[str, Any]], version: str = "Unreleased") -> dict[str, Any]:
"""从提交历史按 conventional commits 归类生成 Release Notes。"""
groups: dict[str, list[str]] = {}
typed = 0
for c in commits:
msg = (c.get("message") or "").splitlines()[0] if c.get("message") else ""
t = _commit_type(msg)
if t == "other":
continue
typed += 1
# 去掉类型前缀,保留描述
desc = re.sub(r"^\s*[a-zA-Z]+(?:\([^)]*\))?!?:\s*", "", msg).strip()
groups.setdefault(t, []).append(desc)
# 生成 Markdown
order = ["feat", "fix", "perf", "refactor", "docs", "test", "build", "ci", "chore"]
lines = [f"## {version}", ""]
for t in order:
if t in groups:
lines.append(f"### {CONVENTIONAL_TYPES[t]}{t}")
for d in groups[t][:20]:
lines.append(f"- {d}")
lines.append("")
return {
"version": version,
"typed_commits": typed,
"total_commits": len(commits),
"groups": {k: len(v) for k, v in groups.items()},
"markdown": "\n".join(lines).strip(),
}
# ---------------------------------------------------------------------------
# 复用 Skill社区健康体检scaffold 核心)
# ---------------------------------------------------------------------------
HEALTH_FILES = {
"README": ["readme.md", "readme.rst", "readme"],
"LICENSE": ["license", "license.md", "copying"],
"CONTRIBUTING": ["contributing.md", "contributing"],
"贡献准则": ["code_of_conduct.md"],
}
def health_check(root_files: list[str]) -> dict[str, Any]:
"""基于根目录文件名检测社区健康文件齐全度。"""
names = {f.lower() for f in root_files}
present, missing = [], []
for label, cands in HEALTH_FILES.items():
if any(c in names for c in cands):
present.append(label)
else:
missing.append(label)
score = round(len(present) / len(HEALTH_FILES) * 100)
return {"score": score, "present": present, "missing": missing}
# ---------------------------------------------------------------------------
# 复用 Skill贡献者致谢contributor 核心)
# ---------------------------------------------------------------------------
def contributor_highlights(contributors: list[dict[str, Any]], top: int = 5) -> dict[str, Any]:
"""提取贡献者亮点:总数、前 N 名。"""
profiles = sorted(
({"name": c.get("login") or c.get("name") or "unknown",
"contributions": int(c.get("contributions") or 0)} for c in contributors),
key=lambda x: x["contributions"], reverse=True,
)
total = sum(p["contributions"] for p in profiles)
return {
"total_contributors": len(profiles),
"total_contributions": total,
"top": profiles[:top],
}

View File

@ -0,0 +1,205 @@
"""gitlink-flow 单元测试。
覆盖三个子工作流triage/pr-review/release-notes与复用 Skill 步骤
以及编排器 run_flow使用合成数据 + FakeClient不触网
python -m pytest tests/ -q
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
import pytest
import steps
import report as report_mod
import cli
from flow import run_flow
# ---------------------------------------------------------------------------
# 子工作流 1triage
# ---------------------------------------------------------------------------
class TestTriage:
def test_classifies_bug(self):
r = steps.triage_issues([{"name": "登录时 crash 崩溃", "description": "复现步骤"}])
assert r["by_category"]["bug"] == 1
def test_classifies_feature(self):
r = steps.triage_issues([{"name": "建议新增搜索功能 feature", "description": ""}])
assert r["by_category"]["feature"] == 1
def test_good_first_detected(self):
r = steps.triage_issues([{"name": "fix typo in docs", "description": "small"}])
assert r["good_first_count"] == 1
assert r["items"][0]["suggested_label"] == "good first issue"
def test_empty(self):
r = steps.triage_issues([])
assert r["total"] == 0
assert r["good_first_count"] == 0
# ---------------------------------------------------------------------------
# 子工作流 2pr-review
# ---------------------------------------------------------------------------
class TestPRReview:
def test_status_counts(self):
pulls = [
{"name": "a", "pull_request_status": 0, "author_login": "u1"},
{"name": "b", "pull_request_status": 1, "author_login": "u2"},
{"name": "c", "pull_request_status": 2, "author_login": "u3"},
]
r = steps.pr_review_summary(pulls)
assert r["open"] == 1 and r["merged"] == 1 and r["closed"] == 1
assert r["merge_rate"] == round(1 / 3 * 100, 1)
def test_pending_review_listed(self):
pulls = [{"name": "open pr", "pull_request_status": 0,
"author_login": "dev", "fork_project_user": "dev"}]
r = steps.pr_review_summary(pulls)
assert len(r["pending_review"]) == 1
assert r["pending_review"][0]["is_fork"] is True
def test_empty(self):
r = steps.pr_review_summary([])
assert r["total"] == 0 and r["merge_rate"] == 0.0
# ---------------------------------------------------------------------------
# 子工作流 3release-notes
# ---------------------------------------------------------------------------
class TestReleaseNotes:
def test_groups_by_type(self):
commits = [
{"message": "feat: 新增登录"},
{"message": "fix: 修复崩溃"},
{"message": "feat(api): 新增接口"},
{"message": "随便写的提交"}, # other不计入
]
r = steps.release_notes(commits, version="v1.0")
assert r["groups"]["feat"] == 2
assert r["groups"]["fix"] == 1
assert r["typed_commits"] == 3
assert "## v1.0" in r["markdown"]
assert "新增登录" in r["markdown"]
def test_strips_prefix(self):
r = steps.release_notes([{"message": "fix(core): 修复空指针"}])
assert "修复空指针" in r["markdown"]
assert "fix(core):" not in r["markdown"]
def test_empty(self):
r = steps.release_notes([])
assert r["typed_commits"] == 0
# ---------------------------------------------------------------------------
# 复用 Skill 步骤
# ---------------------------------------------------------------------------
class TestHealthAndContributors:
def test_health_full(self):
files = ["readme.md", "license", "contributing.md", "code_of_conduct.md"]
r = steps.health_check(files)
assert r["score"] == 100
assert r["missing"] == []
def test_health_partial(self):
r = steps.health_check(["readme.md", "license"])
assert r["score"] == 50
assert "CONTRIBUTING" in r["missing"]
def test_contributors_sorted(self):
contribs = [
{"login": "a", "contributions": 10},
{"login": "b", "contributions": 50},
]
r = steps.contributor_highlights(contribs)
assert r["top"][0]["name"] == "b"
assert r["total_contributions"] == 60
# ---------------------------------------------------------------------------
# 编排器 + 周报
# ---------------------------------------------------------------------------
class FakeClient:
def repo_info(self, o, r):
return {"name": r, "issues_count": 2, "pull_requests_count": 1,
"praises_count": 3, "forked_count": 4}
def issues(self, o, r, limit=50):
return [{"id": "1", "name": "fix typo in docs", "description": "small"},
{"id": "2", "name": "登录 crash", "description": "bug"}]
def pulls(self, o, r, limit=50):
return [{"name": "feat: x", "pull_request_status": 0, "author_login": "dev"}]
def commits(self, o, r, max_pages=4):
return [{"message": "feat: 新功能"}, {"message": "fix: 修复"}]
def contributors(self, o, r):
return [{"login": "alice", "contributions": 100}]
def releases(self, o, r):
return [{"tag_name": "v1.0", "name": "v1.0"}]
def list_dir(self, o, r, path, ref):
return [{"name": "README.md"}, {"name": "LICENSE"}]
class TestOrchestrator:
def test_run_flow_all_steps(self):
result = run_flow("o", "repo", client=FakeClient(), use_cli=False)
assert "step1_triage" in result
assert "step2_pr_review" in result
assert "step3_release_notes" in result
assert "step4_health" in result
assert "step5_contributors" in result
assert "step6_weekly_report" in result
def test_weekly_report_renders(self):
result = run_flow("o", "repo", client=FakeClient(), use_cli=False)
md = result["step6_weekly_report"]
assert "社区运营周报" in md
assert "Issue 自动分拣" in md
assert "PR Review 汇总" in md
assert "Release Notes" in md
def test_triage_in_flow(self):
result = run_flow("o", "repo", client=FakeClient(), use_cli=False)
# 一个 good-firstdocs typo+ 一个 bug
assert result["step1_triage"]["good_first_count"] == 1
def test_fallback_data_source(self):
# 强制走 glapi 直连时,数据源应标注已回退
result = run_flow("o", "repo", client=FakeClient(), use_cli=False)
assert "回退" in result["data_source"]
class TestCliLayer:
"""gitlink-cli 封装层:解包信封与列表提取,不实际调用命令。"""
def test_extract_list_from_dict(self):
assert cli._extract_list({"issues": [1, 2]}, ("issues",)) == [1, 2]
def test_extract_list_passthrough(self):
assert cli._extract_list([1, 2], ("issues",)) == [1, 2]
def test_extract_list_empty(self):
assert cli._extract_list({"other": 1}, ("issues",)) == []
def test_cli_available_returns_bool(self):
assert isinstance(cli.cli_available(), bool)
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))