feat(workflows): add community ops automation example
This commit is contained in:
parent
a46e06b78a
commit
9ebed898ac
|
|
@ -0,0 +1,8 @@
|
|||
outputs/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 项目上复现
|
||||
- 提供运行脚本、验证记录、示例产物和单元测试
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
# 架构说明
|
||||
|
||||
本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。
|
||||
|
||||

|
||||
|
||||
## 设计目标
|
||||
|
||||
- 低门槛:只依赖 `gitlink-cli` 和 Python 标准库
|
||||
- 可复现:同一配置可重复跑出同类报告
|
||||
- 可维护:采集、归一化、分析、生成和发布步骤保持清晰边界
|
||||
- 可验证:报告文件、结构化摘要和 Issue 评论均可作为运行结果核验依据
|
||||
|
||||
## 为什么选这个链路
|
||||
|
||||
子赛题三要求使用现有命令或 Skill 组合形成完整解决方案。本方案覆盖:
|
||||
|
||||
1. 仓库信息采集
|
||||
2. Issue 列表采集
|
||||
3. PR 列表采集
|
||||
4. Release 列表采集
|
||||
5. 报告生成
|
||||
6. Issue 摘要发布
|
||||
|
||||
该链路满足不少于 3 个 CLI 调用的要求,并形成从数据获取到结果发布的端到端闭环。
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 400 KiB |
|
|
@ -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 的发布验证。
|
||||
|
|
@ -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
|
||||
|
|
@ -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 回写能力。
|
||||
|
|
@ -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/` 提供固定示例产物,便于快速查看报告格式和输出内容。
|
||||
|
|
@ -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/`。
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"owner": "Gitlink",
|
||||
"repo": "gitlink-cli",
|
||||
"window_days": 7,
|
||||
"output_dir": "outputs"
|
||||
}
|
||||
|
|
@ -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,建议优先清理。
|
||||
|
|
@ -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,建议优先清理。
|
||||
|
|
@ -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`。
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# gitlink-cli Release Notes 草稿
|
||||
|
||||
- 统计窗口:近 7 天
|
||||
- 生成时间:2026-05-20 14:32:24 UTC
|
||||
|
||||
## 变更概览
|
||||
- 已合并 PR:0 个
|
||||
- 最近窗口内合并 PR:0 个
|
||||
|
||||
## 变更分类
|
||||
- 无
|
||||
|
||||
## 发布说明
|
||||
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。
|
||||
|
|
@ -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 权限。
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"owner": "puygob236",
|
||||
"repo": "gitlink-cli",
|
||||
"window_days": 7,
|
||||
"output_dir": "outputs"
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue