From 9ebed898ac66b9de8e697457c21573b6d03c79f5 Mon Sep 17 00:00:00 2001 From: ljc0426 Date: Sun, 24 May 2026 14:34:47 +0800 Subject: [PATCH] feat(workflows): add community ops automation example --- .../community-ops-automation/.gitignore | 8 + .../community-ops-automation/LICENSE | 17 + .../community-ops-automation/README.md | 53 ++ .../docs/architecture.md | 26 + .../docs/assets/architecture-workflow-v2.svg | 5 + .../docs/demo-output.md | 32 + .../docs/quickstart.md | 34 + .../community-ops-automation/docs/runbook.md | 54 ++ .../docs/submission-checklist.md | 28 + .../docs/upload-to-gitlink.md | 30 + .../docs/verification.md | 67 ++ .../examples/demo_active_config.json | 6 + .../Gitlink_gitlink-cli_release_notes.md | 18 + .../Gitlink_gitlink-cli_report.md | 32 + .../examples/demo_outputs/README.md | 10 + .../puygob236_gitlink-cli_release_notes.md | 14 + .../puygob236_gitlink-cli_report.md | 26 + .../examples/sample_config.json | 6 + .../scripts/gitlink_workflow.py | 814 ++++++++++++++++++ .../scripts/run_demo.ps1 | 45 + .../tests/test_gitlink_workflow.py | 133 +++ 21 files changed, 1458 insertions(+) create mode 100644 examples/workflows/community-ops-automation/.gitignore create mode 100644 examples/workflows/community-ops-automation/LICENSE create mode 100644 examples/workflows/community-ops-automation/README.md create mode 100644 examples/workflows/community-ops-automation/docs/architecture.md create mode 100644 examples/workflows/community-ops-automation/docs/assets/architecture-workflow-v2.svg create mode 100644 examples/workflows/community-ops-automation/docs/demo-output.md create mode 100644 examples/workflows/community-ops-automation/docs/quickstart.md create mode 100644 examples/workflows/community-ops-automation/docs/runbook.md create mode 100644 examples/workflows/community-ops-automation/docs/submission-checklist.md create mode 100644 examples/workflows/community-ops-automation/docs/upload-to-gitlink.md create mode 100644 examples/workflows/community-ops-automation/docs/verification.md create mode 100644 examples/workflows/community-ops-automation/examples/demo_active_config.json create mode 100644 examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md create mode 100644 examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_report.md create mode 100644 examples/workflows/community-ops-automation/examples/demo_outputs/README.md create mode 100644 examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_release_notes.md create mode 100644 examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_report.md create mode 100644 examples/workflows/community-ops-automation/examples/sample_config.json create mode 100644 examples/workflows/community-ops-automation/scripts/gitlink_workflow.py create mode 100644 examples/workflows/community-ops-automation/scripts/run_demo.ps1 create mode 100644 examples/workflows/community-ops-automation/tests/test_gitlink_workflow.py diff --git a/examples/workflows/community-ops-automation/.gitignore b/examples/workflows/community-ops-automation/.gitignore new file mode 100644 index 0000000..7d2b3d8 --- /dev/null +++ b/examples/workflows/community-ops-automation/.gitignore @@ -0,0 +1,8 @@ +outputs/ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +*.log +*.tmp +*.swp diff --git a/examples/workflows/community-ops-automation/LICENSE b/examples/workflows/community-ops-automation/LICENSE new file mode 100644 index 0000000..d1ce275 --- /dev/null +++ b/examples/workflows/community-ops-automation/LICENSE @@ -0,0 +1,17 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +Copyright 2026 GitLink Workflow Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/examples/workflows/community-ops-automation/README.md b/examples/workflows/community-ops-automation/README.md new file mode 100644 index 0000000..181e5cd --- /dev/null +++ b/examples/workflows/community-ops-automation/README.md @@ -0,0 +1,53 @@ +# GitLink 构建端到端自动化工作流 + +面向 GitLink 竞赛子赛题三的端到端自动化工作流项目。 + +本项目面向开源社区运营场景,使用 `gitlink-cli` 串联仓库信息、Issue、PR 和 Release 数据采集,自动生成社区周报、Release Notes 草稿和结构化摘要,并支持将摘要发布到指定 GitLink Issue。该流程覆盖“数据采集 -> 指标分析 -> 文档生成 -> 结果发布”的完整闭环。 + +## 交付物 + +- `scripts/gitlink_workflow.py`:主工作流入口 +- `scripts/run_demo.ps1`:一键复现脚本 +- `docs/architecture.md`:架构图与流程说明 +- `docs/quickstart.md`:最短复现路径 +- `docs/runbook.md`:运行手册 +- `docs/verification.md`:真实仓库验证记录 +- `docs/submission-checklist.md`:参赛提交核对清单 +- `docs/upload-to-gitlink.md`:仓库目录结构说明 +- `examples/sample_config.json`:参赛仓库配置 +- `examples/demo_active_config.json`:公开仓库验证配置 +- `examples/demo_outputs/`:真实运行示例产物 +- `tests/test_gitlink_workflow.py`:单测 +- `LICENSE`:Apache 2.0 + +## 运行方式 + +推荐直接运行一键脚本: + +```powershell +.\scripts\run_demo.ps1 +``` + +切换到参赛仓库配置: + +```powershell +.\scripts\run_demo.ps1 -Config examples\sample_config.json +``` + +## 输出 + +- `outputs/*_report.md` +- `outputs/*_release_notes.md` +- `outputs/*_summary.json` + +## 已验证仓库 + +- `puygob236/gitlink-cli`:完成仓库信息、Issue、PR、Release 采集,并完成 Issue 摘要回写验证 +- `Gitlink/gitlink-cli`:完成仓库信息、Issue、PR、Release 采集,并生成包含有效统计数据的周报、Release Notes 和结构化摘要 + +## 项目定位 + +- 满足子赛题三“端到端自动化工作流”的要求 +- 串联 4 个数据采集命令和 1 个结果发布命令 +- 支持在真实 GitLink 项目上复现 +- 提供运行脚本、验证记录、示例产物和单元测试 diff --git a/examples/workflows/community-ops-automation/docs/architecture.md b/examples/workflows/community-ops-automation/docs/architecture.md new file mode 100644 index 0000000..a6264ed --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/architecture.md @@ -0,0 +1,26 @@ +# 架构说明 + +本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。 + +![GitLink 社区运营端到端自动化工作流架构](assets/architecture-workflow-v2.svg) + +## 设计目标 + +- 低门槛:只依赖 `gitlink-cli` 和 Python 标准库 +- 可复现:同一配置可重复跑出同类报告 +- 可维护:采集、归一化、分析、生成和发布步骤保持清晰边界 +- 可验证:报告文件、结构化摘要和 Issue 评论均可作为运行结果核验依据 + +## 为什么选这个链路 + +子赛题三要求使用现有命令或 Skill 组合形成完整解决方案。本方案覆盖: + +1. 仓库信息采集 +2. Issue 列表采集 +3. PR 列表采集 +4. Release 列表采集 +5. 报告生成 +6. Issue 摘要发布 + +该链路满足不少于 3 个 CLI 调用的要求,并形成从数据获取到结果发布的端到端闭环。 + diff --git a/examples/workflows/community-ops-automation/docs/assets/architecture-workflow-v2.svg b/examples/workflows/community-ops-automation/docs/assets/architecture-workflow-v2.svg new file mode 100644 index 0000000..8cf5f29 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/assets/architecture-workflow-v2.svg @@ -0,0 +1,5 @@ + + GitLink Community Operations Automation Architecture + Publication-style architecture figure for the GitLink community operations automation workflow. + + \ No newline at end of file diff --git a/examples/workflows/community-ops-automation/docs/demo-output.md b/examples/workflows/community-ops-automation/docs/demo-output.md new file mode 100644 index 0000000..c977a30 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/demo-output.md @@ -0,0 +1,32 @@ +# 示例输出摘要 + +## 验证目标 + +`Gitlink/gitlink-cli` + +## 运行命令 + +```powershell +.\scripts\run_demo.ps1 +``` + +## 关键结果 + +- Issues: 15 +- PR: 20 +- Release: 11 +- 输出文件: + - `outputs/Gitlink_gitlink-cli_20260520_140525_report.md` + - `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md` + - `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json` + +## 仓库内示例产物 + +- `examples/demo_outputs/Gitlink_gitlink-cli_report.md` +- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md` +- `examples/demo_outputs/puygob236_gitlink-cli_report.md` +- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md` + +## 额外验证 + +`puygob236/gitlink-cli` 已完成仓库信息、Issue、PR 和 Release 采集验证,并完成摘要回写到 Issue 的发布验证。 diff --git a/examples/workflows/community-ops-automation/docs/quickstart.md b/examples/workflows/community-ops-automation/docs/quickstart.md new file mode 100644 index 0000000..07d9a43 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/quickstart.md @@ -0,0 +1,34 @@ +# 快速开始 + +## 一键运行 + +直接运行一键脚本: + +```powershell +.\scripts\run_demo.ps1 +``` + +脚本会自动通过 `npm exec` 找到 `@gitlink-ai/cli`,把 `gitlink-cli` 放到临时 PATH 里,再执行: + +- 仓库信息采集 +- Issue 列表采集 +- PR 列表采集 +- Release 列表采集 +- 周报生成 +- Release Notes 草稿生成 + +## 配置切换 + +- `examples/demo_active_config.json`:公开仓库验证配置,默认指向 `Gitlink/gitlink-cli` +- `examples/sample_config.json`:参赛仓库验证配置,默认指向 `puygob236/gitlink-cli` + +## 输出 + +- `outputs/*_report.md` +- `outputs/*_release_notes.md` +- `outputs/*_summary.json` + +## 已验证事实 + +- `puygob236/gitlink-cli` 已完成采集、报告生成和 Issue 摘要回写验证 +- `Gitlink/gitlink-cli` 可生成带统计内容的周报和 Release Notes diff --git a/examples/workflows/community-ops-automation/docs/runbook.md b/examples/workflows/community-ops-automation/docs/runbook.md new file mode 100644 index 0000000..86087a1 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/runbook.md @@ -0,0 +1,54 @@ +# 运行手册 + +## 前置条件 + +- 已安装 `gitlink-cli` +- 已完成 `gitlink-cli auth login` +- 目标仓库有可读权限 + +官方快速开始里要求的验证命令是: + +```powershell +gitlink-cli user +me +``` + +## 运行方式 + +### 1. 只生成报告 + +```powershell +python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json +``` + +### 2. 生成报告并发布摘要 + +```powershell +python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json --publish-issue-id 123 +``` + +### 3. 一键复现 + +```powershell +.\scripts\run_demo.ps1 +``` + +## 输出文件 + +- `outputs/*_report.md`:完整周报 +- `outputs/*_release_notes.md`:Release Notes 草稿 +- `outputs/*_summary.json`:结构化摘要 + +## 验证清单 + +- `repo +info` 能返回仓库信息 +- `issue +list` 能返回 Issue 列表 +- `pr +list` 能返回 PR 列表 +- `release +list` 能返回 Release 列表 +- 报告文件能落盘 +- Release Notes 草稿能落盘 +- 发布模式能把摘要写回指定 Issue + +## 真实项目配置 + +- `examples/demo_active_config.json` 指向 `Gitlink/gitlink-cli`,用于验证活跃公开仓库的数据分析能力。 +- `examples/sample_config.json` 指向 `puygob236/gitlink-cli`,用于验证参赛仓库的采集和 Issue 回写能力。 diff --git a/examples/workflows/community-ops-automation/docs/submission-checklist.md b/examples/workflows/community-ops-automation/docs/submission-checklist.md new file mode 100644 index 0000000..9041b70 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/submission-checklist.md @@ -0,0 +1,28 @@ +# 提交核对清单 + +## 官方交付要求映射 + +| 要求 | 本项目对应内容 | +| --- | --- | +| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/gitlink_workflow.py` 串联 `repo +info`、`issue +list`、`pr +list`、`release +list`,并支持 `issue +comment` 发布摘要 | +| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` | +| 在至少一个真实 GitLink 项目上运行并展示效果 | `docs/verification.md`、`docs/demo-output.md`、`examples/demo_outputs/` | +| 提供工作流说明文档 | `README.md`、`docs/quickstart.md`、`docs/runbook.md` | +| 提供架构图 | `docs/architecture.md` 引用 `docs/assets/architecture-workflow-v2.svg` | +| 代码开源并托管到 GitLink | `https://gitlink.org.cn/puygob236/gitlink-cli` 的 `examples/workflows/community-ops-automation/` | +| 提供完整中文 README | `README.md` | +| 开源协议 | `LICENSE`,Apache 2.0 | + +## 验证状态 + +- `python -m py_compile .\scripts\gitlink_workflow.py .\tests\test_gitlink_workflow.py`:通过 +- `python -m unittest discover -s tests`:通过 +- `.\scripts\run_demo.ps1`:已在 `Gitlink/gitlink-cli` 上跑通 +- `.\scripts\run_demo.ps1 -Config examples\sample_config.json`:已在 `puygob236/gitlink-cli` 上跑通 +- `.\scripts\run_demo.ps1 -Config examples\sample_config.json -PublishIssueId 2`:已完成 Issue 摘要回写验证 + +## 交付内容 + +- `README.md`、`docs/`、`scripts/`、`examples/`、`tests/`、`LICENSE` 均位于 `examples/workflows/community-ops-automation/`。 +- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。 +- `examples/demo_outputs/` 提供固定示例产物,便于快速查看报告格式和输出内容。 diff --git a/examples/workflows/community-ops-automation/docs/upload-to-gitlink.md b/examples/workflows/community-ops-automation/docs/upload-to-gitlink.md new file mode 100644 index 0000000..9be839b --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/upload-to-gitlink.md @@ -0,0 +1,30 @@ +# GitLink 仓库目录结构 + +本作品以 `gitlink-cli` 工作流示例的形式托管在 GitLink 仓库中,目录与主项目源码保持隔离,避免改变主仓库既有命令、Skill 和设计文档结构。 + +## 作品路径 + +```text +examples/workflows/community-ops-automation/ +``` + +## 目录内容 + +- `README.md`:项目说明与复现入口 +- `LICENSE`:Apache 2.0 开源协议 +- `.gitignore`:运行时产物忽略规则 +- `docs/`:架构、运行、验证和交付说明 +- `examples/`:配置文件和示例输出 +- `scripts/`:工作流执行脚本 +- `tests/`:单元测试 + +## 仓库内验证 + +进入作品目录后运行: + +```powershell +python -m unittest discover -s tests +.\scripts\run_demo.ps1 +``` + +生成的 `outputs/` 是运行时目录;固定示例产物位于 `examples/demo_outputs/`。 diff --git a/examples/workflows/community-ops-automation/docs/verification.md b/examples/workflows/community-ops-automation/docs/verification.md new file mode 100644 index 0000000..38613c2 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/verification.md @@ -0,0 +1,67 @@ +# 验证记录 + +## 环境 + +- Windows PowerShell +- Python 3 +- `@gitlink-ai/cli` 0.1.13 + +## 已验证的真实仓库 + +### `puygob236/gitlink-cli` + +- `repo +info` 可访问 +- `issue +list` 可访问 +- `pr +list` 可访问 +- `release +list` 可访问 +- 已完成 Issue 摘要回写验证 + +### `Gitlink/gitlink-cli` + +- `repo +info` 可访问 +- `issue +list` 可访问 +- `pr +list` 可访问 +- `release +list` 可访问 +- 当前可提取到的统计结果: + - Issues: 15 + - PR: 20 + - Release: 11 + +## 本地输出 + +已生成的文件: + +- `outputs/Gitlink_gitlink-cli_20260515_040153_report.md` +- `outputs/Gitlink_gitlink-cli_20260515_040153_summary.json` +- `outputs/Gitlink_gitlink-cli_20260515_121523_report.md` +- `outputs/Gitlink_gitlink-cli_20260515_121523_release_notes.md` +- `outputs/Gitlink_gitlink-cli_20260515_121523_summary.json` +- `outputs/puygob236_gitlink-cli_20260515_121544_report.md` +- `outputs/puygob236_gitlink-cli_20260515_121544_release_notes.md` +- `outputs/puygob236_gitlink-cli_20260515_121544_summary.json` +- `outputs/puygob236_gitlink-cli_20260515_121845_report.md` +- `outputs/puygob236_gitlink-cli_20260515_121845_release_notes.md` +- `outputs/puygob236_gitlink-cli_20260515_121845_summary.json` +- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md` +- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md` +- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json` +- `outputs/puygob236_gitlink-cli_20260520_143224_report.md` +- `outputs/puygob236_gitlink-cli_20260520_143224_release_notes.md` +- `outputs/puygob236_gitlink-cli_20260520_143224_summary.json` + +其中 `20260520_140525` 对应公开仓库数据分析验证,`20260520_143224` 对应参赛仓库采集与 Issue 回写验证。 + +## 示例产物 + +`outputs/` 是运行时目录,仓库交付中同时提供了轻量示例: + +- `examples/demo_outputs/Gitlink_gitlink-cli_report.md` +- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md` +- `examples/demo_outputs/puygob236_gitlink-cli_report.md` +- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md` + +## 复现方式 + +```powershell +.\scripts\run_demo.ps1 +``` diff --git a/examples/workflows/community-ops-automation/examples/demo_active_config.json b/examples/workflows/community-ops-automation/examples/demo_active_config.json new file mode 100644 index 0000000..83074b7 --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_active_config.json @@ -0,0 +1,6 @@ +{ + "owner": "Gitlink", + "repo": "gitlink-cli", + "window_days": 7, + "output_dir": "outputs" +} diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md new file mode 100644 index 0000000..25e0f17 --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md @@ -0,0 +1,18 @@ +# gitlink-cli Release Notes 草稿 + +- 统计窗口:近 7 天 +- 生成时间:2026-05-20 14:05:25 UTC + +## 变更概览 +- 已合并 PR:8 个 +- 最近窗口内合并 PR:2 个 + +## 变更分类 +### feature +- feat(pr): add pr +comment shortcut (2026-05-14) + +### fix +- fix(npm): improve missing binary diagnostics (2026-05-19) + +## 发布说明 +- 存在 1 个超过 7 天未更新的开放 Issue,建议优先清理。 diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_report.md b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_report.md new file mode 100644 index 0000000..a357731 --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_report.md @@ -0,0 +1,32 @@ +# gitlink-cli 自动化周报 + +- 统计窗口:近 7 天 +- 生成时间:2026-05-20 14:05:25 UTC + +## 核心指标 + +| 指标 | 数值 | +| --- | ---: | +| Issues 总数 | 15 | +| 打开 Issues | 5 | +| 超窗 Issue | 1 | +| PR 总数 | 20 | +| 打开 PR | 5 | +| 已合并 PR | 8 | +| Release 数 | 11 | + +## 热点标签 +- 无 + +## 最近合并 PR +### fix +- fix(npm): improve missing binary diagnostics (2026-05-19) +### feature +- feat(pr): add pr +comment shortcut (2026-05-14) + +## 风险提示 +### 超窗 Issue +- 2 gitlink-cli 使用讨论与反馈收集 (open) 2026-04-18 + +### 建议动作 +- 存在 1 个超过 7 天未更新的开放 Issue,建议优先清理。 diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/README.md b/examples/workflows/community-ops-automation/examples/demo_outputs/README.md new file mode 100644 index 0000000..27884af --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_outputs/README.md @@ -0,0 +1,10 @@ +# 示例输出说明 + +本目录保存一次真实 GitLink 项目的演示输出,便于评审在不重新运行脚本时快速查看效果。 + +- `Gitlink_gitlink-cli_report.md`:活跃官方仓库周报示例 +- `Gitlink_gitlink-cli_release_notes.md`:活跃官方仓库 Release Notes 草稿示例 +- `puygob236_gitlink-cli_report.md`:参赛 fork 连通性周报示例 +- `puygob236_gitlink-cli_release_notes.md`:参赛 fork Release Notes 草稿示例 + +完整结构化摘要会在运行脚本后生成到 `outputs/*_summary.json`。 diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_release_notes.md b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_release_notes.md new file mode 100644 index 0000000..d10b9af --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_release_notes.md @@ -0,0 +1,14 @@ +# gitlink-cli Release Notes 草稿 + +- 统计窗口:近 7 天 +- 生成时间:2026-05-20 14:32:24 UTC + +## 变更概览 +- 已合并 PR:0 个 +- 最近窗口内合并 PR:0 个 + +## 变更分类 +- 无 + +## 发布说明 +- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。 diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_report.md b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_report.md new file mode 100644 index 0000000..3dbbce4 --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_report.md @@ -0,0 +1,26 @@ +# gitlink-cli 自动化周报 + +- 统计窗口:近 7 天 +- 生成时间:2026-05-20 14:32:24 UTC + +## 核心指标 + +| 指标 | 数值 | +| --- | ---: | +| Issues 总数 | 2 | +| 打开 Issues | 2 | +| 超窗 Issue | 0 | +| PR 总数 | 0 | +| 打开 PR | 0 | +| 已合并 PR | 0 | +| Release 数 | 0 | + +## 热点标签 +- 无 + +## 最近合并 PR +- 无 + +## 风险提示 +### 建议动作 +- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。 diff --git a/examples/workflows/community-ops-automation/examples/sample_config.json b/examples/workflows/community-ops-automation/examples/sample_config.json new file mode 100644 index 0000000..eaf94d4 --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/sample_config.json @@ -0,0 +1,6 @@ +{ + "owner": "puygob236", + "repo": "gitlink-cli", + "window_days": 7, + "output_dir": "outputs" +} diff --git a/examples/workflows/community-ops-automation/scripts/gitlink_workflow.py b/examples/workflows/community-ops-automation/scripts/gitlink_workflow.py new file mode 100644 index 0000000..d27be60 --- /dev/null +++ b/examples/workflows/community-ops-automation/scripts/gitlink_workflow.py @@ -0,0 +1,814 @@ +from __future__ import annotations + +import argparse +import json +import os +import subprocess +from collections import Counter, defaultdict +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterable + + +class WorkflowError(RuntimeError): + pass + + +CLI_PAGE_SIZE = 100 + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="GitLink 社区运营自动化工作流:周报 + Release Notes + 风险提示" + ) + parser.add_argument( + "--config", + type=Path, + default=Path("examples/sample_config.json"), + help="配置文件路径", + ) + parser.add_argument("--owner", help="覆盖配置中的仓库所有者") + parser.add_argument("--repo", help="覆盖配置中的仓库名称") + parser.add_argument( + "--window-days", + type=int, + help="统计窗口,默认从配置文件读取或使用 7 天", + ) + parser.add_argument( + "--output-dir", + type=Path, + help="输出目录,默认从配置文件读取或使用 outputs", + ) + parser.add_argument( + "--publish-issue-id", + type=int, + help="发布摘要到指定 Issue 评论,未提供则只生成本地报告", + ) + parser.add_argument( + "--now", + help="固定当前时间,便于测试,格式为 ISO8601", + ) + parser.add_argument( + "--skip-releases", + action="store_true", + help="跳过 release 列表采集", + ) + parser.add_argument( + "--cli-bin", + help="gitlink-cli 可执行文件路径;可配合 GITLINK_CLI_BIN 使用", + ) + return parser.parse_args(argv) + + +def load_json_file(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def sanitize_repo_name(value: str) -> str: + return value.replace("/", "_").replace("\\", "_") + + +def parse_datetime(value: Any) -> datetime | None: + if value in (None, "", []): + return None + if isinstance(value, datetime): + dt = value + else: + text = str(value).strip() + if not text: + return None + text = text.replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def parse_iso_now(value: str | None) -> datetime: + if not value: + return datetime.now(timezone.utc) + dt = parse_datetime(value) + if dt is None: + raise WorkflowError(f"无法解析 --now 的值: {value}") + return dt + + +def first_value(item: dict[str, Any], keys: Iterable[str], default: Any = None) -> Any: + for key in keys: + if key in item: + value = item[key] + if value not in (None, "", []): + return value + return default + + +def normalize_labels(value: Any) -> list[str]: + labels: list[str] = [] + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + name = first_value(item, ("name", "title", "label_name")) + if name: + labels.append(str(name)) + elif item not in (None, ""): + labels.append(str(item)) + elif isinstance(value, str) and value: + labels.append(value) + return labels + + +def extract_first_list(payload: Any, keys: Iterable[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 + for value in payload.values(): + found = extract_first_list(value, keys) + if found: + return found + return [] + + +def extract_first_dict(payload: Any, keys: Iterable[str]) -> dict[str, Any]: + if isinstance(payload, dict): + for key in keys: + value = payload.get(key) + if isinstance(value, dict): + return value + for value in payload.values(): + found = extract_first_dict(value, keys) + if found: + return found + if isinstance(payload, list): + for item in payload: + found = extract_first_dict(item, keys) + if found: + return found + return {} + + +def run_gitlink_cli(command: list[str], owner: str, repo: str, cwd: Path | None = None) -> Any: + if shutil_which("gitlink-cli") is None: + raise WorkflowError("未找到 gitlink-cli,请先安装并确保它在 PATH 中") + + cli_path = shutil_which("gitlink-cli") or "gitlink-cli" + if cli_path.lower().endswith((".cmd", ".bat")): + cmd = [ + "cmd", + "/c", + cli_path, + *command, + "--owner", + owner, + "--repo", + repo, + "--format", + "json", + ] + else: + cmd = [ + cli_path, + *command, + "--owner", + owner, + "--repo", + repo, + "--format", + "json", + ] + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + encoding="utf-8", + ) + if proc.returncode != 0: + stderr = proc.stderr.strip() or proc.stdout.strip() or "未知错误" + raise WorkflowError(f"{' '.join(cmd)} 失败: {stderr}") + return parse_json_output(proc.stdout) + + +def parse_json_output(text: str) -> Any: + stripped = text.strip() + if not stripped: + raise WorkflowError("CLI 返回空结果") + try: + return json.loads(stripped) + except json.JSONDecodeError: + first_json = min( + [idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1], + default=-1, + ) + if first_json > 0: + return json.loads(stripped[first_json:]) + raise WorkflowError(f"无法解析 CLI JSON 输出: {stripped[:120]}") + + +def normalize_repo_info(payload: Any) -> dict[str, Any]: + repo = extract_first_dict(payload, ("project", "repo", "repository", "data")) + if not repo and isinstance(payload, dict): + repo = payload + return { + "name": first_value(repo, ("name", "repo_name", "project_name", "identifier"), ""), + "description": first_value(repo, ("description", "desc", "summary"), ""), + "default_branch": first_value(repo, ("default_branch", "defaultBranch"), ""), + "language": first_value(repo, ("language",), ""), + "raw": repo, + } + + +def normalize_issue_state(item: dict[str, Any], query_state: str | None = None) -> str: + raw_status = first_value(item, ("status_id", "status", "state_id"), None) + raw_name = str( + first_value(item, ("issue_status", "status_name", "state", "status_name_cn"), "") + ).strip().lower() + if raw_status is not None: + try: + raw_status = int(raw_status) + except (TypeError, ValueError): + raw_status = str(raw_status).strip().lower() + if raw_status in {5, "5", "closed", "close"} or "关" in raw_name or "closed" in raw_name: + return "closed" + if raw_status in {1, "1", 2, "2", 3, "3", "open", "opened"} or "开" in raw_name or "新" in raw_name: + return "open" + if query_state: + return query_state + return "open" + + +def normalize_issue(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]: + return { + "id": str(first_value(item, ("project_issues_index", "iid", "issue_id", "id", "number"), "")), + "title": str(first_value(item, ("subject", "title", "name"), "(untitled)")), + "state": normalize_issue_state(item, query_state=query_state), + "created_at": parse_datetime( + first_value(item, ("created_at", "createdAt", "created_time", "created", "format_time")) + ), + "updated_at": parse_datetime( + first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "format_time")) + ), + "labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])), + "raw": item, + } + + +def normalize_issues(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]: + items = extract_first_list(payload, ("issues", "issue_list", "items", "list")) + normalized: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + normalized.append(normalize_issue(item, query_state=query_state)) + return normalized + + +def normalize_pr_state(item: dict[str, Any], query_state: str | None = None) -> str: + raw_status = first_value(item, ("pull_request_status", "pull_request_staus", "status_id", "state_id"), None) + if raw_status is not None: + try: + raw_status = int(raw_status) + except (TypeError, ValueError): + raw_status = str(raw_status).strip().lower() + if raw_status in {1, "1", "merged"}: + return "merged" + if raw_status in {2, "2", "closed", "close"}: + return "closed" + if raw_status in {0, "0", "open", "opened"}: + return "open" + if query_state: + return query_state + return "open" + + +def normalize_pr(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]: + state = normalize_pr_state(item, query_state=query_state) + merged_at = parse_datetime(first_value(item, ("merged_at", "mergedAt", "merged_time"))) + merged_flag = state == "merged" or merged_at is not None + return { + "id": str( + first_value(item, ("pull_request_number", "iid", "pr_id", "merge_request_iid", "id", "number"), "") + ), + "title": str(first_value(item, ("title", "subject", "name"), "(untitled)")), + "state": state, + "created_at": parse_datetime( + first_value(item, ("created_at", "createdAt", "created_time", "created", "pr_full_time")) + ), + "updated_at": parse_datetime( + first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "pr_full_time")) + ), + "merged_at": merged_at + or (parse_datetime(first_value(item, ("pr_full_time",))) if state == "merged" else None), + "merged": merged_flag, + "labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])), + "raw": item, + } + + +def normalize_prs(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]: + items = extract_first_list(payload, ("pull_requests", "merge_requests", "prs", "items", "list")) + normalized: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + normalized.append(normalize_pr(item, query_state=query_state)) + return normalized + + +def normalize_releases(payload: Any) -> list[dict[str, Any]]: + items = extract_first_list(payload, ("releases", "items", "list")) + normalized: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + normalized.append( + { + "id": str(first_value(item, ("version_id", "id", "release_id", "iid"), "")), + "title": str(first_value(item, ("name", "title", "tag_name"), "(untitled)")), + "created_at": parse_datetime( + first_value(item, ("created_at", "createdAt", "released_at", "releasedAt")) + ), + "raw": item, + } + ) + return normalized + + +def is_open(state: str) -> bool: + return state == "open" + + +def is_closed(state: str) -> bool: + return state in {"closed", "close", "done", "resolved"} + + +def classify_title(title: str) -> str: + lowered = title.strip().lower() + prefix = lowered.split(":", 1)[0] + prefix = prefix.split("(", 1)[0].strip() + mapping = { + "feat": "feature", + "feature": "feature", + "fix": "fix", + "bugfix": "fix", + "docs": "docs", + "doc": "docs", + "refactor": "refactor", + "test": "test", + "chore": "chore", + "ci": "ci", + } + return mapping.get(prefix, "other") + + +def within_window(dt: datetime | None, cutoff: datetime) -> bool: + return dt is not None and dt >= cutoff + + +def dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[str] = set() + result: list[dict[str, Any]] = [] + for item in records: + key = str(item.get("id", "")).strip() + if not key or key in seen: + continue + seen.add(key) + result.append(item) + return result + + +def fetch_paginated_payload( + command: list[str], + owner: str, + repo: str, + item_keys: tuple[str, ...], + page_size: int = CLI_PAGE_SIZE, +) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + page = 1 + max_pages = 50 + while True: + if page > max_pages: + break + payload = run_gitlink_cli( + [*command, "--page", str(page), "--limit", str(page_size)], + owner, + repo, + ) + page_items = extract_first_list(payload, item_keys) + page_items = [item for item in page_items if isinstance(item, dict)] + if not page_items: + break + items.extend(page_items) + if len(page_items) < page_size: + break + page += 1 + return items + + +def fetch_issues(owner: str, repo: str) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for state in ("open", "closed"): + payloads = fetch_paginated_payload( + ["issue", "+list", "--state", state], + owner, + repo, + ("issues", "issue_list", "items", "list"), + ) + records.extend(normalize_issues({"issues": payloads}, query_state=state)) + return dedupe_records(records) + + +def fetch_prs(owner: str, repo: str) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for state in ("open", "merged", "closed"): + payloads = fetch_paginated_payload( + ["pr", "+list", "--state", state], + owner, + repo, + ("pull_requests", "merge_requests", "prs", "items", "list"), + ) + records.extend(normalize_prs({"pull_requests": payloads}, query_state=state)) + return dedupe_records(records) + + +def fetch_releases(owner: str, repo: str) -> list[dict[str, Any]]: + payloads = fetch_paginated_payload( + ["release", "+list"], + owner, + repo, + ("releases", "items", "list"), + ) + return dedupe_records(normalize_releases({"releases": payloads})) + + +def summarize_workflow( + repo_info: dict[str, Any], + issues: list[dict[str, Any]], + prs: list[dict[str, Any]], + releases: list[dict[str, Any]], + now: datetime, + window_days: int, +) -> dict[str, Any]: + cutoff = now - timedelta(days=window_days) + + open_issues = [item for item in issues if is_open(item["state"])] + closed_issues = [item for item in issues if is_closed(item["state"])] + stale_issues = [ + item + for item in open_issues + if item["updated_at"] is None or item["updated_at"] < cutoff + ] + + merged_prs = [item for item in prs if item["merged"] or item["state"] == "merged"] + open_prs = [item for item in prs if is_open(item["state"]) or (not item["merged"] and not is_closed(item["state"]))] + stale_prs = [ + item + for item in open_prs + if item["updated_at"] is None or item["updated_at"] < cutoff + ] + recent_merged_prs = [ + item + for item in merged_prs + if within_window(item["merged_at"] or item["updated_at"] or item["created_at"], cutoff) + ] + + issue_label_counter: Counter[str] = Counter() + for item in issues: + issue_label_counter.update(item["labels"]) + + pr_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list) + for item in recent_merged_prs: + pr_buckets[classify_title(item["title"])].append(item) + + actions: list[str] = [] + if stale_issues: + actions.append( + f"存在 {len(stale_issues)} 个超过 {window_days} 天未更新的开放 Issue,建议优先清理。" + ) + if stale_prs: + actions.append( + f"存在 {len(stale_prs)} 个超过 {window_days} 天未更新的开放 PR,建议安排 review 或重新拆解。" + ) + if not releases: + actions.append("当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。") + + return { + "repo": repo_info, + "window_days": window_days, + "now": now, + "cutoff": cutoff, + "counts": { + "issues_total": len(issues), + "issues_open": len(open_issues), + "issues_closed": len(closed_issues), + "issues_stale": len(stale_issues), + "prs_total": len(prs), + "prs_open": len(open_prs), + "prs_merged": len(merged_prs), + "prs_stale": len(stale_prs), + "releases_total": len(releases), + }, + "labels": issue_label_counter.most_common(8), + "stale_issues": stale_issues, + "stale_prs": stale_prs, + "recent_merged_prs": recent_merged_prs, + "pr_buckets": {key: value for key, value in pr_buckets.items()}, + "actions": actions, + } + + +def render_list_block(items: list[dict[str, Any]], title_key: str = "title") -> str: + if not items: + return "- 无" + lines = [] + for item in items[:10]: + parts = [f"- {item.get('id', '')} {item.get(title_key, '')}".strip()] + state = item.get("state") + if state: + parts.append(f"({state})") + dt = item.get("updated_at") or item.get("merged_at") or item.get("created_at") + if isinstance(dt, datetime): + parts.append(dt.strftime("%Y-%m-%d")) + lines.append(" ".join(parts)) + return "\n".join(lines) + + +def render_markdown_report(summary: dict[str, Any]) -> str: + repo = summary["repo"] + counts = summary["counts"] + lines: list[str] = [] + title = repo["name"] or "GitLink 仓库" + lines.append(f"# {title} 自动化周报") + if repo.get("description"): + lines.append("") + lines.append(repo["description"]) + lines.append("") + lines.append(f"- 统计窗口:近 {summary['window_days']} 天") + lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}") + lines.append("") + lines.append("## 核心指标") + lines.append("") + lines.append("| 指标 | 数值 |") + lines.append("| --- | ---: |") + lines.append(f"| Issues 总数 | {counts['issues_total']} |") + lines.append(f"| 打开 Issues | {counts['issues_open']} |") + lines.append(f"| 超窗 Issue | {counts['issues_stale']} |") + lines.append(f"| PR 总数 | {counts['prs_total']} |") + lines.append(f"| 打开 PR | {counts['prs_open']} |") + lines.append(f"| 已合并 PR | {counts['prs_merged']} |") + lines.append(f"| Release 数 | {counts['releases_total']} |") + lines.append("") + + lines.append("## 热点标签") + if summary["labels"]: + for label, count in summary["labels"]: + lines.append(f"- {label}: {count}") + else: + lines.append("- 无") + lines.append("") + + lines.append("## 最近合并 PR") + recent_groups = summary["pr_buckets"] + if recent_groups: + for bucket, items in recent_groups.items(): + lines.append(f"### {bucket}") + for item in items[:8]: + merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at") + suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else "" + lines.append(f"- {item['title']}{suffix}") + else: + lines.append("- 无") + lines.append("") + + lines.append("## 风险提示") + if summary["stale_issues"]: + lines.append("### 超窗 Issue") + lines.append(render_list_block(summary["stale_issues"])) + lines.append("") + if summary["stale_prs"]: + lines.append("### 超窗 PR") + lines.append(render_list_block(summary["stale_prs"])) + lines.append("") + if summary["actions"]: + lines.append("### 建议动作") + for action in summary["actions"]: + lines.append(f"- {action}") + else: + lines.append("- 当前未发现明显风险。") + return "\n".join(lines).rstrip() + "\n" + + +def render_release_notes(summary: dict[str, Any]) -> str: + repo = summary["repo"] + lines: list[str] = [] + title = repo["name"] or "GitLink 仓库" + lines.append(f"# {title} Release Notes 草稿") + lines.append("") + lines.append(f"- 统计窗口:近 {summary['window_days']} 天") + lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}") + lines.append("") + lines.append("## 变更概览") + lines.append(f"- 已合并 PR:{summary['counts']['prs_merged']} 个") + lines.append(f"- 最近窗口内合并 PR:{len(summary['recent_merged_prs'])} 个") + lines.append("") + lines.append("## 变更分类") + groups = summary["pr_buckets"] + if groups: + for bucket in ("feature", "fix", "docs", "refactor", "test", "chore", "ci", "other"): + items = groups.get(bucket, []) + if not items: + continue + lines.append(f"### {bucket}") + for item in items[:10]: + merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at") + suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else "" + lines.append(f"- {item['title']}{suffix}") + lines.append("") + else: + lines.append("- 无") + lines.append("") + lines.append("## 发布说明") + if summary["actions"]: + for action in summary["actions"]: + lines.append(f"- {action}") + else: + lines.append("- 当前未发现明显风险。") + return "\n".join(lines).rstrip() + "\n" + + +def render_publish_comment( + summary: dict[str, Any], + report_path: Path, + release_notes_path: Path | None = None, +) -> str: + repo = summary["repo"] + counts = summary["counts"] + lines = [ + f"## {repo['name'] or 'GitLink 仓库'} 自动化周报摘要", + "", + f"- 时间窗:近 {summary['window_days']} 天", + f"- Issues:{counts['issues_open']} 个打开,{counts['issues_stale']} 个超窗", + f"- PR:{counts['prs_open']} 个打开,{counts['prs_merged']} 个已合并", + f"- Release:{counts['releases_total']} 条", + "", + f"完整报告已生成:`{report_path.as_posix()}`", + ] + if release_notes_path is not None: + lines.append(f"Release Notes 草稿:`{release_notes_path.as_posix()}`") + if summary["actions"]: + lines.append("") + lines.append("### 建议动作") + for action in summary["actions"][:3]: + lines.append(f"- {action}") + return "\n".join(lines).rstrip() + + +def build_issue_comment_command(issue_number: int, comment: str) -> list[str]: + return ["issue", "+comment", "--number", str(issue_number), "--body", comment] + + +def safe_fetch( + label: str, + func, + warnings: list[str], + default: Any, +) -> Any: + try: + return func() + except Exception as exc: # noqa: BLE001 + warnings.append(f"{label} 失败:{exc}") + return default + + +def shutil_which(name: str) -> str | None: + from shutil import which + + return which(name) + + +def build_artifacts( + owner: str, + repo: str, + window_days: int, + output_dir: Path, + now: datetime, + publish_issue_id: int | None, + skip_releases: bool, +) -> tuple[dict[str, Any], Path, Path, Path, list[str]]: + warnings: list[str] = [] + repo_info = safe_fetch( + "repo +info", + lambda: normalize_repo_info(run_gitlink_cli(["repo", "+info"], owner, repo)), + warnings, + {"name": repo, "description": "", "default_branch": "", "language": "", "raw": {}}, + ) + issues = safe_fetch("issue +list", lambda: fetch_issues(owner, repo), warnings, []) + prs = safe_fetch("pr +list", lambda: fetch_prs(owner, repo), warnings, []) + releases = [] if skip_releases else safe_fetch( + "release +list", + lambda: fetch_releases(owner, repo), + warnings, + [], + ) + + summary = summarize_workflow(repo_info, issues, prs, releases, now, window_days) + summary["warnings"] = warnings + summary["owner"] = owner + summary["repo_name"] = repo + summary["publish_issue_id"] = publish_issue_id + + output_dir.mkdir(parents=True, exist_ok=True) + stamp = now.strftime("%Y%m%d_%H%M%S") + repo_slug = sanitize_repo_name(repo) + base_name = f"{owner}_{repo_slug}_{stamp}" + report_path = output_dir / f"{base_name}_report.md" + summary_path = output_dir / f"{base_name}_summary.json" + release_notes_path = output_dir / f"{base_name}_release_notes.md" + + report_text = render_markdown_report(summary) + release_notes_text = render_release_notes(summary) + report_path.write_text(report_text, encoding="utf-8") + release_notes_path.write_text(release_notes_text, encoding="utf-8") + summary_path.write_text( + json.dumps( + { + **summary, + "now": summary["now"].isoformat(), + "cutoff": summary["cutoff"].isoformat(), + "artifacts": { + "report": report_path.as_posix(), + "summary": summary_path.as_posix(), + "release_notes": release_notes_path.as_posix(), + }, + }, + ensure_ascii=False, + indent=2, + default=str, + ), + encoding="utf-8", + ) + + if publish_issue_id is not None: + comment = render_publish_comment(summary, report_path, release_notes_path) + try: + run_gitlink_cli( + build_issue_comment_command(publish_issue_id, comment), + owner, + repo, + ) + except Exception as exc: # noqa: BLE001 + warnings.append(f"issue +comment 失败:{exc}") + + return summary, report_path, summary_path, release_notes_path, warnings + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + config = load_json_file(args.config) + + owner = args.owner or config.get("owner") + repo = args.repo or config.get("repo") + if not owner or not repo: + raise WorkflowError("请在配置文件或命令行中提供 owner 和 repo") + + window_days = args.window_days or int(config.get("window_days", 7)) + output_dir = args.output_dir or Path(config.get("output_dir", "outputs")) + now = parse_iso_now(args.now) + + summary, report_path, summary_path, release_notes_path, warnings = build_artifacts( + owner=owner, + repo=repo, + window_days=window_days, + output_dir=output_dir, + now=now, + publish_issue_id=args.publish_issue_id, + skip_releases=args.skip_releases, + ) + + print(f"已生成报告: {report_path}") + print(f"已生成摘要: {summary_path}") + print(f"已生成 Release Notes: {release_notes_path}") + if warnings: + print("警告:") + for warning in warnings: + print(f"- {warning}") + print( + "指标概览: " + f"Issues={summary['counts']['issues_total']}, " + f"PR={summary['counts']['prs_total']}, " + f"Release={summary['counts']['releases_total']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/workflows/community-ops-automation/scripts/run_demo.ps1 b/examples/workflows/community-ops-automation/scripts/run_demo.ps1 new file mode 100644 index 0000000..5ad872b --- /dev/null +++ b/examples/workflows/community-ops-automation/scripts/run_demo.ps1 @@ -0,0 +1,45 @@ +param( + [string]$Config = "examples/demo_active_config.json", + [string]$Owner = "", + [string]$Repo = "", + [int]$WindowDays = 7, + [string]$OutputDir = "outputs", + [int]$PublishIssueId = 0, + [switch]$SkipReleases +) + +$ErrorActionPreference = "Stop" + +$cliCandidates = npm.cmd exec --yes --package=@gitlink-ai/cli -- cmd /c where gitlink-cli 2>$null +$cliPath = $cliCandidates | Where-Object { $_ -match 'gitlink-cli\.cmd$' } | Select-Object -First 1 +if (-not $cliPath) { + $cliPath = $cliCandidates | Select-Object -First 1 +} +if (-not $cliPath) { + throw "未能通过 npm exec 找到 gitlink-cli" +} + +$cliDir = Split-Path -Parent $cliPath +$env:PATH = "$cliDir;$env:PATH" + +$args = @( + "scripts\gitlink_workflow.py", + "--config", $Config, + "--window-days", "$WindowDays", + "--output-dir", $OutputDir +) + +if ($Owner) { + $args += @("--owner", $Owner) +} +if ($Repo) { + $args += @("--repo", $Repo) +} +if ($PublishIssueId -gt 0) { + $args += @("--publish-issue-id", "$PublishIssueId") +} +if ($SkipReleases.IsPresent) { + $args += "--skip-releases" +} + +python @args diff --git a/examples/workflows/community-ops-automation/tests/test_gitlink_workflow.py b/examples/workflows/community-ops-automation/tests/test_gitlink_workflow.py new file mode 100644 index 0000000..8025bf8 --- /dev/null +++ b/examples/workflows/community-ops-automation/tests/test_gitlink_workflow.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone + +from scripts.gitlink_workflow import ( + build_issue_comment_command, + normalize_issues, + normalize_prs, + normalize_releases, + render_markdown_report, + render_release_notes, + summarize_workflow, +) + + +class WorkflowTests(unittest.TestCase): + def setUp(self) -> None: + self.now = datetime(2026, 5, 15, 12, 0, tzinfo=timezone.utc) + self.repo_info = { + "name": "forgeplus", + "description": "demo repo", + "default_branch": "master", + } + + def test_normalize_issue_payload(self) -> None: + payload = { + "data": { + "issues": [ + { + "project_issues_index": 1, + "subject": "feat: add report", + "status_id": 1, + "status_name": "新增", + "updated_at": "2026-05-10T10:00:00Z", + "labels": [{"name": "enhancement"}], + } + ] + } + } + issues = normalize_issues(payload) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0]["title"], "feat: add report") + self.assertEqual(issues[0]["labels"], ["enhancement"]) + self.assertEqual(issues[0]["state"], "open") + + def test_normalize_pr_payload(self) -> None: + payload = { + "data": { + "merge_requests": [ + { + "pull_request_number": 10, + "title": "fix: bug", + "pull_request_status": 1, + "merged_at": "2026-05-14T10:00:00Z", + } + ] + } + } + prs = normalize_prs(payload) + self.assertEqual(len(prs), 1) + self.assertTrue(prs[0]["merged"]) + self.assertEqual(prs[0]["state"], "merged") + + def test_normalize_release_payload(self) -> None: + payload = {"data": {"releases": [{"id": 5, "name": "v1.0.0"}]}} + releases = normalize_releases(payload) + self.assertEqual(len(releases), 1) + self.assertEqual(releases[0]["title"], "v1.0.0") + + def test_summary_and_report(self) -> None: + issues = [ + { + "id": "1", + "title": "feat: add report", + "state": "open", + "created_at": datetime(2026, 5, 5, 12, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 5, 10, 12, 0, tzinfo=timezone.utc), + "labels": ["enhancement"], + }, + { + "id": "2", + "title": "fix: stale issue", + "state": "open", + "created_at": datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc), + "labels": ["bug"], + }, + ] + prs = [ + { + "id": "10", + "title": "feat: workflow", + "state": "merged", + "created_at": datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc), + "merged_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc), + "merged": True, + "labels": [], + }, + { + "id": "11", + "title": "chore: cleanup", + "state": "open", + "created_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=timezone.utc), + "merged_at": None, + "merged": False, + "labels": [], + }, + ] + releases = [{"id": "1", "title": "v1.0.0", "created_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc)}] + summary = summarize_workflow(self.repo_info, issues, prs, releases, self.now, 7) + report = render_markdown_report(summary) + self.assertIn("# forgeplus 自动化周报", report) + self.assertIn("Issues 总数", report) + self.assertIn("超窗 Issue", report) + self.assertIn("feature", report) + release_notes = render_release_notes(summary) + self.assertIn("Release Notes", release_notes) + self.assertIn("变更分类", release_notes) + self.assertEqual(summary["counts"]["issues_stale"], 1) + self.assertEqual(summary["counts"]["prs_merged"], 1) + self.assertIn("feature", summary["pr_buckets"]) + + def test_issue_comment_command_uses_number_flag(self) -> None: + command = build_issue_comment_command(2, "demo") + self.assertEqual(command, ["issue", "+comment", "--number", "2", "--body", "demo"]) + self.assertNotIn("-i", command) + + +if __name__ == "__main__": + unittest.main()