feat(workflows): 新增 gitlink-flow 社区运营自动化端到端工作流 #130
|
|
@ -0,0 +1,15 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# 运行时缓存与产物
|
||||
.cache/
|
||||
outputs/
|
||||
|
||||
# 系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
|
@ -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.
|
||||
|
|
@ -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/<owner>_<repo>_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 主仓库保持一致。
|
||||
|
|
@ -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` 的客户端接口可替换为其他平台实现。
|
||||
|
|
@ -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(写操作,请先确认)。
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# 验证记录
|
||||
|
||||
## 环境
|
||||
|
||||
- 操作系统:Windows,PowerShell
|
||||
- Python:3.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/100(README、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 平台公开接口实时采集,未做任何人工修改;分析全程只读,不向远程写入。
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"description": "gitlink-flow 社区运营自动化工作流配置。可列多个仓库批量运行。",
|
||||
"output_dir": "outputs",
|
||||
"repos": [
|
||||
{ "owner": "Gitlink", "repo": "gitlink-cli" }
|
||||
]
|
||||
}
|
||||
|
|
@ -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,自动分类:
|
||||
|
||||
- bug:2 个
|
||||
- feature:1 个
|
||||
- good-first:5 个
|
||||
- other:10 个
|
||||
|
||||
发现 **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 位贡献者,本周致谢榜前列:
|
||||
|
||||
- 🥇 wbtiger(127 次贡献)
|
||||
- 🥈 wangyue789(83 次贡献)
|
||||
- 🥉 Mengz(59 次贡献)
|
||||
- 4. whale(35 次贡献)
|
||||
- 5. wauxing(31 次贡献)
|
||||
|
||||
## 六、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 平台,分析全程只读。
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
# gitlink-flow 端到端工作流依赖
|
||||
# 采集与编排仅用 Python 标准库;可选绘图用 matplotlib
|
||||
matplotlib>=3.8
|
||||
pytest>=7.0
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"""gitlink-cli 命令封装层。
|
||||
|
||||
本模块把 `gitlink-cli` 的子命令(issue +list、pr +list、repo +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",))
|
||||
|
|
@ -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_dir)gitlink-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())
|
||||
|
|
@ -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 请求,返回解析后的 JSON(dict/list)或 None。"""
|
||||
url = f"{self.base}/{path.lstrip('/')}"
|
||||
if query:
|
||||
url = f"{url}?{urllib.parse.urlencode(query)}"
|
||||
|
||||
cache_path = self._cache_path(url)
|
||||
if cache_path and cache_path.exists():
|
||||
return json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
|
||||
headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise GitLinkError(f"HTTP {exc.code}: {url}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise GitLinkError(f"网络错误: {url} -> {exc.reason}") from exc
|
||||
|
||||
text = raw.strip()
|
||||
if not text or text in ("null", "{}", "[]"):
|
||||
data: Any = None
|
||||
elif text[0] in "{[":
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise GitLinkError(f"响应非 JSON: {url}") from exc
|
||||
else:
|
||||
raise GitLinkError(f"响应非 JSON(可能是 HTML): {url}")
|
||||
|
||||
if cache_path is not None:
|
||||
cache_path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 资源访问(高层封装)
|
||||
# ------------------------------------------------------------------
|
||||
def repo_info(self, owner: str, repo: str) -> dict[str, Any]:
|
||||
"""仓库元信息。"""
|
||||
data = self.get(f"{owner}/{repo}.json")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def issues(self, owner: str, repo: str, limit: int = 50,
|
||||
page: int = 1) -> list[dict[str, Any]]:
|
||||
"""Issue 列表。"""
|
||||
data = self.get(f"{owner}/{repo}/issues.json", {"page": page, "limit": limit})
|
||||
return _extract_list(data, ("issues",))
|
||||
|
||||
def issue_detail(self, owner: str, repo: str, number: int) -> dict[str, Any]:
|
||||
"""单个 Issue 详情(含完整字段)。"""
|
||||
data = self.get(f"{owner}/{repo}/issues/{number}.json")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def pulls(self, owner: str, repo: str, limit: int = 50,
|
||||
page: int = 1) -> list[dict[str, Any]]:
|
||||
"""PR 列表。"""
|
||||
data = self.get(f"{owner}/{repo}/pulls.json", {"page": page, "limit": limit})
|
||||
return _extract_list(data, ("issues", "pulls"))
|
||||
|
||||
def contributors(self, owner: str, repo: str) -> list[dict[str, Any]]:
|
||||
"""贡献者列表。"""
|
||||
data = self.get(f"{owner}/{repo}/contributors.json")
|
||||
return _extract_list(data, ("list",))
|
||||
|
||||
def commits(self, owner: str, repo: str, max_pages: int = 4) -> list[dict[str, Any]]:
|
||||
"""提交列表(按需翻页,每页 50 条,以 total_count 为终止依据)。"""
|
||||
out: list[dict[str, Any]] = []
|
||||
total: int | None = None
|
||||
for page in range(1, max(1, max_pages) + 1):
|
||||
data = self.get(f"{owner}/{repo}/commits.json",
|
||||
{"page": page, "limit": COMMIT_PAGE_SIZE})
|
||||
if total is None and isinstance(data, dict):
|
||||
total = _safe_int(data.get("total_count")) or None
|
||||
page_items = _extract_list(data, ("commits",))
|
||||
if not page_items:
|
||||
break
|
||||
out.extend(page_items)
|
||||
if total is not None and len(out) >= total:
|
||||
break
|
||||
return out
|
||||
|
||||
def list_dir(self, owner: str, repo: str, path: str = "",
|
||||
ref: str = "master") -> list[dict[str, Any]]:
|
||||
"""列出目录下的条目(文件与子目录)。
|
||||
|
||||
返回的每个 entry 含 name / path / type(file|dir) / sha / size,
|
||||
文件类型的 entry 还可能直接带明文 content。
|
||||
"""
|
||||
data = self.get(f"{owner}/{repo}/sub_entries.json",
|
||||
{"filepath": path, "ref": ref})
|
||||
# 查询目录时 entries 为 list;查询单文件时 entries 为单个 dict。
|
||||
# 统一归一化为 list,便于下游处理。
|
||||
if isinstance(data, dict):
|
||||
entries = data.get("entries")
|
||||
if isinstance(entries, dict):
|
||||
return [entries]
|
||||
if isinstance(entries, list):
|
||||
return entries
|
||||
return _extract_list(data, ("entries",))
|
||||
|
||||
def file_content(self, owner: str, repo: str, filepath: str,
|
||||
ref: str = "master") -> str | None:
|
||||
"""读取单个文件的文本内容。
|
||||
|
||||
GitLink 的 sub_entries 接口对单文件查询会在 entries 中返回明文 content,
|
||||
据此取出。文件不存在或无内容时返回 None。
|
||||
"""
|
||||
entries = self.list_dir(owner, repo, filepath, ref)
|
||||
target = filepath.rsplit("/", 1)[-1]
|
||||
for entry in entries:
|
||||
if entry.get("type") == "file" and entry.get("name") == target:
|
||||
content = entry.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
# 回退:部分情况下单文件查询 entries 仅一项
|
||||
if len(entries) == 1 and entries[0].get("type") == "file":
|
||||
content = entries[0].get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return None
|
||||
|
||||
def 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}")
|
||||
|
|
@ -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)
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
"""gitlink-flow 工作流步骤库。
|
||||
|
||||
每个函数是一个可独立测试的工作流步骤,接收采集到的原始数据,输出结构化结果。
|
||||
编排器 flow.py 按顺序调用这些步骤,串联成端到端的社区运营自动化工作流。
|
||||
|
||||
三个子工作流对标官方 examples/workflows 的参考场景:
|
||||
- triage:Issue 自动分拣(按关键词/标签分类,识别新手友好任务)
|
||||
- pr_review_summary:PR Review 汇总(统计 PR 状态、识别待处理)
|
||||
- release_notes:Release 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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子工作流 1:Issue 自动分拣(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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子工作流 2:PR Review 汇总(pr-review)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def pr_review_summary(pulls: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""汇总 PR 状态,识别待 Review 的 PR。
|
||||
|
||||
GitLink pull_request_status:0=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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子工作流 3:Release 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],
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子工作流 1:triage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子工作流 2:pr-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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子工作流 3:release-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-first(docs 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"]))
|
||||
Loading…
Reference in New Issue