diff --git a/examples/workflows/gitlink-flow/.gitignore b/examples/workflows/gitlink-flow/.gitignore
new file mode 100644
index 0000000..688b744
--- /dev/null
+++ b/examples/workflows/gitlink-flow/.gitignore
@@ -0,0 +1,15 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.pytest_cache/
+.venv/
+venv/
+
+# 运行时缓存与产物
+.cache/
+outputs/
+
+# 系统
+.DS_Store
+Thumbs.db
diff --git a/examples/workflows/gitlink-flow/LICENSE b/examples/workflows/gitlink-flow/LICENSE
new file mode 100644
index 0000000..5d20abd
--- /dev/null
+++ b/examples/workflows/gitlink-flow/LICENSE
@@ -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.
diff --git a/examples/workflows/gitlink-flow/README.md b/examples/workflows/gitlink-flow/README.md
new file mode 100644
index 0000000..0552529
--- /dev/null
+++ b/examples/workflows/gitlink-flow/README.md
@@ -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 命令
repo +info / issue +list / pr +list / release +list
未装 CLI 时回退 glapi 直连)"] --> B["① Issue 自动分拣
bug / feature / question / 新手友好分类"]
+ B --> C["② PR Review 汇总
开放 / 合并 / 关闭统计 + 待 Review 清单"]
+ C --> D["③ Release Notes 生成
按 conventional commits 归类"]
+ D --> E["④ 社区健康体检
复用 gitlink-scaffold 能力"]
+ E --> F["⑤ 贡献者致谢
复用 gitlink-contributor 能力"]
+ F --> G["⑥ 社区运营周报
汇总以上全部为一份 Markdown 周报"]
+ G --> H["outputs/<owner>_<repo>_flow.md(周报)
或 --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 -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/__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 主仓库保持一致。
diff --git a/examples/workflows/gitlink-flow/docs/architecture.md b/examples/workflows/gitlink-flow/docs/architecture.md
new file mode 100644
index 0000000..f7187f5
--- /dev/null
+++ b/examples/workflows/gitlink-flow/docs/architecture.md
@@ -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/__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` 的客户端接口可替换为其他平台实现。
diff --git a/examples/workflows/gitlink-flow/docs/quickstart.md b/examples/workflows/gitlink-flow/docs/quickstart.md
new file mode 100644
index 0000000..7e512a9
--- /dev/null
+++ b/examples/workflows/gitlink-flow/docs/quickstart.md
@@ -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 -Repo
+```
+
+或直接用 Python:
+
+```bash
+python src/flow.py --owner --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(写操作,请先确认)。
diff --git a/examples/workflows/gitlink-flow/docs/verification.md b/examples/workflows/gitlink-flow/docs/verification.md
new file mode 100644
index 0000000..d803982
--- /dev/null
+++ b/examples/workflows/gitlink-flow/docs/verification.md
@@ -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 平台公开接口实时采集,未做任何人工修改;分析全程只读,不向远程写入。
diff --git a/examples/workflows/gitlink-flow/examples/config.json b/examples/workflows/gitlink-flow/examples/config.json
new file mode 100644
index 0000000..07bde8b
--- /dev/null
+++ b/examples/workflows/gitlink-flow/examples/config.json
@@ -0,0 +1,7 @@
+{
+ "description": "gitlink-flow 社区运营自动化工作流配置。可列多个仓库批量运行。",
+ "output_dir": "outputs",
+ "repos": [
+ { "owner": "Gitlink", "repo": "gitlink-cli" }
+ ]
+}
diff --git a/examples/workflows/gitlink-flow/examples/demo_outputs/Gitlink_gitlink-cli_flow.md b/examples/workflows/gitlink-flow/examples/demo_outputs/Gitlink_gitlink-cli_flow.md
new file mode 100644
index 0000000..aa4dff1
--- /dev/null
+++ b/examples/workflows/gitlink-flow/examples/demo_outputs/Gitlink_gitlink-cli_flow.md
@@ -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 平台,分析全程只读。
\ No newline at end of file
diff --git a/examples/workflows/gitlink-flow/requirements.txt b/examples/workflows/gitlink-flow/requirements.txt
new file mode 100644
index 0000000..a5da13b
--- /dev/null
+++ b/examples/workflows/gitlink-flow/requirements.txt
@@ -0,0 +1,4 @@
+# gitlink-flow 端到端工作流依赖
+# 采集与编排仅用 Python 标准库;可选绘图用 matplotlib
+matplotlib>=3.8
+pytest>=7.0
diff --git a/examples/workflows/gitlink-flow/scripts/run_demo.ps1 b/examples/workflows/gitlink-flow/scripts/run_demo.ps1
new file mode 100644
index 0000000..7a36dd2
--- /dev/null
+++ b/examples/workflows/gitlink-flow/scripts/run_demo.ps1
@@ -0,0 +1,38 @@
+# gitlink-flow 一键复现脚本
+#
+# 运行社区运营自动化端到端工作流,对真实 GitLink 仓库生成社区运营周报。
+#
+# 用法:
+# .\scripts\run_demo.ps1 # 默认 Gitlink/gitlink-cli
+# .\scripts\run_demo.ps1 -Owner -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
diff --git a/examples/workflows/gitlink-flow/src/cli.py b/examples/workflows/gitlink-flow/src/cli.py
new file mode 100644
index 0000000..ccd1ccf
--- /dev/null
+++ b/examples/workflows/gitlink-flow/src/cli.py
@@ -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",))
diff --git a/examples/workflows/gitlink-flow/src/flow.py b/examples/workflows/gitlink-flow/src/flow.py
new file mode 100644
index 0000000..0e5f87b
--- /dev/null
+++ b/examples/workflows/gitlink-flow/src/flow.py
@@ -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())
diff --git a/examples/workflows/gitlink-flow/src/glapi.py b/examples/workflows/gitlink-flow/src/glapi.py
new file mode 100644
index 0000000..f41c718
--- /dev/null
+++ b/examples/workflows/gitlink-flow/src/glapi.py
@@ -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}")
diff --git a/examples/workflows/gitlink-flow/src/report.py b/examples/workflows/gitlink-flow/src/report.py
new file mode 100644
index 0000000..234e4e3
--- /dev/null
+++ b/examples/workflows/gitlink-flow/src/report.py
@@ -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)
diff --git a/examples/workflows/gitlink-flow/src/steps.py b/examples/workflows/gitlink-flow/src/steps.py
new file mode 100644
index 0000000..5b04ecd
--- /dev/null
+++ b/examples/workflows/gitlink-flow/src/steps.py
@@ -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],
+ }
diff --git a/examples/workflows/gitlink-flow/tests/test_flow.py b/examples/workflows/gitlink-flow/tests/test_flow.py
new file mode 100644
index 0000000..95c6d68
--- /dev/null
+++ b/examples/workflows/gitlink-flow/tests/test_flow.py
@@ -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"]))