From 9ebed898ac66b9de8e697457c21573b6d03c79f5 Mon Sep 17 00:00:00 2001 From: ljc0426 Date: Sun, 24 May 2026 14:34:47 +0800 Subject: [PATCH 01/13] 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() -- 2.34.1 From 9638d279d39a5b83f049fb476b6c128225db1922 Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Sun, 24 May 2026 22:24:36 +0800 Subject: [PATCH 02/13] feat(issue): add assigners shortcut --- README.md | 3 +++ README.zh-CN.md | 3 +++ shortcuts/issue/issue.go | 21 ++++++++++++++++ shortcuts/issue/issue_test.go | 23 +++++++++++++++++ skills/gitlink-issue/SKILL.md | 4 +++ .../references/gitlink-issue-assigners.md | 25 +++++++++++++++++++ 6 files changed, 79 insertions(+) create mode 100644 skills/gitlink-issue/references/gitlink-issue-assigners.md diff --git a/README.md b/README.md index b4c55e1..c04b63d 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,9 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" + +# List issue assigners +gitlink-cli issue +assigners --owner Gitlink --repo forgeplus ``` ### Pull Requests diff --git a/README.zh-CN.md b/README.zh-CN.md index f426948..b8c0ae2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -209,6 +209,9 @@ gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.cs # 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" + +# 列出 Issue 负责人 +gitlink-cli issue +assigners --owner Gitlink --repo forgeplus ``` ### Pull Request diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 902fda8..e6c65f0 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -223,6 +223,27 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "assigners", + Description: "List issue assigners", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_assigners", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 088f82c..b412409 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -131,6 +131,29 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) { assertEqual(t, updatePayload["status_id"], float64(5)) } +func TestIssueAssignersShortcutWithKeyword(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_assigners.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + assertEqual(t, r.URL.Query().Get("keyword"), "alice") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "assigners": []map[string]interface{}{ + {"id": 7, "name": "Alice", "login": "alice"}, + }, + }) + }) + defer server.Close() + + err := runIssueShortcut(t, server, "assigners", map[string]string{ + "keyword": "alice", + }) + if err != nil { + t.Fatalf("assigners shortcut failed: %v", err) + } +} + func runIssueShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findIssueShortcut(t, name) diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 1938da6..9d910d8 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -27,6 +27,7 @@ metadata: | `issue +close` | 关闭 Issue | 是 | | `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | | `issue +comment` | 添加评论 | 是 | +| `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) | ## 使用示例 @@ -54,6 +55,9 @@ gitlink-cli issue +batch-close --owner myuser --repo myrepo --from issues.csv # 添加评论 gitlink-cli issue +comment --number 4 --body "已修复,请验证" + +# 查询 Issue 负责人 +gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice ``` ## Raw API 补充 diff --git a/skills/gitlink-issue/references/gitlink-issue-assigners.md b/skills/gitlink-issue/references/gitlink-issue-assigners.md new file mode 100644 index 0000000..23f1a4e --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-assigners.md @@ -0,0 +1,25 @@ +# Issue Assigners + +Use `issue +assigners` to list users that can be assigned to Issues in a repository. +This helps users find the assignee ID before creating or updating an Issue. + +## Usage + +```bash +gitlink-cli issue +assigners --owner Gitlink --repo forgeplus +gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--owner` | Repository owner | +| `--repo` | Repository name | +| `--keyword`, `-k` | Optional search keyword | + +## API + +```http +GET /api/v1/{owner}/{repo}/issue_assigners.json +``` -- 2.34.1 From 23a478bd69d75a3eb9fabbb50a02db1f4c90fc32 Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Sun, 24 May 2026 22:27:00 +0800 Subject: [PATCH 03/13] feat(issue): add authors shortcut --- README.md | 3 +++ README.zh-CN.md | 3 +++ shortcuts/issue/issue.go | 21 ++++++++++++++++ shortcuts/issue/issue_test.go | 23 +++++++++++++++++ skills/gitlink-issue/SKILL.md | 4 +++ .../references/gitlink-issue-authors.md | 25 +++++++++++++++++++ 6 files changed, 79 insertions(+) create mode 100644 skills/gitlink-issue/references/gitlink-issue-authors.md diff --git a/README.md b/README.md index c04b63d..0148d95 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,9 @@ gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" # List issue assigners gitlink-cli issue +assigners --owner Gitlink --repo forgeplus + +# List issue authors +gitlink-cli issue +authors --owner Gitlink --repo forgeplus ``` ### Pull Requests diff --git a/README.zh-CN.md b/README.zh-CN.md index b8c0ae2..62c473d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -212,6 +212,9 @@ gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复 # 列出 Issue 负责人 gitlink-cli issue +assigners --owner Gitlink --repo forgeplus + +# 列出 Issue 发布人 +gitlink-cli issue +authors --owner Gitlink --repo forgeplus ``` ### Pull Request diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index e6c65f0..7c0bc68 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -244,6 +244,27 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "authors", + Description: "List issue authors", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_authors", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index b412409..661a4de 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -154,6 +154,29 @@ func TestIssueAssignersShortcutWithKeyword(t *testing.T) { } } +func TestIssueAuthorsShortcutWithKeyword(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_authors.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + assertEqual(t, r.URL.Query().Get("keyword"), "bob") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "authors": []map[string]interface{}{ + {"id": 8, "name": "Bob", "login": "bob"}, + }, + }) + }) + defer server.Close() + + err := runIssueShortcut(t, server, "authors", map[string]string{ + "keyword": "bob", + }) + if err != nil { + t.Fatalf("authors shortcut failed: %v", err) + } +} + func runIssueShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findIssueShortcut(t, name) diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 9d910d8..0928362 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -28,6 +28,7 @@ metadata: | `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | | `issue +comment` | 添加评论 | 是 | | `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) | +| `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) | ## 使用示例 @@ -58,6 +59,9 @@ gitlink-cli issue +comment --number 4 --body "已修复,请验证" # 查询 Issue 负责人 gitlink-cli issue +assigners --owner Gitlink --repo forgeplus --keyword alice + +# 查询 Issue 发布人 +gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob ``` ## Raw API 补充 diff --git a/skills/gitlink-issue/references/gitlink-issue-authors.md b/skills/gitlink-issue/references/gitlink-issue-authors.md new file mode 100644 index 0000000..49edc9f --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-authors.md @@ -0,0 +1,25 @@ +# Issue Authors + +Use `issue +authors` to list users who have authored Issues in a repository. +This helps users find author IDs for Issue filtering and reporting workflows. + +## Usage + +```bash +gitlink-cli issue +authors --owner Gitlink --repo forgeplus +gitlink-cli issue +authors --owner Gitlink --repo forgeplus --keyword bob +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--owner` | Repository owner | +| `--repo` | Repository name | +| `--keyword`, `-k` | Optional search keyword | + +## API + +```http +GET /api/v1/{owner}/{repo}/issue_authors.json +``` -- 2.34.1 From 1feb6a85fcafaf8b3edb829278f248b4d16b4ebc Mon Sep 17 00:00:00 2001 From: Jiachen Li <3115250840@qq.com> Date: Mon, 25 May 2026 13:42:41 +0800 Subject: [PATCH 04/13] feat(member): add repository member shortcuts --- README.md | 35 ++- README.zh-CN.md | 33 ++- doc/changes/member-shortcut.md | 14 ++ shortcuts/member/member.go | 376 ++++++++++++++++++++++++++++++++ shortcuts/member/member_test.go | 266 ++++++++++++++++++++++ shortcuts/register.go | 3 + skills/README.md | 3 + skills/gitlink-member/SKILL.md | 57 +++++ 8 files changed, 778 insertions(+), 9 deletions(-) create mode 100644 doc/changes/member-shortcut.md create mode 100644 shortcuts/member/member.go create mode 100644 shortcuts/member/member_test.go create mode 100644 skills/gitlink-member/SKILL.md diff --git a/README.md b/README.md index b4c55e1..1f2b0cf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, CI/CD, and AI-powered workflows, with 40+ commands and 13 AI Agent [Skills](./skills/). +The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/). **[中文文档](./README.zh-CN.md)** @@ -27,8 +27,8 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans ## Why gitlink-cli? -- **Agent-Native Design** — 13 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup -- **Wide Coverage** — Repository, Issue, PR, Webhook, Branch, Release, CI, Org, Search, User — all core domains covered +- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup +- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Org, Search, and User workflows are covered by high-level commands - **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output - **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded - **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install` @@ -43,6 +43,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 📦 Repo | List, create, fork, delete repositories, view repo info | | 🐛 Issue | Create, update, close, batch close, comment on issues | | 🔀 PR | Create, merge, review pull requests, view changed files | +| 👥 Member | List, add, remove repository members, change roles, create and accept invite links | | 🌿 Branch | Create, delete, list, protect, unprotect branches | | 🏷️ Release | Create, view, delete releases | | 🏢 Org | Manage organizations, members, teams | @@ -70,7 +71,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans **From npm (recommended):** ```bash -# One command: installs CLI binary + all 13 AI Agent Skills +# One command: installs CLI binary + AI Agent Skills npm install -g @gitlink-ai/cli ``` @@ -175,6 +176,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 ``` +### Member Management + +```bash +# List repository members +gitlink-cli member +list --owner Gitlink --repo forgeplus + +# Add a member +gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101 + +# Preview batch add without changing data +gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run + +# Batch add members from a CSV file +gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv + +# Change a member role +gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer + +# Create an invite link +gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true +``` + ### Issue Management ```bash @@ -338,7 +361,7 @@ git push gitlink ## AI Agent Skills -The `skills/` directory contains 12 Agent Skill files for AI-automated GitLink operations. +The `skills/` directory contains Agent Skill files for AI-automated GitLink operations. See [skills/README.md](skills/README.md) for details. @@ -348,6 +371,7 @@ See [skills/README.md](skills/README.md) for details. | `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) | | `gitlink-issue` | Issue operations (create, update, close, comment, etc.) | | `gitlink-pr` | Pull request operations (create, merge, review, etc.) | +| `gitlink-member` | Repository member and invite link management | | `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) | | `gitlink-release` | Release management (create, view, delete, etc.) | | `gitlink-ci` | CI/CD operations (builds, logs, etc.) | @@ -378,6 +402,7 @@ gitlink-cli/ │ ├── repo/ # Repository shortcuts │ ├── issue/ # Issue shortcuts │ ├── pr/ # PR shortcuts +│ ├── member/ # Repository member shortcuts │ ├── branch/ # Branch shortcuts │ ├── release/ # Release shortcuts │ ├── org/ # Organization shortcuts diff --git a/README.zh-CN.md b/README.zh-CN.md index f426948..c470989 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 13 个 AI Agent [Skills](./skills/)。 +[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。 **[English](./README.md)** @@ -27,8 +27,8 @@ ## 为什么选择 gitlink-cli? -- **Agent-Native 设计** — 开箱即用 13 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink -- **广泛覆盖** — 仓库、Issue、PR、Webhook、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖 +- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink +- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、组织、搜索、用户等常用工作流均提供高层命令 - **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出 - **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装 - **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用 @@ -43,6 +43,7 @@ | 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 | | 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | +| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 | | 🌿 分支 | 创建、删除、保护分支 | | 🏷️ 发布 | 创建、查看、删除 Release | | 🏢 组织 | 管理组织、成员、团队 | @@ -186,6 +187,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68 ``` +### 成员管理 + +```bash +# 列出仓库成员 +gitlink-cli member +list --owner Gitlink --repo forgeplus + +# 添加成员 +gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101 + +# 预览批量添加成员,不修改数据 +gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run + +# 从 CSV 文件批量添加成员 +gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv + +# 调整成员权限 +gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer + +# 生成邀请链接 +gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true +``` + ### Issue 管理 ```bash @@ -317,7 +340,7 @@ git push gitlink ## AI Agent Skills -`skills/` 目录包含 13 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。 +`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。 详见 [skills/README.md](skills/README.md) @@ -327,6 +350,7 @@ git push gitlink | `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) | | `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) | | `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) | +| `gitlink-member` | 仓库成员与邀请链接管理 | | `gitlink-release` | 发布管理(创建、查看、删除等) | | `gitlink-org` | 组织管理(成员、团队等) | | `gitlink-ci` | CI/CD 操作(构建、日志等) | @@ -356,6 +380,7 @@ gitlink-cli/ │ ├── repo/ # 仓库 shortcuts │ ├── issue/ # Issue shortcuts │ ├── pr/ # PR shortcuts +│ ├── member/ # 仓库成员 shortcuts │ ├── branch/ # 分支 shortcuts │ ├── release/ # Release shortcuts │ ├── org/ # 组织 shortcuts diff --git a/doc/changes/member-shortcut.md b/doc/changes/member-shortcut.md new file mode 100644 index 0000000..b3278b0 --- /dev/null +++ b/doc/changes/member-shortcut.md @@ -0,0 +1,14 @@ +# Member Shortcut + +新增 `member` Shortcut 组,支持仓库成员管理和项目邀请链接操作: + +- `member +list` +- `member +add` +- `member +batch-add` +- `member +remove` +- `member +role` +- `member +invite-link` +- `member +invite-info` +- `member +accept-invite` + +同时补充了单元测试、README 示例和 `gitlink-member` Skill 说明。 diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go new file mode 100644 index 0000000..0c8f817 --- /dev/null +++ b/shortcuts/member/member.go @@ -0,0 +1,376 @@ +package member + +import ( + "encoding/csv" + "fmt" + "net/url" + "os" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +var roleAliases = map[string]string{ + "manager": "Manager", + "developer": "Developer", + "reporter": "Reporter", + "Manager": "Manager", + "Developer": "Developer", + "Reporter": "Reporter", +} + +// Shortcuts returns repository member management shortcuts. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List repository members", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", collaboratorsPath(ctx), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "add", + Description: "Add a repository member by user ID", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to add", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := parseUserID(ctx.Arg("user-id")) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "batch-add", + Description: "Add multiple repository members by user IDs or a CSV file", + Flags: []common.Flag{ + {Name: "user-ids", Short: "u", Usage: "Comma-separated GitLink user IDs, for example: 101,102"}, + {Name: "from", Usage: "Read user IDs from a CSV file. Supports a user_id/id column or first column without header"}, + {Name: "dry-run", Usage: "Preview members that would be added without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchAdd, + }, + { + Name: "remove", + Description: "Remove a repository member by user ID", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to remove", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := parseUserID(ctx.Arg("user-id")) + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", collaboratorsRemovePath(ctx), map[string]interface{}{"user_id": userID}) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "role", + Description: "Change a repository member role", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to update", Required: true}, + {Name: "role", Short: "r", Usage: "Member role: Manager, Developer, or Reporter", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := parseUserID(ctx.Arg("user-id")) + if err != nil { + return err + } + role, err := normalizeRole(ctx.Arg("role")) + if err != nil { + return err + } + env, err := ctx.CallAPI("PUT", collaboratorsRolePath(ctx), map[string]interface{}{ + "user_id": userID, + "role": role, + }) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "invite-link", + Description: "Get or create a repository invite link", + Flags: []common.Flag{ + {Name: "role", Short: "r", Usage: "Invite role: manager, developer, or reporter", Default: "developer"}, + {Name: "apply", Usage: "Whether joining by invite requires approval: true or false", Default: "true"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + role, err := normalizeInviteRole(ctx.Arg("role")) + if err != nil { + return err + } + apply, err := parseBoolArg("apply", ctx.Arg("apply")) + if err != nil { + return err + } + query := url.Values{} + query.Set("role", role) + query.Set("is_apply", strconv.FormatBool(apply)) + env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "current_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "invite-info", + Description: "Show repository invite link information", + Flags: []common.Flag{ + {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sign, err := ctx.RequireArg("sign") + if err != nil { + return err + } + query := url.Values{} + query.Set("invite_sign", sign) + env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "show_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "accept-invite", + Description: "Accept a repository invite link", + Flags: []common.Flag{ + {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sign, err := ctx.RequireArg("sign") + if err != nil { + return err + } + query := url.Values{} + query.Set("invite_sign", sign) + env, err := ctx.CallAPIWithQuery("POST", inviteLinkPath(ctx, "redirect_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func runBatchAdd(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userIDs, err := collectUserIDs(ctx.Arg("user-ids"), ctx.Arg("from")) + if err != nil { + return err + } + if len(userIDs) == 0 { + return fmt.Errorf("provide --user-ids or --from") + } + if parseDryRun(ctx.Arg("dry-run")) { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "user_ids": userIDs, + "count": len(userIDs), + }) + } + + results := make([]map[string]interface{}, 0, len(userIDs)) + for _, userID := range userIDs { + env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) + result := map[string]interface{}{"user_id": userID} + if err != nil { + result["ok"] = false + result["error"] = err.Error() + } else { + result["ok"] = env.OK + result["data"] = env.Data + } + results = append(results, result) + } + return ctx.OutputData(map[string]interface{}{ + "count": len(userIDs), + "results": results, + }) +} + +func collaboratorsPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo) +} + +func collaboratorsRemovePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("%s/remove", collaboratorsPath(ctx)) +} + +func collaboratorsRolePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("%s/change_role", collaboratorsPath(ctx)) +} + +func inviteLinkPath(ctx *common.RuntimeContext, action string) string { + return fmt.Sprintf("/%s/%s/project_invite_links/%s", ctx.Owner, ctx.Repo, action) +} + +func parseUserID(value string) (int, error) { + value = strings.TrimSpace(value) + userID, err := strconv.Atoi(value) + if err != nil || userID <= 0 { + return 0, fmt.Errorf("invalid user ID %q", value) + } + return userID, nil +} + +func normalizeRole(value string) (string, error) { + role, ok := roleAliases[strings.TrimSpace(value)] + if !ok { + return "", fmt.Errorf("invalid --role value %q: use Manager, Developer, or Reporter", value) + } + return role, nil +} + +func normalizeInviteRole(value string) (string, error) { + role, err := normalizeRole(value) + if err != nil { + return "", fmt.Errorf("invalid --role value %q: use manager, developer, or reporter", value) + } + return strings.ToLower(role), nil +} + +func parseBoolArg(name, value string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("invalid --%s value %q: use true or false", name, value) + } +} + +func parseDryRun(value string) bool { + ok, _ := parseBoolArg("dry-run", value) + return ok && strings.TrimSpace(value) != "" +} + +func collectUserIDs(inline, csvPath string) ([]int, error) { + seen := map[int]bool{} + var ids []int + add := func(raw string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + userID, err := parseUserID(raw) + if err != nil { + return err + } + if !seen[userID] { + seen[userID] = true + ids = append(ids, userID) + } + return nil + } + + for _, part := range strings.Split(inline, ",") { + if err := add(part); err != nil { + return nil, err + } + } + if csvPath != "" { + csvIDs, err := readUserIDsFromCSV(csvPath) + if err != nil { + return nil, err + } + for _, userID := range csvIDs { + if !seen[userID] { + seen[userID] = true + ids = append(ids, userID) + } + } + } + return ids, nil +} + +func readUserIDsFromCSV(path string) ([]int, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + rows, err := csv.NewReader(file).ReadAll() + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + + column := 0 + start := 0 + if idx := userIDColumn(rows[0]); idx >= 0 { + column = idx + start = 1 + } + + var ids []int + for _, row := range rows[start:] { + if column >= len(row) { + continue + } + userID, err := parseUserID(row[column]) + if err != nil { + return nil, err + } + ids = append(ids, userID) + } + return ids, nil +} + +func userIDColumn(header []string) int { + for i, name := range header { + switch strings.ToLower(strings.TrimSpace(name)) { + case "user_id", "userid", "id": + return i + } + } + return -1 +} diff --git a/shortcuts/member/member_test.go b/shortcuts/member/member_test.go new file mode 100644 index 0000000..2ab76b4 --- /dev/null +++ b/shortcuts/member/member_test.go @@ -0,0 +1,266 @@ +package member + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestMemberList(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/collaborators.json") + writeJSON(t, w, map[string]interface{}{"total_count": 1, "members": []interface{}{}}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "list", nil); err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestMemberAdd(t *testing.T) { + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "101"}); err != nil { + t.Fatalf("add shortcut failed: %v", err) + } + assertNumber(t, payload["user_id"], 101) +} + +func TestMemberBatchAdd(t *testing.T) { + var seen []int + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload := decodeJSON(t, r) + seen = append(seen, int(payload["user_id"].(float64))) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + csvPath := writeTempCSV(t, "user_id\n102\n103\n") + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + "from": csvPath, + }) + if err != nil { + t.Fatalf("batch-add shortcut failed: %v", err) + } + want := []int{101, 102, 103} + if !reflect.DeepEqual(seen, want) { + t.Fatalf("batch-add user IDs = %v, want %v", seen, want) + } +} + +func TestMemberBatchAddDryRunDoesNotCallAPI(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("batch-add dry-run failed: %v", err) + } +} + +func TestMemberRemove(t *testing.T) { + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/owner/repo/collaborators/remove.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "101"}); err != nil { + t.Fatalf("remove shortcut failed: %v", err) + } + assertNumber(t, payload["user_id"], 101) +} + +func TestMemberRole(t *testing.T) { + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "PUT", "/owner/repo/collaborators/change_role.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "role", map[string]string{ + "user-id": "101", + "role": "developer", + }) + if err != nil { + t.Fatalf("role shortcut failed: %v", err) + } + assertNumber(t, payload["user_id"], 101) + if payload["role"] != "Developer" { + t.Fatalf("role = %v, want Developer", payload["role"]) + } +} + +func TestMemberInviteLink(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/project_invite_links/current_link.json") + if r.URL.Query().Get("role") != "developer" { + t.Fatalf("role query = %q, want developer", r.URL.Query().Get("role")) + } + if r.URL.Query().Get("is_apply") != "false" { + t.Fatalf("is_apply query = %q, want false", r.URL.Query().Get("is_apply")) + } + writeJSON(t, w, map[string]interface{}{"sign": "abc"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "invite-link", map[string]string{ + "role": "developer", + "apply": "false", + }) + if err != nil { + t.Fatalf("invite-link shortcut failed: %v", err) + } +} + +func TestMemberInviteInfo(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/project_invite_links/show_link.json") + if r.URL.Query().Get("invite_sign") != "abc" { + t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign")) + } + writeJSON(t, w, map[string]interface{}{"sign": "abc"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "invite-info", map[string]string{"sign": "abc"}); err != nil { + t.Fatalf("invite-info shortcut failed: %v", err) + } +} + +func TestMemberAcceptInvite(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/project_invite_links/redirect_link.json") + if r.URL.Query().Get("invite_sign") != "abc" { + t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign")) + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "accept-invite", map[string]string{"sign": "abc"}); err != nil { + t.Fatalf("accept-invite shortcut failed: %v", err) + } +} + +func TestCollectUserIDs(t *testing.T) { + csvPath := writeTempCSV(t, "name,id\nfirst,102\nsecond,103\n") + got, err := collectUserIDs("101,102", csvPath) + if err != nil { + t.Fatalf("collectUserIDs returned error: %v", err) + } + want := []int{101, 102, 103} + if !reflect.DeepEqual(got, want) { + t.Fatalf("collectUserIDs() = %v, want %v", got, want) + } +} + +func TestNormalizeRoleRejectsInvalidRole(t *testing.T) { + if _, err := normalizeRole("owner"); err == nil { + t.Fatal("expected invalid role to return an error") + } +} + +func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findMemberShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findMemberShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func newMemberTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func assertRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertNumber(t *testing.T, got interface{}, want int) { + t.Helper() + value, ok := got.(float64) + if !ok { + t.Fatalf("got %v (%T), want JSON number", got, got) + } + if int(value) != want { + t.Fatalf("got %v, want %d", got, want) + } +} + +func writeTempCSV(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "members.csv") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp csv: %v", err) + } + return path +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 84362f5..05a685f 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -7,6 +7,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" + "github.com/gitlink-org/gitlink-cli/shortcuts/member" "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" @@ -21,6 +22,7 @@ func RegisterAll(root *cobra.Command) { groups := map[string][]*common.Shortcut{ "repo": repo.Shortcuts(), "issue": issue.Shortcuts(), + "member": member.Shortcuts(), "pr": pr.Shortcuts(), "release": release.Shortcuts(), "branch": branch.Shortcuts(), @@ -34,6 +36,7 @@ func RegisterAll(root *cobra.Command) { descriptions := map[string]string{ "repo": "Repository operations", "issue": "Issue operations", + "member": "Repository member operations", "pr": "Pull request operations", "release": "Release operations", "branch": "Branch operations", diff --git a/skills/README.md b/skills/README.md index 972254b..b4c6998 100644 --- a/skills/README.md +++ b/skills/README.md @@ -83,6 +83,8 @@ skills/ │ ├── REFERENCE.md # PR API 参考 │ └── examples/ │ └── pr-workflow.md # PR 工作流 +├── gitlink-member/ # 仓库成员管理 +│ └── SKILL.md # 成员与邀请链接操作指南 ├── gitlink-branch/ # 分支管理 │ ├── SKILL.md # 分支操作指南 │ └── examples/ @@ -124,6 +126,7 @@ skills/ | **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` | | **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | +| **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | | **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` | diff --git a/skills/gitlink-member/SKILL.md b/skills/gitlink-member/SKILL.md new file mode 100644 index 0000000..61f8c50 --- /dev/null +++ b/skills/gitlink-member/SKILL.md @@ -0,0 +1,57 @@ +--- +name: gitlink-member +description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,生成、查看和接受项目邀请链接。" +metadata: + cliHelp: "gitlink-cli member --help" +--- + +# gitlink-member(仓库成员管理) + +当用户需要管理 GitLink 仓库成员、成员角色或邀请链接时使用本 Skill。 + +## 常用命令 + +| 命令 | 用途 | +|------|------| +| `member +list` | 列出仓库成员 | +| `member +add` | 通过用户 ID 添加仓库成员 | +| `member +batch-add` | 通过用户 ID 列表或 CSV 批量添加成员 | +| `member +remove` | 通过用户 ID 移除仓库成员 | +| `member +role` | 修改成员角色 | +| `member +invite-link` | 获取或生成当前邀请链接 | +| `member +invite-info` | 查看邀请链接信息 | +| `member +accept-invite` | 接受邀请链接 | + +## 示例 + +```bash +# 列出仓库成员 +gitlink-cli member +list --owner Gitlink --repo forgeplus + +# 添加成员 +gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101 + +# 批量添加前预览 +gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run + +# 从 CSV 批量添加。CSV 支持 user_id、userid、id 列;无表头时读取第一列。 +gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv + +# 修改角色。角色支持 Manager、Developer、Reporter,也支持小写别名。 +gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer + +# 获取或生成当前邀请链接。role 支持 manager、developer、reporter;apply 表示是否需要审核。 +gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true + +# 查看邀请链接信息 +gitlink-cli member +invite-info --owner Gitlink --repo forgeplus --sign + +# 接受邀请链接 +gitlink-cli member +accept-invite --owner Gitlink --repo forgeplus --sign +``` + +## 安全规则 + +- 执行 `member +remove`、`member +role`、`member +add`、`member +batch-add` 前,确认目标仓库和用户 ID。 +- 批量添加前优先使用 `--dry-run` 预览。 +- 避免在公开日志中暴露邀请链接的完整 `sign`。 -- 2.34.1 From 46799d8e567c99906ea54bc47419c6ec587aadd5 Mon Sep 17 00:00:00 2001 From: Jiachen Li <3115250840@qq.com> Date: Mon, 25 May 2026 21:16:04 +0800 Subject: [PATCH 05/13] fix(member): report batch-add partial failures --- shortcuts/member/member.go | 24 ++++++++++++++++++++---- shortcuts/member/member_test.go | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go index 0c8f817..3734da3 100644 --- a/shortcuts/member/member.go +++ b/shortcuts/member/member.go @@ -216,22 +216,38 @@ func runBatchAdd(ctx *common.RuntimeContext) error { } results := make([]map[string]interface{}, 0, len(userIDs)) + succeeded := 0 + failed := 0 for _, userID := range userIDs { env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) result := map[string]interface{}{"user_id": userID} if err != nil { result["ok"] = false result["error"] = err.Error() + failed++ } else { result["ok"] = env.OK result["data"] = env.Data + if env.OK { + succeeded++ + } else { + failed++ + } } results = append(results, result) } - return ctx.OutputData(map[string]interface{}{ - "count": len(userIDs), - "results": results, - }) + if err := ctx.OutputData(map[string]interface{}{ + "count": len(userIDs), + "succeeded": succeeded, + "failed": failed, + "results": results, + }); err != nil { + return err + } + if failed > 0 { + return fmt.Errorf("%d of %d member(s) failed to add", failed, len(userIDs)) + } + return nil } func collaboratorsPath(ctx *common.RuntimeContext) string { diff --git a/shortcuts/member/member_test.go b/shortcuts/member/member_test.go index 2ab76b4..d379e82 100644 --- a/shortcuts/member/member_test.go +++ b/shortcuts/member/member_test.go @@ -79,6 +79,26 @@ func TestMemberBatchAddDryRunDoesNotCallAPI(t *testing.T) { } } +func TestMemberBatchAddReturnsErrorWhenAnyRequestFails(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload := decodeJSON(t, r) + if int(payload["user_id"].(float64)) == 102 { + http.Error(w, "member add failed", http.StatusBadRequest) + return + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + }) + if err == nil { + t.Fatal("expected batch-add to return an error when one request fails") + } +} + func TestMemberRemove(t *testing.T) { var payload map[string]interface{} server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { -- 2.34.1 From 7f0f332f7713fd7878c30df7d3e62fece3dec798 Mon Sep 17 00:00:00 2001 From: fsafasff <2624934035@qq.com> Date: Tue, 26 May 2026 17:11:33 +0800 Subject: [PATCH 06/13] fix(pr): include closed time in view output --- shortcuts/pr/pr.go | 94 +++++++++++++++++++++++++++++++++ shortcuts/pr/pr_test.go | 114 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 203 insertions(+), 5 deletions(-) diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 7f9984c..bf2b628 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -3,6 +3,7 @@ package pr import ( "fmt" "net/url" + "strings" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -84,6 +85,9 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } + if err := enrichPullRequestClosedAt(ctx, env); err != nil { + return err + } return ctx.Output(env) }, }, @@ -370,3 +374,93 @@ func extractIssueID(env *output.Envelope) (int64, error) { } return int64(idFloat), nil } + +func enrichPullRequestClosedAt(ctx *common.RuntimeContext, env *output.Envelope) error { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil + } + pr, ok := data["pull_request"].(map[string]interface{}) + if !ok || !isClosedPullRequest(pr) || stringField(pr, "closed_at") != "" { + return nil + } + issue, ok := data["issue"].(map[string]interface{}) + if !ok { + return nil + } + issueID, ok := numberField(issue, "id") + if !ok { + return nil + } + journalsEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, int64(issueID)), nil) + if err != nil { + return err + } + closedAt := extractPullRequestClosedAt(journalsEnv) + if closedAt == "" { + return nil + } + pr["closed_at"] = closedAt + data["closed_at"] = closedAt + return nil +} + +func isClosedPullRequest(pr map[string]interface{}) bool { + if stringField(pr, "pull_request_staus") == "closed" || stringField(pr, "state") == "closed" { + return true + } + status, ok := numberField(pr, "status") + return ok && int(status) == 2 +} + +func extractPullRequestClosedAt(env *output.Envelope) string { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "" + } + rawJournals, ok := data["journals"].([]interface{}) + if !ok { + return "" + } + for i := len(rawJournals) - 1; i >= 0; i-- { + journal, ok := rawJournals[i].(map[string]interface{}) + if !ok || stringField(journal, "operate_category") != "status" { + continue + } + content := stringField(journal, "operate_content") + if !isPullRequestCloseOperation(content) { + continue + } + if updatedAt := stringField(journal, "updated_at"); updatedAt != "" { + return updatedAt + } + if createdAt := stringField(journal, "created_at"); createdAt != "" { + return createdAt + } + } + return "" +} + +func isPullRequestCloseOperation(content string) bool { + content = strings.ToLower(content) + return strings.Contains(content, "合并请求") && + (strings.Contains(content, "拒绝") || strings.Contains(content, "关闭") || strings.Contains(content, "closed")) +} + +func stringField(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func numberField(m map[string]interface{}, key string) (float64, bool) { + switch v := m[key].(type) { + case float64: + return v, true + case int: + return float64(v), true + case int64: + return float64(v), true + default: + return 0, false + } +} diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index db58cb6..90e5a07 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -53,6 +54,87 @@ func TestPRCommentPostsToCorrectIssueJournal(t *testing.T) { assertEqual(t, journalPayload["notes"], "LGTM, looks good!") } +func TestPRViewAddsClosedAtFromIssueJournal(t *testing.T) { + var issueJournalCalled bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/37.json": + writeJSON(t, w, map[string]interface{}{ + "issue": map[string]interface{}{ + "id": float64(142756), + }, + "pull_request": map[string]interface{}{ + "status": float64(2), + "pull_request_staus": "closed", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/142756/journals.json": + issueJournalCalled = true + writeJSON(t, w, map[string]interface{}{ + "journals": []map[string]interface{}{ + { + "operate_category": "pull_request", + "operate_content": "创建了合并请求", + "created_at": "2026-05-24 21:43", + }, + { + "operate_category": "status", + "operate_content": "拒绝了合并请求", + "created_at": "2026-05-25 08:58", + "updated_at": "2026-05-25 08:58", + }, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{ + "id": "37", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + if !issueJournalCalled { + t.Fatal("issue journal endpoint was not called") + } + data := env.Data.(map[string]interface{}) + assertEqual(t, data["closed_at"], "2026-05-25 08:58") + prData := data["pull_request"].(map[string]interface{}) + assertEqual(t, prData["closed_at"], "2026-05-25 08:58") +} + +func TestPRViewDoesNotFetchJournalsForOpenPR(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/45.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "issue": map[string]interface{}{ + "id": float64(142793), + }, + "pull_request": map[string]interface{}{ + "status": float64(0), + "pull_request_staus": "open", + }, + }) + })) + defer server.Close() + + env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{ + "id": "45", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + data := env.Data.(map[string]interface{}) + if _, ok := data["closed_at"]; ok { + t.Fatal("open PR should not include closed_at") + } +} + func TestPRCommentFailsWhenPRNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -264,19 +346,41 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) { } func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + _, err := runPRShortcutWithOutput(t, server, name, args) + return err +} + +func runPRShortcutWithOutput(t *testing.T, server *httptest.Server, name string, args map[string]string) (*output.Envelope, error) { t.Helper() shortcut := findPRShortcut(t, name) + client := &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + } ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, + Client: client, Owner: "owner", Repo: "repo", Format: "json", Args: args, } - return shortcut.Run(ctx) + err := shortcut.Run(ctx) + if err != nil { + return nil, err + } + if name != "view" { + return nil, nil + } + id := args["id"] + env, err := client.Do("GET", fmt.Sprintf("/owner/repo/pulls/%s", id), nil, nil) + if err != nil { + return nil, err + } + if err := enrichPullRequestClosedAt(ctx, env); err != nil { + return nil, err + } + return env, nil } func findPRShortcut(t *testing.T, name string) *common.Shortcut { -- 2.34.1 From 65cd7f619942c2747d3e62c28e4a74bdd174bcc5 Mon Sep 17 00:00:00 2001 From: fsafasff <2624934035@qq.com> Date: Tue, 26 May 2026 17:54:33 +0800 Subject: [PATCH 07/13] feat(repo): add readme shortcut --- README.md | 3 ++ README.zh-CN.md | 3 ++ internal/client/client.go | 14 ++++++++ internal/client/client_test.go | 27 +++++++++++++++ shortcuts/repo/repo.go | 25 ++++++++++++++ shortcuts/repo/repo_test.go | 63 ++++++++++++++++++++++++++++++++++ 6 files changed, 135 insertions(+) create mode 100644 internal/client/client_test.go create mode 100644 shortcuts/repo/repo_test.go diff --git a/README.md b/README.md index 670808c..9899f1e 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,9 @@ gitlink-cli repo +list # View repository info gitlink-cli repo +info --owner Gitlink --repo forgeplus +# Read repository README +gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master + # Create a repository gitlink-cli repo +create -n my-project -d "Project description" diff --git a/README.zh-CN.md b/README.zh-CN.md index f426948..496b0eb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -162,6 +162,9 @@ gitlink-cli repo +list # 查看仓库信息 gitlink-cli repo +info --owner Gitlink --repo forgeplus +# 读取仓库 README +gitlink-cli repo +readme --owner Gitlink --repo forgeplus --ref master + # 创建仓库 gitlink-cli repo +create -n my-project -d "项目描述" diff --git a/internal/client/client.go b/internal/client/client.go index d147d96..831caa7 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -42,6 +42,8 @@ func New() (*Client, error) { } func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { + path = normalizeAPIPath(c.BaseURL, path) + // Append .json suffix if not already present (GitLink API convention) // Handle paths that may already contain query strings (e.g., /path?key=val) if idx := strings.Index(path, "?"); idx != -1 { @@ -158,6 +160,18 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o return output.SuccessEnvelope(raw, meta), nil } +func normalizeAPIPath(baseURL, path string) string { + if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") { + switch { + case path == "/api": + return "" + case strings.HasPrefix(path, "/api/"): + return strings.TrimPrefix(path, "/api") + } + } + return path +} + func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) { return c.Do("GET", path, nil, query) } diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..87fb91d --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,27 @@ +package client + +import "testing" + +func TestNormalizeAPIPathStripsDuplicateAPIPrefix(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/api/v1/repos/Gitlink/gitlink-cli/contents/README.md") + want := "/v1/repos/Gitlink/gitlink-cli/contents/README.md" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} + +func TestNormalizeAPIPathKeepsRegularPath(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/projects") + want := "/projects" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} + +func TestNormalizeAPIPathKeepsAPIPrefixForNonAPIBaseURL(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn", "/api/v1/repos/Gitlink/gitlink-cli") + want := "/api/v1/repos/Gitlink/gitlink-cli" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 75091a4..4d621ad 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -52,6 +52,31 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "readme", + Description: "Show repository README content", + Flags: []common.Flag{ + {Name: "ref", Usage: "Branch, tag, or commit SHA"}, + {Name: "path", Usage: "README directory path"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } + if path := ctx.Arg("path"); path != "" { + q.Set("filepath", path) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "create", Description: "Create a new repository", diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go new file mode 100644 index 0000000..41305c4 --- /dev/null +++ b/shortcuts/repo/repo_test.go @@ -0,0 +1,63 @@ +package repo + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/readme.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("ref"); got != "main" { + t.Fatalf("ref query = %q, want main", got) + } + if got := r.URL.Query().Get("filepath"); got != "docs" { + t.Fatalf("filepath query = %q, want docs", got) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "file", + "name": "README.md", + "content": "# docs\n", + }); err != nil { + t.Fatalf("write response: %v", err) + } + })) + defer server.Close() + + err := runRepoShortcut(server, "readme", map[string]string{ + "ref": "main", + "path": "docs", + }) + if err != nil { + t.Fatalf("readme shortcut failed: %v", err) + } +} + +func runRepoShortcut(server *httptest.Server, name string, args map[string]string) error { + for _, shortcut := range Shortcuts() { + if shortcut.Name != name { + continue + } + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) + } + return fmt.Errorf("shortcut %q not found", name) +} -- 2.34.1 From 7bcba5aed81c6e95661a5fbaf0b0b0701d27e8fa Mon Sep 17 00:00:00 2001 From: wbtiger <28288271@qq.com> Date: Wed, 27 May 2026 00:24:15 +0800 Subject: [PATCH 08/13] feat(member): add repository member shortcuts (#46) Add member command group with 8 shortcuts: - +list, +add, +batch-add, +remove, +role - +invite-link, +invite-info, +accept-invite Batch add supports --dry-run, CSV file input, and partial failure reporting. 12 tests included. Co-authored-by: Jiachen Li (puygob236) Co-Authored-By: Claude Opus 4.7 --- doc/changes/member-shortcut.md | 14 ++ shortcuts/member/member.go | 392 ++++++++++++++++++++++++++++++++ shortcuts/member/member_test.go | 286 +++++++++++++++++++++++ shortcuts/register.go | 3 + skills/gitlink-member/SKILL.md | 57 +++++ 5 files changed, 752 insertions(+) create mode 100644 doc/changes/member-shortcut.md create mode 100644 shortcuts/member/member.go create mode 100644 shortcuts/member/member_test.go create mode 100644 skills/gitlink-member/SKILL.md diff --git a/doc/changes/member-shortcut.md b/doc/changes/member-shortcut.md new file mode 100644 index 0000000..b3278b0 --- /dev/null +++ b/doc/changes/member-shortcut.md @@ -0,0 +1,14 @@ +# Member Shortcut + +新增 `member` Shortcut 组,支持仓库成员管理和项目邀请链接操作: + +- `member +list` +- `member +add` +- `member +batch-add` +- `member +remove` +- `member +role` +- `member +invite-link` +- `member +invite-info` +- `member +accept-invite` + +同时补充了单元测试、README 示例和 `gitlink-member` Skill 说明。 diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go new file mode 100644 index 0000000..3734da3 --- /dev/null +++ b/shortcuts/member/member.go @@ -0,0 +1,392 @@ +package member + +import ( + "encoding/csv" + "fmt" + "net/url" + "os" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +var roleAliases = map[string]string{ + "manager": "Manager", + "developer": "Developer", + "reporter": "Reporter", + "Manager": "Manager", + "Developer": "Developer", + "Reporter": "Reporter", +} + +// Shortcuts returns repository member management shortcuts. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List repository members", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", collaboratorsPath(ctx), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "add", + Description: "Add a repository member by user ID", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to add", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := parseUserID(ctx.Arg("user-id")) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "batch-add", + Description: "Add multiple repository members by user IDs or a CSV file", + Flags: []common.Flag{ + {Name: "user-ids", Short: "u", Usage: "Comma-separated GitLink user IDs, for example: 101,102"}, + {Name: "from", Usage: "Read user IDs from a CSV file. Supports a user_id/id column or first column without header"}, + {Name: "dry-run", Usage: "Preview members that would be added without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchAdd, + }, + { + Name: "remove", + Description: "Remove a repository member by user ID", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to remove", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := parseUserID(ctx.Arg("user-id")) + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", collaboratorsRemovePath(ctx), map[string]interface{}{"user_id": userID}) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "role", + Description: "Change a repository member role", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to update", Required: true}, + {Name: "role", Short: "r", Usage: "Member role: Manager, Developer, or Reporter", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := parseUserID(ctx.Arg("user-id")) + if err != nil { + return err + } + role, err := normalizeRole(ctx.Arg("role")) + if err != nil { + return err + } + env, err := ctx.CallAPI("PUT", collaboratorsRolePath(ctx), map[string]interface{}{ + "user_id": userID, + "role": role, + }) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "invite-link", + Description: "Get or create a repository invite link", + Flags: []common.Flag{ + {Name: "role", Short: "r", Usage: "Invite role: manager, developer, or reporter", Default: "developer"}, + {Name: "apply", Usage: "Whether joining by invite requires approval: true or false", Default: "true"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + role, err := normalizeInviteRole(ctx.Arg("role")) + if err != nil { + return err + } + apply, err := parseBoolArg("apply", ctx.Arg("apply")) + if err != nil { + return err + } + query := url.Values{} + query.Set("role", role) + query.Set("is_apply", strconv.FormatBool(apply)) + env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "current_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "invite-info", + Description: "Show repository invite link information", + Flags: []common.Flag{ + {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sign, err := ctx.RequireArg("sign") + if err != nil { + return err + } + query := url.Values{} + query.Set("invite_sign", sign) + env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "show_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "accept-invite", + Description: "Accept a repository invite link", + Flags: []common.Flag{ + {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sign, err := ctx.RequireArg("sign") + if err != nil { + return err + } + query := url.Values{} + query.Set("invite_sign", sign) + env, err := ctx.CallAPIWithQuery("POST", inviteLinkPath(ctx, "redirect_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func runBatchAdd(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userIDs, err := collectUserIDs(ctx.Arg("user-ids"), ctx.Arg("from")) + if err != nil { + return err + } + if len(userIDs) == 0 { + return fmt.Errorf("provide --user-ids or --from") + } + if parseDryRun(ctx.Arg("dry-run")) { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "user_ids": userIDs, + "count": len(userIDs), + }) + } + + results := make([]map[string]interface{}, 0, len(userIDs)) + succeeded := 0 + failed := 0 + for _, userID := range userIDs { + env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) + result := map[string]interface{}{"user_id": userID} + if err != nil { + result["ok"] = false + result["error"] = err.Error() + failed++ + } else { + result["ok"] = env.OK + result["data"] = env.Data + if env.OK { + succeeded++ + } else { + failed++ + } + } + results = append(results, result) + } + if err := ctx.OutputData(map[string]interface{}{ + "count": len(userIDs), + "succeeded": succeeded, + "failed": failed, + "results": results, + }); err != nil { + return err + } + if failed > 0 { + return fmt.Errorf("%d of %d member(s) failed to add", failed, len(userIDs)) + } + return nil +} + +func collaboratorsPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo) +} + +func collaboratorsRemovePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("%s/remove", collaboratorsPath(ctx)) +} + +func collaboratorsRolePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("%s/change_role", collaboratorsPath(ctx)) +} + +func inviteLinkPath(ctx *common.RuntimeContext, action string) string { + return fmt.Sprintf("/%s/%s/project_invite_links/%s", ctx.Owner, ctx.Repo, action) +} + +func parseUserID(value string) (int, error) { + value = strings.TrimSpace(value) + userID, err := strconv.Atoi(value) + if err != nil || userID <= 0 { + return 0, fmt.Errorf("invalid user ID %q", value) + } + return userID, nil +} + +func normalizeRole(value string) (string, error) { + role, ok := roleAliases[strings.TrimSpace(value)] + if !ok { + return "", fmt.Errorf("invalid --role value %q: use Manager, Developer, or Reporter", value) + } + return role, nil +} + +func normalizeInviteRole(value string) (string, error) { + role, err := normalizeRole(value) + if err != nil { + return "", fmt.Errorf("invalid --role value %q: use manager, developer, or reporter", value) + } + return strings.ToLower(role), nil +} + +func parseBoolArg(name, value string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("invalid --%s value %q: use true or false", name, value) + } +} + +func parseDryRun(value string) bool { + ok, _ := parseBoolArg("dry-run", value) + return ok && strings.TrimSpace(value) != "" +} + +func collectUserIDs(inline, csvPath string) ([]int, error) { + seen := map[int]bool{} + var ids []int + add := func(raw string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + userID, err := parseUserID(raw) + if err != nil { + return err + } + if !seen[userID] { + seen[userID] = true + ids = append(ids, userID) + } + return nil + } + + for _, part := range strings.Split(inline, ",") { + if err := add(part); err != nil { + return nil, err + } + } + if csvPath != "" { + csvIDs, err := readUserIDsFromCSV(csvPath) + if err != nil { + return nil, err + } + for _, userID := range csvIDs { + if !seen[userID] { + seen[userID] = true + ids = append(ids, userID) + } + } + } + return ids, nil +} + +func readUserIDsFromCSV(path string) ([]int, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + rows, err := csv.NewReader(file).ReadAll() + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + + column := 0 + start := 0 + if idx := userIDColumn(rows[0]); idx >= 0 { + column = idx + start = 1 + } + + var ids []int + for _, row := range rows[start:] { + if column >= len(row) { + continue + } + userID, err := parseUserID(row[column]) + if err != nil { + return nil, err + } + ids = append(ids, userID) + } + return ids, nil +} + +func userIDColumn(header []string) int { + for i, name := range header { + switch strings.ToLower(strings.TrimSpace(name)) { + case "user_id", "userid", "id": + return i + } + } + return -1 +} diff --git a/shortcuts/member/member_test.go b/shortcuts/member/member_test.go new file mode 100644 index 0000000..d379e82 --- /dev/null +++ b/shortcuts/member/member_test.go @@ -0,0 +1,286 @@ +package member + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestMemberList(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/collaborators.json") + writeJSON(t, w, map[string]interface{}{"total_count": 1, "members": []interface{}{}}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "list", nil); err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestMemberAdd(t *testing.T) { + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "101"}); err != nil { + t.Fatalf("add shortcut failed: %v", err) + } + assertNumber(t, payload["user_id"], 101) +} + +func TestMemberBatchAdd(t *testing.T) { + var seen []int + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload := decodeJSON(t, r) + seen = append(seen, int(payload["user_id"].(float64))) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + csvPath := writeTempCSV(t, "user_id\n102\n103\n") + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + "from": csvPath, + }) + if err != nil { + t.Fatalf("batch-add shortcut failed: %v", err) + } + want := []int{101, 102, 103} + if !reflect.DeepEqual(seen, want) { + t.Fatalf("batch-add user IDs = %v, want %v", seen, want) + } +} + +func TestMemberBatchAddDryRunDoesNotCallAPI(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("batch-add dry-run failed: %v", err) + } +} + +func TestMemberBatchAddReturnsErrorWhenAnyRequestFails(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload := decodeJSON(t, r) + if int(payload["user_id"].(float64)) == 102 { + http.Error(w, "member add failed", http.StatusBadRequest) + return + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + }) + if err == nil { + t.Fatal("expected batch-add to return an error when one request fails") + } +} + +func TestMemberRemove(t *testing.T) { + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/owner/repo/collaborators/remove.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "101"}); err != nil { + t.Fatalf("remove shortcut failed: %v", err) + } + assertNumber(t, payload["user_id"], 101) +} + +func TestMemberRole(t *testing.T) { + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "PUT", "/owner/repo/collaborators/change_role.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "role", map[string]string{ + "user-id": "101", + "role": "developer", + }) + if err != nil { + t.Fatalf("role shortcut failed: %v", err) + } + assertNumber(t, payload["user_id"], 101) + if payload["role"] != "Developer" { + t.Fatalf("role = %v, want Developer", payload["role"]) + } +} + +func TestMemberInviteLink(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/project_invite_links/current_link.json") + if r.URL.Query().Get("role") != "developer" { + t.Fatalf("role query = %q, want developer", r.URL.Query().Get("role")) + } + if r.URL.Query().Get("is_apply") != "false" { + t.Fatalf("is_apply query = %q, want false", r.URL.Query().Get("is_apply")) + } + writeJSON(t, w, map[string]interface{}{"sign": "abc"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "invite-link", map[string]string{ + "role": "developer", + "apply": "false", + }) + if err != nil { + t.Fatalf("invite-link shortcut failed: %v", err) + } +} + +func TestMemberInviteInfo(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/project_invite_links/show_link.json") + if r.URL.Query().Get("invite_sign") != "abc" { + t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign")) + } + writeJSON(t, w, map[string]interface{}{"sign": "abc"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "invite-info", map[string]string{"sign": "abc"}); err != nil { + t.Fatalf("invite-info shortcut failed: %v", err) + } +} + +func TestMemberAcceptInvite(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/project_invite_links/redirect_link.json") + if r.URL.Query().Get("invite_sign") != "abc" { + t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign")) + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "accept-invite", map[string]string{"sign": "abc"}); err != nil { + t.Fatalf("accept-invite shortcut failed: %v", err) + } +} + +func TestCollectUserIDs(t *testing.T) { + csvPath := writeTempCSV(t, "name,id\nfirst,102\nsecond,103\n") + got, err := collectUserIDs("101,102", csvPath) + if err != nil { + t.Fatalf("collectUserIDs returned error: %v", err) + } + want := []int{101, 102, 103} + if !reflect.DeepEqual(got, want) { + t.Fatalf("collectUserIDs() = %v, want %v", got, want) + } +} + +func TestNormalizeRoleRejectsInvalidRole(t *testing.T) { + if _, err := normalizeRole("owner"); err == nil { + t.Fatal("expected invalid role to return an error") + } +} + +func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findMemberShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findMemberShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func newMemberTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func assertRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertNumber(t *testing.T, got interface{}, want int) { + t.Helper() + value, ok := got.(float64) + if !ok { + t.Fatalf("got %v (%T), want JSON number", got, got) + } + if int(value) != want { + t.Fatalf("got %v, want %d", got, want) + } +} + +func writeTempCSV(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "members.csv") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp csv: %v", err) + } + return path +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 943b6dd..91cf569 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -7,6 +7,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" + "github.com/gitlink-org/gitlink-cli/shortcuts/member" "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" @@ -22,6 +23,7 @@ func RegisterAll(root *cobra.Command) { groups := map[string][]*common.Shortcut{ "repo": repo.Shortcuts(), "issue": issue.Shortcuts(), + "member": member.Shortcuts(), "pr": pr.Shortcuts(), "release": release.Shortcuts(), "branch": branch.Shortcuts(), @@ -36,6 +38,7 @@ func RegisterAll(root *cobra.Command) { descriptions := map[string]string{ "repo": "Repository operations", "issue": "Issue operations", + "member": "Repository member operations", "pr": "Pull request operations", "release": "Release operations", "branch": "Branch operations", diff --git a/skills/gitlink-member/SKILL.md b/skills/gitlink-member/SKILL.md new file mode 100644 index 0000000..61f8c50 --- /dev/null +++ b/skills/gitlink-member/SKILL.md @@ -0,0 +1,57 @@ +--- +name: gitlink-member +description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,生成、查看和接受项目邀请链接。" +metadata: + cliHelp: "gitlink-cli member --help" +--- + +# gitlink-member(仓库成员管理) + +当用户需要管理 GitLink 仓库成员、成员角色或邀请链接时使用本 Skill。 + +## 常用命令 + +| 命令 | 用途 | +|------|------| +| `member +list` | 列出仓库成员 | +| `member +add` | 通过用户 ID 添加仓库成员 | +| `member +batch-add` | 通过用户 ID 列表或 CSV 批量添加成员 | +| `member +remove` | 通过用户 ID 移除仓库成员 | +| `member +role` | 修改成员角色 | +| `member +invite-link` | 获取或生成当前邀请链接 | +| `member +invite-info` | 查看邀请链接信息 | +| `member +accept-invite` | 接受邀请链接 | + +## 示例 + +```bash +# 列出仓库成员 +gitlink-cli member +list --owner Gitlink --repo forgeplus + +# 添加成员 +gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101 + +# 批量添加前预览 +gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run + +# 从 CSV 批量添加。CSV 支持 user_id、userid、id 列;无表头时读取第一列。 +gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv + +# 修改角色。角色支持 Manager、Developer、Reporter,也支持小写别名。 +gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer + +# 获取或生成当前邀请链接。role 支持 manager、developer、reporter;apply 表示是否需要审核。 +gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true + +# 查看邀请链接信息 +gitlink-cli member +invite-info --owner Gitlink --repo forgeplus --sign + +# 接受邀请链接 +gitlink-cli member +accept-invite --owner Gitlink --repo forgeplus --sign +``` + +## 安全规则 + +- 执行 `member +remove`、`member +role`、`member +add`、`member +batch-add` 前,确认目标仓库和用户 ID。 +- 批量添加前优先使用 `--dry-run` 预览。 +- 避免在公开日志中暴露邀请链接的完整 `sign`。 -- 2.34.1 From 36249f30877613bf778589fb45c4b1042a02189d Mon Sep 17 00:00:00 2001 From: wbtiger <28288271@qq.com> Date: Wed, 27 May 2026 00:35:31 +0800 Subject: [PATCH 09/13] feat: add PR closed time and repo README shortcuts (#49, #52) PR #49 (dtwdtw): enrich PR view with closed_at from issue journals - Fetches close timestamp for closed/rejected PRs - Skips extra API call for open PRs - 2 new tests PR #52 (dtwdtw): add repo +readme shortcut - Fetches README content with optional --ref and --path params - normalizeAPIPath prevents duplicate /api prefix in client - 4 new tests (3 client + 1 repo) Co-authored-by: dtwdtw Co-Authored-By: Claude Opus 4.7 --- internal/client/client.go | 14 ++++ internal/client/client_test.go | 27 ++++++++ shortcuts/pr/pr.go | 94 +++++++++++++++++++++++++++ shortcuts/pr/pr_test.go | 114 +++++++++++++++++++++++++++++++-- shortcuts/repo/repo.go | 25 ++++++++ shortcuts/repo/repo_test.go | 63 ++++++++++++++++++ 6 files changed, 332 insertions(+), 5 deletions(-) create mode 100644 internal/client/client_test.go create mode 100644 shortcuts/repo/repo_test.go diff --git a/internal/client/client.go b/internal/client/client.go index d147d96..831caa7 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -42,6 +42,8 @@ func New() (*Client, error) { } func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { + path = normalizeAPIPath(c.BaseURL, path) + // Append .json suffix if not already present (GitLink API convention) // Handle paths that may already contain query strings (e.g., /path?key=val) if idx := strings.Index(path, "?"); idx != -1 { @@ -158,6 +160,18 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o return output.SuccessEnvelope(raw, meta), nil } +func normalizeAPIPath(baseURL, path string) string { + if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") { + switch { + case path == "/api": + return "" + case strings.HasPrefix(path, "/api/"): + return strings.TrimPrefix(path, "/api") + } + } + return path +} + func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) { return c.Do("GET", path, nil, query) } diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..87fb91d --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,27 @@ +package client + +import "testing" + +func TestNormalizeAPIPathStripsDuplicateAPIPrefix(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/api/v1/repos/Gitlink/gitlink-cli/contents/README.md") + want := "/v1/repos/Gitlink/gitlink-cli/contents/README.md" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} + +func TestNormalizeAPIPathKeepsRegularPath(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/projects") + want := "/projects" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} + +func TestNormalizeAPIPathKeepsAPIPrefixForNonAPIBaseURL(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn", "/api/v1/repos/Gitlink/gitlink-cli") + want := "/api/v1/repos/Gitlink/gitlink-cli" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 7f9984c..bf2b628 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -3,6 +3,7 @@ package pr import ( "fmt" "net/url" + "strings" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -84,6 +85,9 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } + if err := enrichPullRequestClosedAt(ctx, env); err != nil { + return err + } return ctx.Output(env) }, }, @@ -370,3 +374,93 @@ func extractIssueID(env *output.Envelope) (int64, error) { } return int64(idFloat), nil } + +func enrichPullRequestClosedAt(ctx *common.RuntimeContext, env *output.Envelope) error { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil + } + pr, ok := data["pull_request"].(map[string]interface{}) + if !ok || !isClosedPullRequest(pr) || stringField(pr, "closed_at") != "" { + return nil + } + issue, ok := data["issue"].(map[string]interface{}) + if !ok { + return nil + } + issueID, ok := numberField(issue, "id") + if !ok { + return nil + } + journalsEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, int64(issueID)), nil) + if err != nil { + return err + } + closedAt := extractPullRequestClosedAt(journalsEnv) + if closedAt == "" { + return nil + } + pr["closed_at"] = closedAt + data["closed_at"] = closedAt + return nil +} + +func isClosedPullRequest(pr map[string]interface{}) bool { + if stringField(pr, "pull_request_staus") == "closed" || stringField(pr, "state") == "closed" { + return true + } + status, ok := numberField(pr, "status") + return ok && int(status) == 2 +} + +func extractPullRequestClosedAt(env *output.Envelope) string { + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "" + } + rawJournals, ok := data["journals"].([]interface{}) + if !ok { + return "" + } + for i := len(rawJournals) - 1; i >= 0; i-- { + journal, ok := rawJournals[i].(map[string]interface{}) + if !ok || stringField(journal, "operate_category") != "status" { + continue + } + content := stringField(journal, "operate_content") + if !isPullRequestCloseOperation(content) { + continue + } + if updatedAt := stringField(journal, "updated_at"); updatedAt != "" { + return updatedAt + } + if createdAt := stringField(journal, "created_at"); createdAt != "" { + return createdAt + } + } + return "" +} + +func isPullRequestCloseOperation(content string) bool { + content = strings.ToLower(content) + return strings.Contains(content, "合并请求") && + (strings.Contains(content, "拒绝") || strings.Contains(content, "关闭") || strings.Contains(content, "closed")) +} + +func stringField(m map[string]interface{}, key string) string { + v, _ := m[key].(string) + return v +} + +func numberField(m map[string]interface{}, key string) (float64, bool) { + switch v := m[key].(type) { + case float64: + return v, true + case int: + return float64(v), true + case int64: + return float64(v), true + default: + return 0, false + } +} diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index db58cb6..90e5a07 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -53,6 +54,87 @@ func TestPRCommentPostsToCorrectIssueJournal(t *testing.T) { assertEqual(t, journalPayload["notes"], "LGTM, looks good!") } +func TestPRViewAddsClosedAtFromIssueJournal(t *testing.T) { + var issueJournalCalled bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/37.json": + writeJSON(t, w, map[string]interface{}{ + "issue": map[string]interface{}{ + "id": float64(142756), + }, + "pull_request": map[string]interface{}{ + "status": float64(2), + "pull_request_staus": "closed", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/142756/journals.json": + issueJournalCalled = true + writeJSON(t, w, map[string]interface{}{ + "journals": []map[string]interface{}{ + { + "operate_category": "pull_request", + "operate_content": "创建了合并请求", + "created_at": "2026-05-24 21:43", + }, + { + "operate_category": "status", + "operate_content": "拒绝了合并请求", + "created_at": "2026-05-25 08:58", + "updated_at": "2026-05-25 08:58", + }, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{ + "id": "37", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + if !issueJournalCalled { + t.Fatal("issue journal endpoint was not called") + } + data := env.Data.(map[string]interface{}) + assertEqual(t, data["closed_at"], "2026-05-25 08:58") + prData := data["pull_request"].(map[string]interface{}) + assertEqual(t, prData["closed_at"], "2026-05-25 08:58") +} + +func TestPRViewDoesNotFetchJournalsForOpenPR(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/45.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{ + "issue": map[string]interface{}{ + "id": float64(142793), + }, + "pull_request": map[string]interface{}{ + "status": float64(0), + "pull_request_staus": "open", + }, + }) + })) + defer server.Close() + + env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{ + "id": "45", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + data := env.Data.(map[string]interface{}) + if _, ok := data["closed_at"]; ok { + t.Fatal("open PR should not include closed_at") + } +} + func TestPRCommentFailsWhenPRNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -264,19 +346,41 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) { } func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + _, err := runPRShortcutWithOutput(t, server, name, args) + return err +} + +func runPRShortcutWithOutput(t *testing.T, server *httptest.Server, name string, args map[string]string) (*output.Envelope, error) { t.Helper() shortcut := findPRShortcut(t, name) + client := &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + } ctx := &common.RuntimeContext{ - Client: &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - }, + Client: client, Owner: "owner", Repo: "repo", Format: "json", Args: args, } - return shortcut.Run(ctx) + err := shortcut.Run(ctx) + if err != nil { + return nil, err + } + if name != "view" { + return nil, nil + } + id := args["id"] + env, err := client.Do("GET", fmt.Sprintf("/owner/repo/pulls/%s", id), nil, nil) + if err != nil { + return nil, err + } + if err := enrichPullRequestClosedAt(ctx, env); err != nil { + return nil, err + } + return env, nil } func findPRShortcut(t *testing.T, name string) *common.Shortcut { diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 75091a4..4d621ad 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -52,6 +52,31 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "readme", + Description: "Show repository README content", + Flags: []common.Flag{ + {Name: "ref", Usage: "Branch, tag, or commit SHA"}, + {Name: "path", Usage: "README directory path"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } + if path := ctx.Arg("path"); path != "" { + q.Set("filepath", path) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "create", Description: "Create a new repository", diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go new file mode 100644 index 0000000..41305c4 --- /dev/null +++ b/shortcuts/repo/repo_test.go @@ -0,0 +1,63 @@ +package repo + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/readme.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("ref"); got != "main" { + t.Fatalf("ref query = %q, want main", got) + } + if got := r.URL.Query().Get("filepath"); got != "docs" { + t.Fatalf("filepath query = %q, want docs", got) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "file", + "name": "README.md", + "content": "# docs\n", + }); err != nil { + t.Fatalf("write response: %v", err) + } + })) + defer server.Close() + + err := runRepoShortcut(server, "readme", map[string]string{ + "ref": "main", + "path": "docs", + }) + if err != nil { + t.Fatalf("readme shortcut failed: %v", err) + } +} + +func runRepoShortcut(server *httptest.Server, name string, args map[string]string) error { + for _, shortcut := range Shortcuts() { + if shortcut.Name != name { + continue + } + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) + } + return fmt.Errorf("shortcut %q not found", name) +} -- 2.34.1 From 898b7f59d1f5e22d3767a9730b4c366b855c20c6 Mon Sep 17 00:00:00 2001 From: wbtiger <28288271@qq.com> Date: Wed, 27 May 2026 00:44:39 +0800 Subject: [PATCH 10/13] feat: add milestone, compare, pr reopen shortcuts and api enhancements (#45) - milestone shortcuts: list, create, view, update, delete, close, reopen - compare shortcuts: view, files (compare branches/tags/commits) - pr +reopen: reopen closed pull requests - api: add --body-file and --body-stdin for reading JSON from file/stdin - comprehensive tests for all new shortcuts Co-authored-by: wangyue111 Co-Authored-By: Claude Opus 4.7 --- cmd/api/api.go | 59 ++++- cmd/api/api_test.go | 90 +++++++ doc/changes/milestone-shortcut.md | 19 ++ shortcuts/compare/compare.go | 83 ++++++ shortcuts/compare/compare_test.go | 107 ++++++++ shortcuts/milestone/milestone.go | 239 ++++++++++++++++++ shortcuts/milestone/milestone_test.go | 207 +++++++++++++++ shortcuts/pr/pr.go | 115 ++------- shortcuts/pr/pr_test.go | 135 ++-------- shortcuts/register.go | 18 +- skills/gitlink-compare/SKILL.md | 40 +++ skills/gitlink-milestone/SKILL.md | 66 +++++ .../references/gitlink-pr-reopen.md | 30 +++ 13 files changed, 993 insertions(+), 215 deletions(-) create mode 100644 cmd/api/api_test.go create mode 100644 doc/changes/milestone-shortcut.md create mode 100644 shortcuts/compare/compare.go create mode 100644 shortcuts/compare/compare_test.go create mode 100644 shortcuts/milestone/milestone.go create mode 100644 shortcuts/milestone/milestone_test.go create mode 100644 skills/gitlink-compare/SKILL.md create mode 100644 skills/gitlink-milestone/SKILL.md create mode 100644 skills/gitlink-pr/references/gitlink-pr-reopen.md diff --git a/cmd/api/api.go b/cmd/api/api.go index af0593d..d13cf52 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -3,7 +3,9 @@ package api import ( "encoding/json" "fmt" + "io" "net/url" + "os" "strings" "github.com/spf13/cobra" @@ -20,12 +22,15 @@ func NewAPICmd() *cobra.Command { Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`, Example: ` gitlink-cli api GET /users/me gitlink-cli api GET /projects --query 'page=1&limit=10' - gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`, + gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' + gitlink-cli api POST /:owner/:repo/issues --body-file issue.json`, Args: cobra.ExactArgs(2), RunE: runAPI, } apiCmd.Flags().String("body", "", "Request body (JSON string)") + apiCmd.Flags().String("body-file", "", "Read request body JSON from a file") + apiCmd.Flags().Bool("body-stdin", false, "Read request body JSON from stdin") apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)") apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)") @@ -46,12 +51,9 @@ func runAPI(c *cobra.Command, args []string) error { } cli.Debug = cmdutil.Debug - var body interface{} - bodyStr, _ := c.Flags().GetString("body") - if bodyStr != "" { - if err := json.Unmarshal([]byte(bodyStr), &body); err != nil { - return fmt.Errorf("invalid JSON body: %w", err) - } + body, err := readJSONBody(c) + if err != nil { + return err } var query url.Values @@ -76,6 +78,49 @@ func runAPI(c *cobra.Command, args []string) error { return output.Print(env, resolveFormat()) } +func readJSONBody(c *cobra.Command) (interface{}, error) { + bodyStr, _ := c.Flags().GetString("body") + bodyFile, _ := c.Flags().GetString("body-file") + bodyStdin, _ := c.Flags().GetBool("body-stdin") + + sources := 0 + if bodyStr != "" { + sources++ + } + if bodyFile != "" { + sources++ + } + if bodyStdin { + sources++ + } + if sources == 0 { + return nil, nil + } + if sources > 1 { + return nil, fmt.Errorf("use only one of --body, --body-file, or --body-stdin") + } + + var data []byte + var err error + switch { + case bodyStr != "": + data = []byte(bodyStr) + case bodyFile != "": + data, err = os.ReadFile(bodyFile) + case bodyStdin: + data, err = io.ReadAll(c.InOrStdin()) + } + if err != nil { + return nil, fmt.Errorf("read JSON body: %w", err) + } + + var body interface{} + if err := json.Unmarshal(data, &body); err != nil { + return nil, fmt.Errorf("invalid JSON body: %w", err) + } + return body, nil +} + func resolveFormat() string { f := cmdutil.Format if f == "" { diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go new file mode 100644 index 0000000..df9a104 --- /dev/null +++ b/cmd/api/api_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadJSONBodyFromInlineFlag(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body", `{"title":"hello","count":2}`) + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + values := body.(map[string]interface{}) + if values["title"] != "hello" { + t.Fatalf("title = %v, want hello", values["title"]) + } + if values["count"] != float64(2) { + t.Fatalf("count = %v, want 2", values["count"]) + } +} + +func TestReadJSONBodyFromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "body.json") + if err := os.WriteFile(path, []byte(`{"description":"来自文件"}`), 0o600); err != nil { + t.Fatalf("write body file: %v", err) + } + + cmd := NewAPICmd() + cmd.Flags().Set("body-file", path) + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + values := body.(map[string]interface{}) + if values["description"] != "来自文件" { + t.Fatalf("description = %v, want 来自文件", values["description"]) + } +} + +func TestReadJSONBodyFromStdin(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body-stdin", "true") + cmd.SetIn(strings.NewReader(`{"notes":"from stdin"}`)) + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + values := body.(map[string]interface{}) + if values["notes"] != "from stdin" { + t.Fatalf("notes = %v, want from stdin", values["notes"]) + } +} + +func TestReadJSONBodyRejectsMultipleSources(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body", `{"title":"hello"}`) + cmd.Flags().Set("body-stdin", "true") + + if _, err := readJSONBody(cmd); err == nil { + t.Fatal("expected multiple body sources to return an error") + } +} + +func TestReadJSONBodyRejectsInvalidJSON(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body", `{"title":`) + + if _, err := readJSONBody(cmd); err == nil { + t.Fatal("expected invalid JSON to return an error") + } +} + +func TestReadJSONBodyWithoutSource(t *testing.T) { + cmd := NewAPICmd() + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + if body != nil { + t.Fatalf("body = %v, want nil", body) + } +} diff --git a/doc/changes/milestone-shortcut.md b/doc/changes/milestone-shortcut.md new file mode 100644 index 0000000..a0e569b --- /dev/null +++ b/doc/changes/milestone-shortcut.md @@ -0,0 +1,19 @@ +# Milestone shortcut + +新增 `milestone` Shortcut 组,补齐 GitLink 里程碑 OpenAPI 的常用操作封装: + +- `milestone +list` +- `milestone +create` +- `milestone +view` +- `milestone +update` +- `milestone +delete` +- `milestone +close` +- `milestone +reopen` + +实现要点: + +- 支持列表筛选、分页、排序,以及详情页关联 Issue 过滤参数。 +- 写入时将 CLI 参数 `--due-date` 映射为 API 字段 `effective_date`。 +- `+update` 在没有任何变更字段时直接报错,避免发送空更新。 +- `+close` 和 `+reopen` 使用 GitLink 的 milestone 状态更新接口。 +- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数和 payload。 diff --git a/shortcuts/compare/compare.go b/shortcuts/compare/compare.go new file mode 100644 index 0000000..b39937a --- /dev/null +++ b/shortcuts/compare/compare.go @@ -0,0 +1,83 @@ +package compare + +import ( + "encoding/base64" + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "view", + Description: "Compare two branches, tags, or commits", + Flags: []common.Flag{ + {Name: "head", Usage: "Source branch, tag, or commit", Required: true}, + {Name: "base", Usage: "Target branch, tag, or commit", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, err := ctx.RequireArg("head") + if err != nil { + return err + } + base, err := ctx.RequireArg("base") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", comparePath(ctx, head, base), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "files", + Description: "List changed files between two refs", + Flags: []common.Flag{ + {Name: "head", Usage: "Source branch, tag, or commit", Required: true}, + {Name: "base", Usage: "Target branch, tag, or commit", Required: true}, + {Name: "file", Short: "f", Usage: "Filter by file path"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, err := ctx.RequireArg("head") + if err != nil { + return err + } + base, err := ctx.RequireArg("base") + if err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if file := ctx.Arg("file"); file != "" { + q.Set("filepath", file) + } + env, err := ctx.CallAPIWithQuery("GET", "/v1"+comparePath(ctx, head, base)+"/files", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func comparePath(ctx *common.RuntimeContext, head, base string) string { + return fmt.Sprintf("%s/compare/%s...%s", ctx.RepoPath(), encodeRef(head), encodeRef(base)) +} + +func encodeRef(ref string) string { + return base64.RawURLEncoding.EncodeToString([]byte(ref)) +} diff --git a/shortcuts/compare/compare_test.go b/shortcuts/compare/compare_test.go new file mode 100644 index 0000000..d10b9f5 --- /dev/null +++ b/shortcuts/compare/compare_test.go @@ -0,0 +1,107 @@ +package compare + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestCompareViewEncodesRefs(t *testing.T) { + var calledPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calledPath = r.URL.Path + if r.Method != "GET" || r.URL.Path != "/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"commits_count": 1}) + })) + defer server.Close() + + err := runCompareShortcut(t, server, "view", map[string]string{ + "head": "feature/api", + "base": "master", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + if calledPath == "" { + t.Fatal("server was not called") + } +} + +func TestCompareFilesUsesV1EndpointWithFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/compare/YnVnZml4...cmVsZWFzZS92MQ/files.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("filepath"); got != "cmd/api/api.go" { + t.Fatalf("filepath query = %q, want cmd/api/api.go", got) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Fatalf("page query = %q, want 2", got) + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Fatalf("limit query = %q, want 50", got) + } + writeJSON(t, w, map[string]interface{}{"files": []interface{}{}}) + })) + defer server.Close() + + err := runCompareShortcut(t, server, "files", map[string]string{ + "head": "bugfix", + "base": "release/v1", + "file": "cmd/api/api.go", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("files shortcut failed: %v", err) + } +} + +func TestEncodeRefUsesRawURLBase64(t *testing.T) { + got := encodeRef("feature/api") + want := "ZmVhdHVyZS9hcGk" + if got != want { + t.Fatalf("encodeRef() = %q, want %q", got, want) + } +} + +func runCompareShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findCompareShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) +} + +func findCompareShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go new file mode 100644 index 0000000..1ec2b30 --- /dev/null +++ b/shortcuts/milestone/milestone.go @@ -0,0 +1,239 @@ +package milestone + +import ( + "fmt" + "net/url" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List milestones", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + {Name: "category", Short: "c", Usage: "Filter by category: opening, closed"}, + {Name: "only-name", Usage: "Return only milestone id and name: true or false"}, + {Name: "sort-by", Usage: "Sort field: created_on, updated_on, effective_date, issues_count, percent"}, + {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + setQueryIfPresent(q, "keyword", ctx.Arg("keyword")) + setQueryIfPresent(q, "category", ctx.Arg("category")) + setQueryIfPresent(q, "only_name", ctx.Arg("only-name")) + setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) + setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) + env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a milestone", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Milestone name", Required: true}, + {Name: "description", Short: "d", Usage: "Milestone description", Required: true}, + {Name: "due-date", Usage: "Due date in YYYY-MM-DD format", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload, err := milestonePayload(ctx, true) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", milestonePath(ctx), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "view", + Description: "View milestone details and linked issues", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + {Name: "category", Short: "c", Usage: "Filter issues by category: all, opened, closed"}, + {Name: "author-id", Usage: "Filter issues by author ID"}, + {Name: "assigner-id", Usage: "Filter issues by assignee ID"}, + {Name: "issue-tag-ids", Usage: "Comma-separated issue tag IDs"}, + {Name: "sort-by", Usage: "Sort field: issues.created_on, issues.updated_on, issue_priorities.position"}, + {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + setQueryIfPresent(q, "category", ctx.Arg("category")) + setQueryIfPresent(q, "author_id", ctx.Arg("author-id")) + setQueryIfPresent(q, "assigner_id", ctx.Arg("assigner-id")) + setQueryIfPresent(q, "issue_tag_ids", normalizeCSV(ctx.Arg("issue-tag-ids"))) + setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) + setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) + env, err := ctx.CallAPIWithQuery("GET", milestoneItemPath(ctx, id), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update a milestone", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + {Name: "name", Short: "n", Usage: "Milestone name"}, + {Name: "description", Short: "d", Usage: "Milestone description"}, + {Name: "due-date", Usage: "Due date in YYYY-MM-DD format"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + payload, err := milestonePayload(ctx, false) + if err != nil { + return err + } + env, err := ctx.CallAPI("PATCH", milestoneItemPath(ctx, id), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a milestone", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", milestoneItemPath(ctx, id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + newStatusShortcut("close", "Close a milestone", "closed"), + newStatusShortcut("reopen", "Reopen a milestone", "open"), + } +} + +func milestonePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo) +} + +func milestoneItemPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/%s", milestonePath(ctx), url.PathEscape(id)) +} + +func milestoneStatusPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/milestones/%s/update_status", ctx.RepoPath(), url.PathEscape(id)) +} + +func milestonePayload(ctx *common.RuntimeContext, requireAll bool) (map[string]interface{}, error) { + payload := map[string]interface{}{} + if name := ctx.Arg("name"); name != "" { + payload["name"] = name + } + if description := ctx.Arg("description"); description != "" { + payload["description"] = description + } + if dueDate := ctx.Arg("due-date"); dueDate != "" { + payload["effective_date"] = dueDate + } + + if requireAll { + for _, name := range []string{"name", "description", "due-date"} { + if _, err := ctx.RequireArg(name); err != nil { + return nil, err + } + } + return payload, nil + } + if len(payload) == 0 { + return nil, fmt.Errorf("at least one of --name, --description, or --due-date is required") + } + return payload, nil +} + +func newStatusShortcut(name, description, status string) *common.Shortcut { + return &common.Shortcut{ + Name: name, + Description: description, + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", milestoneStatusPath(ctx, id), map[string]interface{}{ + "status": status, + }) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func setQueryIfPresent(q url.Values, name, value string) { + if value != "" { + q.Set(name, value) + } +} + +func normalizeCSV(value string) string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + return strings.Join(result, ",") +} diff --git a/shortcuts/milestone/milestone_test.go b/shortcuts/milestone/milestone_test.go new file mode 100644 index 0000000..bf76193 --- /dev/null +++ b/shortcuts/milestone/milestone_test.go @@ -0,0 +1,207 @@ +package milestone + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestMilestoneList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/milestones.json") + assertEqual(t, r.URL.Query().Get("category"), "opening") + assertEqual(t, r.URL.Query().Get("keyword"), "v1") + assertEqual(t, r.URL.Query().Get("page"), "2") + assertEqual(t, r.URL.Query().Get("limit"), "50") + writeJSON(t, w, map[string]interface{}{"total_count": 0, "milestones": []interface{}{}}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "list", map[string]string{ + "category": "opening", + "keyword": "v1", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestMilestoneCreatePayload(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/v1/owner/repo/milestones.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "create", map[string]string{ + "name": "v1.0", + "description": "first release", + "due-date": "2026-07-01", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + + assertEqual(t, payload["name"], "v1.0") + assertEqual(t, payload["description"], "first release") + assertEqual(t, payload["effective_date"], "2026-07-01") +} + +func TestMilestoneViewWithIssueFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/milestones/7.json") + assertEqual(t, r.URL.Query().Get("category"), "opened") + assertEqual(t, r.URL.Query().Get("author_id"), "11") + assertEqual(t, r.URL.Query().Get("assigner_id"), "22") + assertEqual(t, r.URL.Query().Get("issue_tag_ids"), "1,2,3") + writeJSON(t, w, map[string]interface{}{"milestone": map[string]interface{}{"id": 7}}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "view", map[string]string{ + "id": "7", + "category": "opened", + "author-id": "11", + "assigner-id": "22", + "issue-tag-ids": "1, 2,3", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +func TestMilestoneUpdatePayload(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "PATCH", "/v1/owner/repo/milestones/7.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "update", map[string]string{ + "id": "7", + "due-date": "2026-08-01", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + if _, ok := payload["name"]; ok { + t.Fatal("update payload should omit empty name") + } + assertEqual(t, payload["effective_date"], "2026-08-01") +} + +func TestMilestoneUpdateRequiresChange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("server should not be called when update payload is empty: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "update", map[string]string{"id": "7"}) + if err == nil { + t.Fatal("expected update without fields to return an error") + } +} + +func TestMilestoneDelete(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/v1/owner/repo/milestones/7.json") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + if err := runMilestoneShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } +} + +func TestMilestoneCloseAndReopen(t *testing.T) { + gotStatuses := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/milestones/7/update_status.json") + payload := decodeJSON(t, r) + gotStatuses = append(gotStatuses, payload["status"].(string)) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + if err := runMilestoneShortcut(t, server, "close", map[string]string{"id": "7"}); err != nil { + t.Fatalf("close shortcut failed: %v", err) + } + if err := runMilestoneShortcut(t, server, "reopen", map[string]string{"id": "7"}); err != nil { + t.Fatalf("reopen shortcut failed: %v", err) + } + assertEqual(t, gotStatuses[0], "closed") + assertEqual(t, gotStatuses[1], "open") +} + +func runMilestoneShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findMilestoneShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findMilestoneShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func assertRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if got != want { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index bf2b628..ce7453a 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -3,7 +3,6 @@ package pr import ( "fmt" "net/url" - "strings" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -85,9 +84,6 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - if err := enrichPullRequestClosedAt(ctx, env); err != nil { - return err - } return ctx.Output(env) }, }, @@ -135,6 +131,27 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "reopen", + Description: "Reopen a closed pull request", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reopen", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "files", Description: "List changed files in a pull request", @@ -374,93 +391,3 @@ func extractIssueID(env *output.Envelope) (int64, error) { } return int64(idFloat), nil } - -func enrichPullRequestClosedAt(ctx *common.RuntimeContext, env *output.Envelope) error { - data, ok := env.Data.(map[string]interface{}) - if !ok { - return nil - } - pr, ok := data["pull_request"].(map[string]interface{}) - if !ok || !isClosedPullRequest(pr) || stringField(pr, "closed_at") != "" { - return nil - } - issue, ok := data["issue"].(map[string]interface{}) - if !ok { - return nil - } - issueID, ok := numberField(issue, "id") - if !ok { - return nil - } - journalsEnv, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, int64(issueID)), nil) - if err != nil { - return err - } - closedAt := extractPullRequestClosedAt(journalsEnv) - if closedAt == "" { - return nil - } - pr["closed_at"] = closedAt - data["closed_at"] = closedAt - return nil -} - -func isClosedPullRequest(pr map[string]interface{}) bool { - if stringField(pr, "pull_request_staus") == "closed" || stringField(pr, "state") == "closed" { - return true - } - status, ok := numberField(pr, "status") - return ok && int(status) == 2 -} - -func extractPullRequestClosedAt(env *output.Envelope) string { - data, ok := env.Data.(map[string]interface{}) - if !ok { - return "" - } - rawJournals, ok := data["journals"].([]interface{}) - if !ok { - return "" - } - for i := len(rawJournals) - 1; i >= 0; i-- { - journal, ok := rawJournals[i].(map[string]interface{}) - if !ok || stringField(journal, "operate_category") != "status" { - continue - } - content := stringField(journal, "operate_content") - if !isPullRequestCloseOperation(content) { - continue - } - if updatedAt := stringField(journal, "updated_at"); updatedAt != "" { - return updatedAt - } - if createdAt := stringField(journal, "created_at"); createdAt != "" { - return createdAt - } - } - return "" -} - -func isPullRequestCloseOperation(content string) bool { - content = strings.ToLower(content) - return strings.Contains(content, "合并请求") && - (strings.Contains(content, "拒绝") || strings.Contains(content, "关闭") || strings.Contains(content, "closed")) -} - -func stringField(m map[string]interface{}, key string) string { - v, _ := m[key].(string) - return v -} - -func numberField(m map[string]interface{}, key string) (float64, bool) { - switch v := m[key].(type) { - case float64: - return v, true - case int: - return float64(v), true - case int64: - return float64(v), true - default: - return 0, false - } -} diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index 90e5a07..551bc8d 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/gitlink-org/gitlink-cli/internal/client" - "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -54,87 +53,6 @@ func TestPRCommentPostsToCorrectIssueJournal(t *testing.T) { assertEqual(t, journalPayload["notes"], "LGTM, looks good!") } -func TestPRViewAddsClosedAtFromIssueJournal(t *testing.T) { - var issueJournalCalled bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.Method == "GET" && r.URL.Path == "/owner/repo/pulls/37.json": - writeJSON(t, w, map[string]interface{}{ - "issue": map[string]interface{}{ - "id": float64(142756), - }, - "pull_request": map[string]interface{}{ - "status": float64(2), - "pull_request_staus": "closed", - }, - }) - case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/142756/journals.json": - issueJournalCalled = true - writeJSON(t, w, map[string]interface{}{ - "journals": []map[string]interface{}{ - { - "operate_category": "pull_request", - "operate_content": "创建了合并请求", - "created_at": "2026-05-24 21:43", - }, - { - "operate_category": "status", - "operate_content": "拒绝了合并请求", - "created_at": "2026-05-25 08:58", - "updated_at": "2026-05-25 08:58", - }, - }, - }) - default: - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - })) - defer server.Close() - - env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{ - "id": "37", - }) - if err != nil { - t.Fatalf("view shortcut failed: %v", err) - } - if !issueJournalCalled { - t.Fatal("issue journal endpoint was not called") - } - data := env.Data.(map[string]interface{}) - assertEqual(t, data["closed_at"], "2026-05-25 08:58") - prData := data["pull_request"].(map[string]interface{}) - assertEqual(t, prData["closed_at"], "2026-05-25 08:58") -} - -func TestPRViewDoesNotFetchJournalsForOpenPR(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/owner/repo/pulls/45.json" { - t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) - } - writeJSON(t, w, map[string]interface{}{ - "issue": map[string]interface{}{ - "id": float64(142793), - }, - "pull_request": map[string]interface{}{ - "status": float64(0), - "pull_request_staus": "open", - }, - }) - })) - defer server.Close() - - env, err := runPRShortcutWithOutput(t, server, "view", map[string]string{ - "id": "45", - }) - if err != nil { - t.Fatalf("view shortcut failed: %v", err) - } - data := env.Data.(map[string]interface{}) - if _, ok := data["closed_at"]; ok { - t.Fatal("open PR should not include closed_at") - } -} - func TestPRCommentFailsWhenPRNotFound(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) @@ -345,42 +263,43 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) { } } -func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { - t.Helper() - _, err := runPRShortcutWithOutput(t, server, name, args) - return err +func TestPRReopenUsesV1Endpoint(t *testing.T) { + var calledPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/reopen.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + calledPath = r.URL.Path + writeJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "success", + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "reopen", map[string]string{ + "id": "13", + }) + if err != nil { + t.Fatalf("reopen shortcut failed: %v", err) + } + assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reopen.json") } -func runPRShortcutWithOutput(t *testing.T, server *httptest.Server, name string, args map[string]string) (*output.Envelope, error) { +func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findPRShortcut(t, name) - client := &client.Client{ - HTTP: server.Client(), - BaseURL: server.URL, - } ctx := &common.RuntimeContext{ - Client: client, + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, Owner: "owner", Repo: "repo", Format: "json", Args: args, } - err := shortcut.Run(ctx) - if err != nil { - return nil, err - } - if name != "view" { - return nil, nil - } - id := args["id"] - env, err := client.Do("GET", fmt.Sprintf("/owner/repo/pulls/%s", id), nil, nil) - if err != nil { - return nil, err - } - if err := enrichPullRequestClosedAt(ctx, env); err != nil { - return nil, err - } - return env, nil + return shortcut.Run(ctx) } func findPRShortcut(t *testing.T, name string) *common.Shortcut { diff --git a/shortcuts/register.go b/shortcuts/register.go index 91cf569..49b20ed 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -6,8 +6,10 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" + "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/member" + "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" @@ -22,30 +24,34 @@ import ( func RegisterAll(root *cobra.Command) { groups := map[string][]*common.Shortcut{ "repo": repo.Shortcuts(), - "issue": issue.Shortcuts(), - "member": member.Shortcuts(), - "pr": pr.Shortcuts(), + "issue": issue.Shortcuts(), + "member": member.Shortcuts(), + "milestone": milestone.Shortcuts(), + "pr": pr.Shortcuts(), "release": release.Shortcuts(), "branch": branch.Shortcuts(), "org": org.Shortcuts(), "user": user.Shortcuts(), "search": search.Shortcuts(), "ci": ci.Shortcuts(), + "compare": compare.Shortcuts(), "webhook": webhook.Shortcuts(), "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ "repo": "Repository operations", - "issue": "Issue operations", - "member": "Repository member operations", - "pr": "Pull request operations", + "issue": "Issue operations", + "member": "Repository member operations", + "milestone": "Milestone operations", + "pr": "Pull request operations", "release": "Release operations", "branch": "Branch operations", "org": "Organization operations", "user": "User operations", "search": "Search operations", "ci": "CI/CD operations", + "compare": "Compare branches, tags, or commits", "webhook": "Webhook operations", "workflow": "AI agent workflow analysis", } diff --git a/skills/gitlink-compare/SKILL.md b/skills/gitlink-compare/SKILL.md new file mode 100644 index 0000000..af7dbe9 --- /dev/null +++ b/skills/gitlink-compare/SKILL.md @@ -0,0 +1,40 @@ +--- +name: gitlink-compare +version: 1.0.0 +description: "Compare GitLink branches, tags, or commits and inspect changed files." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli compare --help" +--- + +# gitlink-compare + +Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) first for authentication, global flags, and API behavior. + +## Shortcuts + +| Shortcut | Description | +|----------|-------------| +| `compare +view` | Compare two branches, tags, or commits | +| `compare +files` | List changed files between two refs | + +## Examples + +```bash +# Compare two refs and include commit/diff summary +gitlink-cli compare +view --owner Gitlink --repo forgeplus --head feature/api --base master + +# List changed files +gitlink-cli compare +files --owner Gitlink --repo forgeplus --head feature/api --base master + +# Filter a single file in the file diff endpoint +gitlink-cli compare +files --owner Gitlink --repo forgeplus \ + --head feature/api --base master --file cmd/api/api.go +``` + +## Notes + +- Pass normal branch, tag, or commit names. The CLI base64-url encodes refs before calling GitLink compare endpoints. +- `compare +view` calls `/api/{owner}/{repo}/compare/{head}...{base}`. +- `compare +files` calls `/api/v1/{owner}/{repo}/compare/{head}...{base}/files`. diff --git a/skills/gitlink-milestone/SKILL.md b/skills/gitlink-milestone/SKILL.md new file mode 100644 index 0000000..cd77948 --- /dev/null +++ b/skills/gitlink-milestone/SKILL.md @@ -0,0 +1,66 @@ +--- +name: gitlink-milestone +version: 1.0.0 +description: "Milestone management: list, create, view, update, delete, close, and reopen GitLink project milestones." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli milestone --help" +--- + +# gitlink-milestone + +**CRITICAL**: Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) before starting. It covers authentication, permissions, global flags, and GitLink API behavior. +**CRITICAL**: Confirm user intent before running write or destructive operations such as `+create`, `+update`, `+delete`, `+close`, or `+reopen`. +**CRITICAL**: Use `gitlink-cli` for GitLink resources. Do not use GitHub-only tools such as `gh`. + +## Shortcuts + +| Shortcut | Description | Operation | +|----------|-------------|-----------| +| `milestone +list` | List repository milestones | Read | +| `milestone +create` | Create a milestone | Write | +| `milestone +view` | View milestone details and linked issues | Read | +| `milestone +update` | Update milestone fields | Write | +| `milestone +delete` | Delete a milestone | Destructive | +| `milestone +close` | Close a milestone | Write | +| `milestone +reopen` | Reopen a closed milestone | Write | + +## Examples + +```bash +# List open milestones +gitlink-cli milestone +list --owner Gitlink --repo forgeplus --category opening + +# Create a milestone +gitlink-cli milestone +create --owner Gitlink --repo forgeplus \ + --name v1.0 --description "First stable release" --due-date 2026-07-01 + +# View milestone details and linked opened issues +gitlink-cli milestone +view --owner Gitlink --repo forgeplus --id 7 --category opened + +# Update the due date +gitlink-cli milestone +update --owner Gitlink --repo forgeplus --id 7 --due-date 2026-08-01 + +# Close and reopen +gitlink-cli milestone +close --owner Gitlink --repo forgeplus --id 7 +gitlink-cli milestone +reopen --owner Gitlink --repo forgeplus --id 7 +``` + +## Parameters + +| Command | Key parameters | +|---------|----------------| +| `+list` | `--keyword`, `--category opening,closed`, `--only-name`, `--sort-by`, `--sort-direction`, `--page`, `--limit` | +| `+create` | `--name`, `--description`, `--due-date` | +| `+view` | `--id`, `--category all,opened,closed`, `--author-id`, `--assigner-id`, `--issue-tag-ids`, `--page`, `--limit` | +| `+update` | `--id` plus at least one of `--name`, `--description`, `--due-date` | +| `+delete` | `--id` | +| `+close` / `+reopen` | `--id` | + +## API Notes + +- Milestone list/create/view/update/delete use `/api/v1/{owner}/{repo}/milestones`. +- Status updates use `/api/{owner}/{repo}/milestones/{id}/update_status`. +- `--due-date` maps to the GitLink API field `effective_date`. +- `--issue-tag-ids` accepts comma-separated IDs and normalizes whitespace before calling the API. diff --git a/skills/gitlink-pr/references/gitlink-pr-reopen.md b/skills/gitlink-pr/references/gitlink-pr-reopen.md new file mode 100644 index 0000000..6c2505e --- /dev/null +++ b/skills/gitlink-pr/references/gitlink-pr-reopen.md @@ -0,0 +1,30 @@ +# pr +reopen + +Reopen a closed Pull Request. + +## Usage + +```bash +gitlink-cli pr +reopen --id 3 +gitlink-cli pr +reopen -i 3 +``` + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--id` / `-i` | Yes | PR number from the web URL `/pulls/N` | +| `--owner` | No | Repository owner, auto-detected from git remote when omitted | +| `--repo` | No | Repository name, auto-detected from git remote when omitted | +| `--format` | No | Output format: `json`, `table`, or `yaml` | + +## API + +```text +POST /v1/{owner}/{repo}/pulls/{number}/reopen +``` + +## Notes + +- Use `pr +view -i ` first to confirm the PR is currently closed. +- This command uses the PR number shown in the web URL, not the internal database ID. -- 2.34.1 From 2421c1f0c8ff2eea8c1be54aa9d042c2382d7836 Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Sun, 24 May 2026 21:12:29 +0800 Subject: [PATCH 11/13] feat(api): support body input files --- README.md | 6 ++++++ README.zh-CN.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 670808c..1d38277 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,12 @@ gitlink-cli api GET /users/me # POST request gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}' +# POST request with body from a file +gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json + +# POST request with body from stdin +Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin + # With query parameters gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index f426948..61bb6de 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -280,6 +280,12 @@ gitlink-cli api GET /users/me # POST 请求 gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}' +# 从文件读取 JSON body +gitlink-cli api POST /Gitlink/forgeplus/issues --body-file issue.json + +# 从 stdin 读取 JSON body +Get-Content issue.json | gitlink-cli api POST /Gitlink/forgeplus/issues --body-stdin + # 带查询参数 gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' ``` -- 2.34.1 From 00c753b42109c0d7c3a3ff037e4319c1dd8644d2 Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Sun, 24 May 2026 21:28:42 +0800 Subject: [PATCH 12/13] feat(pr): add reopen shortcut --- README.md | 3 +++ README.zh-CN.md | 3 +++ skills/gitlink-pr/SKILL.md | 6 +++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1d38277..8feea14 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,9 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42 # Merge a PR gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42 +# Reopen a closed PR +gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42 + # View changed files gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42 diff --git a/README.zh-CN.md b/README.zh-CN.md index 61bb6de..3a18367 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -229,6 +229,9 @@ gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42 # 合并 PR gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42 +# 重开已关闭的 PR +gitlink-cli pr +reopen --owner Gitlink --repo forgeplus -i 42 + # 查看 PR 变更文件 gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42 diff --git a/skills/gitlink-pr/SKILL.md b/skills/gitlink-pr/SKILL.md index 1fb6beb..77e0cec 100644 --- a/skills/gitlink-pr/SKILL.md +++ b/skills/gitlink-pr/SKILL.md @@ -24,6 +24,7 @@ metadata: | `pr +view` | PR 详情 | 否(公开项目) | | `pr +merge` | 合并 PR | 是 | | `pr +close` | 关闭 PR | 是 | +| `pr +reopen` | 重开已关闭的 PR | 是 | | `pr +files` | 变更文件列表 | 否 | | `pr +diff` | 查看变更文件和 diff 内容 | 否 | | `pr +versions` | 查看 PR patchset/version 列表 | 否 | @@ -51,6 +52,9 @@ gitlink-cli pr +merge --id 3 --method squash # 关闭 PR(拒绝合并) gitlink-cli pr +close --id 3 +# 重开已关闭的 PR +gitlink-cli pr +reopen --id 3 + # 查看变更文件(含 diff 内容) gitlink-cli pr +files --id 3 @@ -157,7 +161,7 @@ gitlink-cli api GET /v1/:owner/:repo/pulls/:id/versions/:version_id/diff - GitLink 的默认分支通常是 `master`(非 `main`),创建 PR 时注意 `--base` 参数 - 合并 PR 前建议先用 `pr +view` 确认状态 - **PR 创建要求源分支与目标分支有实际代码差异**,否则返回"分支内容相同,无需创建合并请求" -- PR 查看/合并/关闭需要使用 `pull_request_number`(即网页 URL `/pulls/N` 中的序号,从 `pr +list` 返回) +- PR 查看/合并/关闭/重开需要使用 `pull_request_number`(即网页 URL `/pulls/N` 中的序号,从 `pr +list` 返回) - `pr +merge` 默认使用 merge 方式,可通过 `--method` 指定 rebase 或 squash - `pr +diff` 实际调用 `/pulls/:id/files` 端点,返回变更文件列表和 diff 内容 - `pr +versions` / `pr +version-diff` 使用 v1 API,`--id` 为网页 URL `/pulls/N` 中的 PR 序号,`--version-id` 为 patchset/version id -- 2.34.1 From 33387ae042b6c5249c6e893c01b9db992c6149d9 Mon Sep 17 00:00:00 2001 From: fsafasff <2624934035@qq.com> Date: Fri, 29 May 2026 17:38:03 +0800 Subject: [PATCH 13/13] fix: apply raw api header flags --- cmd/api/api.go | 29 +++++++++++++++++++++++++++- cmd/api/api_test.go | 35 ++++++++++++++++++++++++++++++++++ internal/client/client.go | 9 +++++++++ internal/client/client_test.go | 27 +++++++++++++++++++++++++- 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index d13cf52..9dd7ad1 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "net/http" "net/url" "os" "strings" @@ -66,7 +67,12 @@ func runAPI(c *cobra.Command, args []string) error { } } - env, err := cli.Do(method, path, body, query) + headers, err := parseHeaders(c) + if err != nil { + return err + } + + env, err := cli.DoWithHeaders(method, path, body, query, headers) if err != nil { if apiErr, ok := err.(*client.APIError); ok { errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "") @@ -78,6 +84,27 @@ func runAPI(c *cobra.Command, args []string) error { return output.Print(env, resolveFormat()) } +func parseHeaders(c *cobra.Command) (http.Header, error) { + values, _ := c.Flags().GetStringSlice("header") + if len(values) == 0 { + return nil, nil + } + + headers := http.Header{} + for _, value := range values { + key, headerValue, ok := strings.Cut(value, ":") + if !ok { + return nil, fmt.Errorf("invalid header %q: expected key:value", value) + } + key = strings.TrimSpace(key) + if key == "" { + return nil, fmt.Errorf("invalid header %q: header key is empty", value) + } + headers.Add(key, strings.TrimSpace(headerValue)) + } + return headers, nil +} + func readJSONBody(c *cobra.Command) (interface{}, error) { bodyStr, _ := c.Flags().GetString("body") bodyFile, _ := c.Flags().GetString("body-file") diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index df9a104..821dfcb 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -88,3 +88,38 @@ func TestReadJSONBodyWithoutSource(t *testing.T) { t.Fatalf("body = %v, want nil", body) } } + +func TestParseHeaders(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("header", "X-Test: value") + cmd.Flags().Set("header", "X-Trace: one:two") + + headers, err := parseHeaders(cmd) + if err != nil { + t.Fatalf("parseHeaders returned error: %v", err) + } + if got := headers.Get("X-Test"); got != "value" { + t.Fatalf("X-Test = %q, want value", got) + } + if got := headers.Get("X-Trace"); got != "one:two" { + t.Fatalf("X-Trace = %q, want one:two", got) + } +} + +func TestParseHeadersRejectsInvalidValue(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("header", "missing-colon") + + if _, err := parseHeaders(cmd); err == nil { + t.Fatal("expected invalid header to return an error") + } +} + +func TestParseHeadersRejectsEmptyKey(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("header", ": value") + + if _, err := parseHeaders(cmd); err == nil { + t.Fatal("expected empty header key to return an error") + } +} diff --git a/internal/client/client.go b/internal/client/client.go index 831caa7..52aab17 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -42,6 +42,10 @@ func New() (*Client, error) { } func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { + return c.DoWithHeaders(method, path, body, query, nil) +} + +func (c *Client) DoWithHeaders(method, path string, body interface{}, query url.Values, headers http.Header) (*output.Envelope, error) { path = normalizeAPIPath(c.BaseURL, path) // Append .json suffix if not already present (GitLink API convention) @@ -78,6 +82,11 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o if err != nil { return nil, err } + for key, values := range headers { + for _, value := range values { + req.Header.Add(key, value) + } + } if c.Debug { fmt.Printf("→ %s %s\n", method, fullURL) diff --git a/internal/client/client_test.go b/internal/client/client_test.go index 87fb91d..2dcc851 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -1,6 +1,10 @@ package client -import "testing" +import ( + "net/http" + "net/http/httptest" + "testing" +) func TestNormalizeAPIPathStripsDuplicateAPIPrefix(t *testing.T) { got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/api/v1/repos/Gitlink/gitlink-cli/contents/README.md") @@ -25,3 +29,24 @@ func TestNormalizeAPIPathKeepsAPIPrefixForNonAPIBaseURL(t *testing.T) { t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) } } + +func TestDoWithHeadersAddsRequestHeaders(t *testing.T) { + var gotHeader string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("X-Test") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":1,"data":{"ok":true}}`)) + })) + defer server.Close() + + cli := &Client{HTTP: server.Client(), BaseURL: server.URL} + headers := http.Header{} + headers.Set("X-Test", "value") + + if _, err := cli.DoWithHeaders("GET", "/users/me", nil, nil, headers); err != nil { + t.Fatalf("DoWithHeaders returned error: %v", err) + } + if gotHeader != "value" { + t.Fatalf("X-Test header = %q, want value", gotHeader) + } +} -- 2.34.1