feat(workflows): add community ops automation example

This commit is contained in:
ljc0426 2026-05-24 14:34:47 +08:00
parent a46e06b78a
commit 9ebed898ac
21 changed files with 1458 additions and 0 deletions

View File

@ -0,0 +1,8 @@
outputs/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
*.log
*.tmp
*.swp

View File

@ -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.

View File

@ -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 项目上复现
- 提供运行脚本、验证记录、示例产物和单元测试

View File

@ -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 调用的要求,并形成从数据获取到结果发布的端到端闭环。

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 400 KiB

View File

@ -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 的发布验证。

View File

@ -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

View File

@ -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 回写能力。

View File

@ -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/` 提供固定示例产物,便于快速查看报告格式和输出内容。

View File

@ -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/`

View File

@ -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
```

View File

@ -0,0 +1,6 @@
{
"owner": "Gitlink",
"repo": "gitlink-cli",
"window_days": 7,
"output_dir": "outputs"
}

View File

@ -0,0 +1,18 @@
# gitlink-cli Release Notes 草稿
- 统计窗口:近 7 天
- 生成时间2026-05-20 14:05:25 UTC
## 变更概览
- 已合并 PR8 个
- 最近窗口内合并 PR2 个
## 变更分类
### feature
- feat(pr): add pr +comment shortcut (2026-05-14)
### fix
- fix(npm): improve missing binary diagnostics (2026-05-19)
## 发布说明
- 存在 1 个超过 7 天未更新的开放 Issue建议优先清理。

View File

@ -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建议优先清理。

View File

@ -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`

View File

@ -0,0 +1,14 @@
# gitlink-cli Release Notes 草稿
- 统计窗口:近 7 天
- 生成时间2026-05-20 14:32:24 UTC
## 变更概览
- 已合并 PR0 个
- 最近窗口内合并 PR0 个
## 变更分类
- 无
## 发布说明
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。

View File

@ -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 权限。

View File

@ -0,0 +1,6 @@
{
"owner": "puygob236",
"repo": "gitlink-cli",
"window_days": 7,
"output_dir": "outputs"
}

View File

@ -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())

View File

@ -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

View File

@ -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()