From b6682a20097f38bb55fdddad489001a48101baf9 Mon Sep 17 00:00:00 2001 From: Ct201314 <1195214305@qq.com> Date: Sat, 6 Jun 2026 14:47:05 +0800 Subject: [PATCH 1/3] feat(workflows): add gitlink-flow community ops automation workflow --- examples/workflows/gitlink-flow/.gitignore | 15 ++ examples/workflows/gitlink-flow/LICENSE | 64 +++++ examples/workflows/gitlink-flow/README.md | 108 ++++++++ .../gitlink-flow/docs/architecture.md | 75 ++++++ .../workflows/gitlink-flow/docs/quickstart.md | 68 +++++ .../gitlink-flow/docs/verification.md | 59 +++++ .../gitlink-flow/examples/config.json | 7 + .../demo_outputs/Gitlink_gitlink-cli_flow.md | 147 +++++++++++ .../workflows/gitlink-flow/requirements.txt | 4 + .../gitlink-flow/scripts/run_demo.ps1 | 38 +++ examples/workflows/gitlink-flow/src/flow.py | 142 ++++++++++ examples/workflows/gitlink-flow/src/glapi.py | 247 ++++++++++++++++++ examples/workflows/gitlink-flow/src/report.py | 97 +++++++ examples/workflows/gitlink-flow/src/steps.py | 207 +++++++++++++++ .../workflows/gitlink-flow/tests/test_flow.py | 183 +++++++++++++ 15 files changed, 1461 insertions(+) create mode 100644 examples/workflows/gitlink-flow/.gitignore create mode 100644 examples/workflows/gitlink-flow/LICENSE create mode 100644 examples/workflows/gitlink-flow/README.md create mode 100644 examples/workflows/gitlink-flow/docs/architecture.md create mode 100644 examples/workflows/gitlink-flow/docs/quickstart.md create mode 100644 examples/workflows/gitlink-flow/docs/verification.md create mode 100644 examples/workflows/gitlink-flow/examples/config.json create mode 100644 examples/workflows/gitlink-flow/examples/demo_outputs/Gitlink_gitlink-cli_flow.md create mode 100644 examples/workflows/gitlink-flow/requirements.txt create mode 100644 examples/workflows/gitlink-flow/scripts/run_demo.ps1 create mode 100644 examples/workflows/gitlink-flow/src/flow.py create mode 100644 examples/workflows/gitlink-flow/src/glapi.py create mode 100644 examples/workflows/gitlink-flow/src/report.py create mode 100644 examples/workflows/gitlink-flow/src/steps.py create mode 100644 examples/workflows/gitlink-flow/tests/test_flow.py 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..062fdf0 --- /dev/null +++ b/examples/workflows/gitlink-flow/README.md @@ -0,0 +1,108 @@ +# gitlink-flow:社区运营自动化端到端工作流 + +面向 GitLink 智能化服务开源项目贡献赛 **子赛题三(构建端到端自动化工作流)** 的参赛作品。 + +把开源社区运营中分散的日常工作——Issue 分拣、PR Review 跟进、Release Notes 整理、社区健康检查、贡献者致谢——**串联成一条可一键运行的自动化流水线**,对任意真实 GitLink 仓库生成一份完整的「社区运营周报」。 + +作者:Ct201314 + +## 这是什么 + +子赛题三要求「组合 gitlink-cli 已有命令和 Skills,完成一个可复现的端到端自动化场景,串联 ≥3 个调用」。本作品串联 **6 个步骤**,复用了我在子赛题二完成的 **5 个自研 Skill** 的能力,远超 3 个的要求: + +``` + gitlink-flow 端到端工作流 +┌──────────────────────────────────────────────────────────────┐ +│ │ +│ 采集(GitLink 公开 API,只读) │ +│ 仓库信息 / Issue / PR / 提交 / 贡献者 / 版本 / 文件树 │ +│ │ │ +│ ▼ │ +│ ① Issue 自动分拣 ────────── bug/feature/question/新手友好分类 │ +│ │ (对标官方 triage 参考工作流) │ +│ ▼ │ +│ ② PR Review 汇总 ───────── 开放/合并/关闭统计 + 待 Review 清单 │ +│ │ (对标官方 PR Review 参考工作流) │ +│ ▼ │ +│ ③ Release Notes 生成 ───── 按 conventional commits 归类 │ +│ │ (对标官方 Release Notes 参考) │ +│ ▼ │ +│ ④ 社区健康体检 ─────────── 复用 gitlink-scaffold 能力 │ +│ │ │ +│ ▼ │ +│ ⑤ 贡献者致谢 ───────────── 复用 gitlink-contributor 能力 │ +│ │ │ +│ ▼ │ +│ ⑥ 社区运营周报 ─────────── 汇总以上全部为一份 Markdown 周报 │ +│ │ +└──────────────────────────────────────────────────────────────┘ + │ + ▼ + outputs/__flow.md(周报) + 或 --format json(供 Agent 消费) +``` + +其中 ①②③ 三个子工作流**精准对标官方 `examples/workflows/` 列出的三个参考场景**(Issue 自动分拣、PR Review、Release Notes 生成),④⑤ 复用自研 Skill,⑥ 汇总输出。 + +## 与子赛题二的区别 + +子赛题二交付的是**独立可复用的 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 客户端(采集层) +│ ├── 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..cd2a29e --- /dev/null +++ b/examples/workflows/gitlink-flow/examples/demo_outputs/Gitlink_gitlink-cli_flow.md @@ -0,0 +1,147 @@ +# 社区运营周报 — Gitlink/gitlink-cli + +生成时间:2026-06-06 14:40 | 工具:gitlink-flow 端到端工作流 + +> 本周报由 gitlink-flow 自动串联 Issue 分拣、PR Review、Release Notes、社区健康体检、贡献者致谢等步骤生成,覆盖社区运营全链路。 + +## 一、仓库概览 + +- Star 5 / Fork 25 / Issue 17 / PR 129 + +## 二、Issue 自动分拣 + +共 17 个 Issue,自动分类: + +- bug:1 个 +- 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:开放 20 / 已合并 0 / 已关闭 0,合并率 0.0%。 + +待 Review 的 PR: + +- #129 feat(skills): 新增 gitlink-kb 知识库问答 Skill — @Ct201314 (来自 Fork) +- #128 feat(skills): 新增 gitlink-contributor 贡献者致谢与成长 Skill — @Ct201314 (来自 Fork) +- #127 feat(skills): 新增 gitlink-deps 依赖追踪 Skill — @Ct201314 (来自 Fork) +- #126 feat(skills): 新增 gitlink-scaffold 社区健康文件体检 Skill — @Ct201314 (来自 Fork) +- #125 feat(skills): 新增 gitlink-newcomer 新人引导 Skill — @Ct201314 (来自 Fork) +- #124 新增 Raw API 批处理执行器 — @Mengz (来自 Fork) +- #123 新增 Release 资产下载命令 — @Mengz (来自 Fork) +- #122 完善仓库 README 快捷命令 — @Mengz (来自 Fork) +- #121 feat(shortcut): add shortcuts/license — @co63oc (来自 Fork) +- #120 fix(health): use effective list filters — @wangyue111 (来自 Fork) + +## 四、社区健康体检 + +健康度评分:**50/100** +- 已具备:README、LICENSE +- 缺失:CONTRIBUTING、贡献准则 + +## 五、贡献者致谢 + +共 20 位贡献者,本周致谢榜前列: + +- 🥇 wbtiger(112 次贡献) +- 🥈 wangyue789(44 次贡献) +- 🥉 wauxing(31 次贡献) +- 4. Mengz(26 次贡献) +- 5. whale(24 次贡献) + +## 六、Release Notes(自动生成) + +基于提交历史,版本 `v0.1.18` 的变更摘要(规范化提交 70/127): + +## v0.1.18 + +### 新功能(feat) +- remove per-issue detail API, add tag tables and persistence +- rewrite gitlink-health as a Go shortcut +- 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 +- add milestone, compare, pr reopen shortcuts and api enhancements (#45) +- add PR closed time and repo README shortcuts (#49, #52) +- add repository member shortcuts (#46) +- add readme shortcut +- add repository member shortcuts +- add authors shortcut +- add assigners shortcut +- add community ops automation example +- add review shortcuts +- add skills gitlink-release-auto + +### 缺陷修复(fix) +- address code review issues 3-6,8 +- 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 +- improve input mode detection and error hint +- report batch-add partial failures +- improve missing binary diagnostics +- URL-encode branch name in unprotect, add +unprotect to Skill +- Issue 操作切换到 v1 API,统一使用 project_issues_index 替代数据库 ID +- 统一使用 pull_request_number 替代 pull_request_id +- preserve issue description on updates +- require Fork even for admin/owner, unless user explicitly says otherwise +- add tool boundary rules to prevent gh/hub misuse on GitLink + +### 性能(perf) +- parallel fetch with errgroup and global rate limiter + +### 重构(refactor) +- rename pr +close to pr +refuse + +### 文档(docs) +- add missing contributor puygob236 +- update contributors section with usernames and new contributors +- trim materials for official PR +- remove continuation file from PR draft +- update final submission checklist placeholders +- align workflow agent competition materials +- add competition submission materials +- add REFERENCE.md for all 3 Skills +- finalize webhook shortcut docs +- update README for v0.1.17 — branch skill, npm one-step install, 12 skills +- remove Gitea reference from branch skill +- 补充 Issue status_id 映射表(1=新增, 2=正在解决, 3=已解决, 5=关闭) +- add Fork-based PR workflow guidelines +- update license from Apache 2.0 to MulanPSL-2.0 +- translate README to English, add Chinese README.zh-CN.md + +### 测试(test) +- add comprehensive test coverage across all packages (88.5% → 90.3%) +- fix webhook endpoint expectations + +### 持续集成(ci) +- add local checks (make check, pre-commit hook) and Gitea Actions workflow skeleton +- add PR checks workflow (build, test, vet, fmt) +- add GitHub Release workflow with npm publish + +### 工程(chore) +- ensure trailing newline +- fix CI workflow, golangci-lint config, and minor lint/format issues +- add golangci-lint config and fix lint issues +- ignore local gitlink-cli binary + +--- + +由 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/flow.py b/examples/workflows/gitlink-flow/src/flow.py new file mode 100644 index 0000000..204c126 --- /dev/null +++ b/examples/workflows/gitlink-flow/src/flow.py @@ -0,0 +1,142 @@ +"""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 steps +import report as report_mod + + +def run_flow(owner: str, repo: str, client: GitLinkClient | None = None, + commit_pages: int = 4) -> dict[str, Any]: + """对单个仓库执行完整工作流,返回各步骤结果。""" + client = client or GitLinkClient() + + # ===== 采集阶段 ===== + info = client.repo_info(owner, repo) + issues = client.issues(owner, repo, limit=50) + pulls = client.pulls(owner, repo, limit=50) + commits = client.commits(owner, repo, max_pages=commit_pages) + contributors = client.contributors(owner, repo) + releases = client.releases(owner, repo) if hasattr(client, "releases") else [] + try: + root_entries = client.list_dir(owner, repo, "", "master") + root_files = [str(e.get("name", "")) for e in root_entries] + except GitLinkError: + 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"), + "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..db23981 --- /dev/null +++ b/examples/workflows/gitlink-flow/src/report.py @@ -0,0 +1,97 @@ +"""社区运营周报生成。 + +把工作流 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}", + "", + "## 二、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..d844646 --- /dev/null +++ b/examples/workflows/gitlink-flow/tests/test_flow.py @@ -0,0 +1,183 @@ +"""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 +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()) + 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()) + 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()) + # 一个 good-first(docs typo)+ 一个 bug + assert result["step1_triage"]["good_first_count"] == 1 + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) From ad470f15bab89f673ba96615206f16132a02b18f Mon Sep 17 00:00:00 2001 From: Ct201314 <1195214305@qq.com> Date: Fri, 12 Jun 2026 18:16:24 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(workflow):=20gitlink-flow=20=E9=87=87?= =?UTF-8?q?=E9=9B=86=E4=B8=BB=E8=B0=83=E7=94=A8=E9=93=BE=E6=94=B9=E7=94=A8?= =?UTF-8?q?=20gitlink-cli=20=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 依审阅意见整改:cli.py 封装 gitlink-cli 命令为主调用链,glapi 作为 fallback;README 流程图改 mermaid;周报标注数据来源。 --- examples/workflows/gitlink-flow/README.md | 44 +--- .../gitlink-flow/docs/docs/architecture.md | 75 ++++++ .../gitlink-flow/docs/docs/quickstart.md | 68 +++++ .../gitlink-flow/docs/docs/verification.md | 59 +++++ .../examples/examples/config.json | 7 + .../demo_outputs/Gitlink_gitlink-cli_flow.md | 138 ++++++++++ .../workflows/gitlink-flow/src/src/cli.py | 96 +++++++ .../workflows/gitlink-flow/src/src/flow.py | 189 ++++++++++++++ .../workflows/gitlink-flow/src/src/glapi.py | 247 ++++++++++++++++++ .../workflows/gitlink-flow/src/src/report.py | 98 +++++++ .../workflows/gitlink-flow/src/src/steps.py | 207 +++++++++++++++ .../gitlink-flow/tests/tests/test_flow.py | 205 +++++++++++++++ 12 files changed, 1402 insertions(+), 31 deletions(-) create mode 100644 examples/workflows/gitlink-flow/docs/docs/architecture.md create mode 100644 examples/workflows/gitlink-flow/docs/docs/quickstart.md create mode 100644 examples/workflows/gitlink-flow/docs/docs/verification.md create mode 100644 examples/workflows/gitlink-flow/examples/examples/config.json create mode 100644 examples/workflows/gitlink-flow/examples/examples/demo_outputs/Gitlink_gitlink-cli_flow.md create mode 100644 examples/workflows/gitlink-flow/src/src/cli.py create mode 100644 examples/workflows/gitlink-flow/src/src/flow.py create mode 100644 examples/workflows/gitlink-flow/src/src/glapi.py create mode 100644 examples/workflows/gitlink-flow/src/src/report.py create mode 100644 examples/workflows/gitlink-flow/src/src/steps.py create mode 100644 examples/workflows/gitlink-flow/tests/tests/test_flow.py diff --git a/examples/workflows/gitlink-flow/README.md b/examples/workflows/gitlink-flow/README.md index 062fdf0..0552529 100644 --- a/examples/workflows/gitlink-flow/README.md +++ b/examples/workflows/gitlink-flow/README.md @@ -10,40 +10,21 @@ 子赛题三要求「组合 gitlink-cli 已有命令和 Skills,完成一个可复现的端到端自动化场景,串联 ≥3 个调用」。本作品串联 **6 个步骤**,复用了我在子赛题二完成的 **5 个自研 Skill** 的能力,远超 3 个的要求: -``` - gitlink-flow 端到端工作流 -┌──────────────────────────────────────────────────────────────┐ -│ │ -│ 采集(GitLink 公开 API,只读) │ -│ 仓库信息 / Issue / PR / 提交 / 贡献者 / 版本 / 文件树 │ -│ │ │ -│ ▼ │ -│ ① Issue 自动分拣 ────────── bug/feature/question/新手友好分类 │ -│ │ (对标官方 triage 参考工作流) │ -│ ▼ │ -│ ② PR Review 汇总 ───────── 开放/合并/关闭统计 + 待 Review 清单 │ -│ │ (对标官方 PR Review 参考工作流) │ -│ ▼ │ -│ ③ Release Notes 生成 ───── 按 conventional commits 归类 │ -│ │ (对标官方 Release Notes 参考) │ -│ ▼ │ -│ ④ 社区健康体检 ─────────── 复用 gitlink-scaffold 能力 │ -│ │ │ -│ ▼ │ -│ ⑤ 贡献者致谢 ───────────── 复用 gitlink-contributor 能力 │ -│ │ │ -│ ▼ │ -│ ⑥ 社区运营周报 ─────────── 汇总以上全部为一份 Markdown 周报 │ -│ │ -└──────────────────────────────────────────────────────────────┘ - │ - ▼ - outputs/__flow.md(周报) - 或 --format json(供 Agent 消费) +```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**;本作品交付的是**串联多个步骤的完整解决方案**——它把这些能力编排成一条端到端流水线,一条命令产出社区运营全景。 @@ -72,7 +53,8 @@ python src/flow.py --config examples/config.json # 批量多仓库 ``` gitlink-flow/ ├── src/ -│ ├── glapi.py GitLink 公开 API 客户端(采集层) +│ ├── glapi.py GitLink 公开 API 客户端(直连 fallback) +│ ├── cli.py gitlink-cli 命令封装(主调用链) │ ├── steps.py 工作流步骤库(三个子工作流 + 复用 Skill 能力) │ ├── flow.py 编排器主入口(串联 6 步) │ └── report.py 社区运营周报生成 diff --git a/examples/workflows/gitlink-flow/docs/docs/architecture.md b/examples/workflows/gitlink-flow/docs/docs/architecture.md new file mode 100644 index 0000000..f7187f5 --- /dev/null +++ b/examples/workflows/gitlink-flow/docs/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/docs/quickstart.md b/examples/workflows/gitlink-flow/docs/docs/quickstart.md new file mode 100644 index 0000000..7e512a9 --- /dev/null +++ b/examples/workflows/gitlink-flow/docs/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/docs/verification.md b/examples/workflows/gitlink-flow/docs/docs/verification.md new file mode 100644 index 0000000..d803982 --- /dev/null +++ b/examples/workflows/gitlink-flow/docs/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/examples/config.json b/examples/workflows/gitlink-flow/examples/examples/config.json new file mode 100644 index 0000000..07bde8b --- /dev/null +++ b/examples/workflows/gitlink-flow/examples/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/examples/demo_outputs/Gitlink_gitlink-cli_flow.md b/examples/workflows/gitlink-flow/examples/examples/demo_outputs/Gitlink_gitlink-cli_flow.md new file mode 100644 index 0000000..aa4dff1 --- /dev/null +++ b/examples/workflows/gitlink-flow/examples/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/src/src/cli.py b/examples/workflows/gitlink-flow/src/src/cli.py new file mode 100644 index 0000000..ccd1ccf --- /dev/null +++ b/examples/workflows/gitlink-flow/src/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/src/flow.py b/examples/workflows/gitlink-flow/src/src/flow.py new file mode 100644 index 0000000..0e5f87b --- /dev/null +++ b/examples/workflows/gitlink-flow/src/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/src/glapi.py b/examples/workflows/gitlink-flow/src/src/glapi.py new file mode 100644 index 0000000..f41c718 --- /dev/null +++ b/examples/workflows/gitlink-flow/src/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/src/report.py b/examples/workflows/gitlink-flow/src/src/report.py new file mode 100644 index 0000000..234e4e3 --- /dev/null +++ b/examples/workflows/gitlink-flow/src/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/src/steps.py b/examples/workflows/gitlink-flow/src/src/steps.py new file mode 100644 index 0000000..5b04ecd --- /dev/null +++ b/examples/workflows/gitlink-flow/src/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/tests/test_flow.py b/examples/workflows/gitlink-flow/tests/tests/test_flow.py new file mode 100644 index 0000000..95c6d68 --- /dev/null +++ b/examples/workflows/gitlink-flow/tests/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"])) From e6c92973cb6f868bbdcc5bed62ffc9e942f48826 Mon Sep 17 00:00:00 2001 From: Ct201314 <1195214305@qq.com> Date: Fri, 12 Jun 2026 18:17:25 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(workflow):=20=E4=BF=AE=E6=AD=A3=20gitli?= =?UTF-8?q?nk-flow=20=E7=9B=AE=E5=BD=95=E7=BB=93=E6=9E=84=EF=BC=88?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E8=AF=AF=E5=B5=8C=E5=A5=97=E7=9A=84=20src/sr?= =?UTF-8?q?c=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gitlink-flow/docs/docs/architecture.md | 75 ------ .../gitlink-flow/docs/docs/quickstart.md | 68 ----- .../gitlink-flow/docs/docs/verification.md | 59 ----- .../demo_outputs/Gitlink_gitlink-cli_flow.md | 105 ++++---- .../examples/examples/config.json | 7 - .../demo_outputs/Gitlink_gitlink-cli_flow.md | 138 ---------- .../gitlink-flow/src/{src => }/cli.py | 0 examples/workflows/gitlink-flow/src/flow.py | 65 ++++- examples/workflows/gitlink-flow/src/report.py | 1 + .../workflows/gitlink-flow/src/src/flow.py | 189 -------------- .../workflows/gitlink-flow/src/src/glapi.py | 247 ------------------ .../workflows/gitlink-flow/src/src/report.py | 98 ------- .../workflows/gitlink-flow/src/src/steps.py | 207 --------------- .../workflows/gitlink-flow/tests/test_flow.py | 28 +- .../gitlink-flow/tests/tests/test_flow.py | 205 --------------- 15 files changed, 130 insertions(+), 1362 deletions(-) delete mode 100644 examples/workflows/gitlink-flow/docs/docs/architecture.md delete mode 100644 examples/workflows/gitlink-flow/docs/docs/quickstart.md delete mode 100644 examples/workflows/gitlink-flow/docs/docs/verification.md delete mode 100644 examples/workflows/gitlink-flow/examples/examples/config.json delete mode 100644 examples/workflows/gitlink-flow/examples/examples/demo_outputs/Gitlink_gitlink-cli_flow.md rename examples/workflows/gitlink-flow/src/{src => }/cli.py (100%) delete mode 100644 examples/workflows/gitlink-flow/src/src/flow.py delete mode 100644 examples/workflows/gitlink-flow/src/src/glapi.py delete mode 100644 examples/workflows/gitlink-flow/src/src/report.py delete mode 100644 examples/workflows/gitlink-flow/src/src/steps.py delete mode 100644 examples/workflows/gitlink-flow/tests/tests/test_flow.py diff --git a/examples/workflows/gitlink-flow/docs/docs/architecture.md b/examples/workflows/gitlink-flow/docs/docs/architecture.md deleted file mode 100644 index f7187f5..0000000 --- a/examples/workflows/gitlink-flow/docs/docs/architecture.md +++ /dev/null @@ -1,75 +0,0 @@ -# 架构说明 - -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/docs/quickstart.md b/examples/workflows/gitlink-flow/docs/docs/quickstart.md deleted file mode 100644 index 7e512a9..0000000 --- a/examples/workflows/gitlink-flow/docs/docs/quickstart.md +++ /dev/null @@ -1,68 +0,0 @@ -# 快速开始 - -## 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/docs/verification.md b/examples/workflows/gitlink-flow/docs/docs/verification.md deleted file mode 100644 index d803982..0000000 --- a/examples/workflows/gitlink-flow/docs/docs/verification.md +++ /dev/null @@ -1,59 +0,0 @@ -# 验证记录 - -## 环境 - -- 操作系统: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/demo_outputs/Gitlink_gitlink-cli_flow.md b/examples/workflows/gitlink-flow/examples/demo_outputs/Gitlink_gitlink-cli_flow.md index cd2a29e..aa4dff1 100644 --- 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 @@ -1,18 +1,19 @@ # 社区运营周报 — Gitlink/gitlink-cli -生成时间:2026-06-06 14:40 | 工具:gitlink-flow 端到端工作流 +生成时间:2026-06-12 18:12 | 工具:gitlink-flow 端到端工作流 > 本周报由 gitlink-flow 自动串联 Issue 分拣、PR Review、Release Notes、社区健康体检、贡献者致谢等步骤生成,覆盖社区运营全链路。 ## 一、仓库概览 -- Star 5 / Fork 25 / Issue 17 / PR 129 +- Star 5 / Fork 29 / Issue 18 / PR 232 +- 数据采集:gitlink-cli 命令 ## 二、Issue 自动分拣 -共 17 个 Issue,自动分类: +共 18 个 Issue,自动分类: -- bug:1 个 +- bug:2 个 - feature:1 个 - good-first:5 个 - other:10 个 @@ -27,20 +28,20 @@ ## 三、PR Review 汇总 -共 20 个 PR:开放 20 / 已合并 0 / 已关闭 0,合并率 0.0%。 +共 20 个 PR:开放 19 / 已合并 0 / 已关闭 1,合并率 0.0%。 待 Review 的 PR: -- #129 feat(skills): 新增 gitlink-kb 知识库问答 Skill — @Ct201314 (来自 Fork) -- #128 feat(skills): 新增 gitlink-contributor 贡献者致谢与成长 Skill — @Ct201314 (来自 Fork) -- #127 feat(skills): 新增 gitlink-deps 依赖追踪 Skill — @Ct201314 (来自 Fork) -- #126 feat(skills): 新增 gitlink-scaffold 社区健康文件体检 Skill — @Ct201314 (来自 Fork) -- #125 feat(skills): 新增 gitlink-newcomer 新人引导 Skill — @Ct201314 (来自 Fork) -- #124 新增 Raw API 批处理执行器 — @Mengz (来自 Fork) -- #123 新增 Release 资产下载命令 — @Mengz (来自 Fork) -- #122 完善仓库 README 快捷命令 — @Mengz (来自 Fork) -- #121 feat(shortcut): add shortcuts/license — @co63oc (来自 Fork) -- #120 fix(health): use effective list filters — @wangyue111 (来自 Fork) +- #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) ## 四、社区健康体检 @@ -52,21 +53,31 @@ 共 20 位贡献者,本周致谢榜前列: -- 🥇 wbtiger(112 次贡献) -- 🥈 wangyue789(44 次贡献) -- 🥉 wauxing(31 次贡献) -- 4. Mengz(26 次贡献) -- 5. whale(24 次贡献) +- 🥇 wbtiger(127 次贡献) +- 🥈 wangyue789(83 次贡献) +- 🥉 Mengz(59 次贡献) +- 4. whale(35 次贡献) +- 5. wauxing(31 次贡献) ## 六、Release Notes(自动生成) -基于提交历史,版本 `v0.1.18` 的变更摘要(规范化提交 70/127): +基于提交历史,版本 `v0.2.0` 的变更摘要(规范化提交 57/100): -## v0.1.18 +## 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 @@ -75,34 +86,19 @@ - add OpenAPI shortcuts - add reopen shortcut - support body input files -- add milestone, compare, pr reopen shortcuts and api enhancements (#45) -- add PR closed time and repo README shortcuts (#49, #52) -- add repository member shortcuts (#46) -- add readme shortcut -- add repository member shortcuts -- add authors shortcut -- add assigners shortcut -- add community ops automation example -- add review shortcuts -- add skills gitlink-release-auto ### 缺陷修复(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 -- improve input mode detection and error hint -- report batch-add partial failures -- improve missing binary diagnostics -- URL-encode branch name in unprotect, add +unprotect to Skill -- Issue 操作切换到 v1 API,统一使用 project_issues_index 替代数据库 ID -- 统一使用 pull_request_number 替代 pull_request_id -- preserve issue description on updates -- require Fork even for admin/owner, unless user explicitly says otherwise -- add tool boundary rules to prevent gh/hub misuse on GitLink ### 性能(perf) - parallel fetch with errgroup and global rate limiter @@ -111,36 +107,31 @@ - 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 -- trim materials for official PR -- remove continuation file from PR draft -- update final submission checklist placeholders -- align workflow agent competition materials -- add competition submission materials -- add REFERENCE.md for all 3 Skills -- finalize webhook shortcut docs -- update README for v0.1.17 — branch skill, npm one-step install, 12 skills -- remove Gitea reference from branch skill -- 补充 Issue status_id 映射表(1=新增, 2=正在解决, 3=已解决, 5=关闭) -- add Fork-based PR workflow guidelines -- update license from Apache 2.0 to MulanPSL-2.0 -- translate README to English, add Chinese README.zh-CN.md +- fix skills badge repository link ### 测试(test) - add comprehensive test coverage across all packages (88.5% → 90.3%) -- fix webhook endpoint expectations ### 持续集成(ci) - add local checks (make check, pre-commit hook) and Gitea Actions workflow skeleton - add PR checks workflow (build, test, vet, fmt) -- add GitHub Release workflow with npm publish ### 工程(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 -- ignore local gitlink-cli binary --- diff --git a/examples/workflows/gitlink-flow/examples/examples/config.json b/examples/workflows/gitlink-flow/examples/examples/config.json deleted file mode 100644 index 07bde8b..0000000 --- a/examples/workflows/gitlink-flow/examples/examples/config.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "description": "gitlink-flow 社区运营自动化工作流配置。可列多个仓库批量运行。", - "output_dir": "outputs", - "repos": [ - { "owner": "Gitlink", "repo": "gitlink-cli" } - ] -} diff --git a/examples/workflows/gitlink-flow/examples/examples/demo_outputs/Gitlink_gitlink-cli_flow.md b/examples/workflows/gitlink-flow/examples/examples/demo_outputs/Gitlink_gitlink-cli_flow.md deleted file mode 100644 index aa4dff1..0000000 --- a/examples/workflows/gitlink-flow/examples/examples/demo_outputs/Gitlink_gitlink-cli_flow.md +++ /dev/null @@ -1,138 +0,0 @@ -# 社区运营周报 — 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/src/src/cli.py b/examples/workflows/gitlink-flow/src/cli.py similarity index 100% rename from examples/workflows/gitlink-flow/src/src/cli.py rename to examples/workflows/gitlink-flow/src/cli.py diff --git a/examples/workflows/gitlink-flow/src/flow.py b/examples/workflows/gitlink-flow/src/flow.py index 204c126..0e5f87b 100644 --- a/examples/workflows/gitlink-flow/src/flow.py +++ b/examples/workflows/gitlink-flow/src/flow.py @@ -30,28 +30,74 @@ 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 run_flow(owner: str, repo: str, client: GitLinkClient | None = None, - commit_pages: int = 4) -> dict[str, Any]: - """对单个仓库执行完整工作流,返回各步骤结果。""" - client = client or GitLinkClient() +def collect(owner: str, repo: str, client: GitLinkClient, + commit_pages: int = 4, use_cli: bool | None = None) -> dict[str, Any]: + """采集阶段:优先走 gitlink-cli 命令(主调用链),失败回退直连 API。 - # ===== 采集阶段 ===== - info = client.repo_info(owner, repo) - issues = client.issues(owner, repo, limit=50) - pulls = client.pulls(owner, repo, limit=50) + 赛题要求工作流组合 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) - releases = client.releases(owner, repo) if hasattr(client, "releases") else [] 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: @@ -61,6 +107,7 @@ def run_flow(owner: str, repo: str, client: GitLinkClient | None = None, "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"), diff --git a/examples/workflows/gitlink-flow/src/report.py b/examples/workflows/gitlink-flow/src/report.py index db23981..234e4e3 100644 --- a/examples/workflows/gitlink-flow/src/report.py +++ b/examples/workflows/gitlink-flow/src/report.py @@ -30,6 +30,7 @@ def render_weekly(result: dict[str, Any]) -> str: "", 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 自动分拣", "", diff --git a/examples/workflows/gitlink-flow/src/src/flow.py b/examples/workflows/gitlink-flow/src/src/flow.py deleted file mode 100644 index 0e5f87b..0000000 --- a/examples/workflows/gitlink-flow/src/src/flow.py +++ /dev/null @@ -1,189 +0,0 @@ -"""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/src/glapi.py b/examples/workflows/gitlink-flow/src/src/glapi.py deleted file mode 100644 index f41c718..0000000 --- a/examples/workflows/gitlink-flow/src/src/glapi.py +++ /dev/null @@ -1,247 +0,0 @@ -"""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/src/report.py b/examples/workflows/gitlink-flow/src/src/report.py deleted file mode 100644 index 234e4e3..0000000 --- a/examples/workflows/gitlink-flow/src/src/report.py +++ /dev/null @@ -1,98 +0,0 @@ -"""社区运营周报生成。 - -把工作流 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/src/steps.py b/examples/workflows/gitlink-flow/src/src/steps.py deleted file mode 100644 index 5b04ecd..0000000 --- a/examples/workflows/gitlink-flow/src/src/steps.py +++ /dev/null @@ -1,207 +0,0 @@ -"""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 index d844646..95c6d68 100644 --- a/examples/workflows/gitlink-flow/tests/test_flow.py +++ b/examples/workflows/gitlink-flow/tests/test_flow.py @@ -17,6 +17,7 @@ import pytest import steps import report as report_mod +import cli from flow import run_flow @@ -157,7 +158,7 @@ class FakeClient: class TestOrchestrator: def test_run_flow_all_steps(self): - result = run_flow("o", "repo", client=FakeClient()) + 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 @@ -166,7 +167,7 @@ class TestOrchestrator: assert "step6_weekly_report" in result def test_weekly_report_renders(self): - result = run_flow("o", "repo", client=FakeClient()) + result = run_flow("o", "repo", client=FakeClient(), use_cli=False) md = result["step6_weekly_report"] assert "社区运营周报" in md assert "Issue 自动分拣" in md @@ -174,10 +175,31 @@ class TestOrchestrator: assert "Release Notes" in md def test_triage_in_flow(self): - result = run_flow("o", "repo", client=FakeClient()) + 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"])) diff --git a/examples/workflows/gitlink-flow/tests/tests/test_flow.py b/examples/workflows/gitlink-flow/tests/tests/test_flow.py deleted file mode 100644 index 95c6d68..0000000 --- a/examples/workflows/gitlink-flow/tests/tests/test_flow.py +++ /dev/null @@ -1,205 +0,0 @@ -"""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"]))