Compare commits

...

41 Commits

Author SHA1 Message Date
chroe d1965eb098 fix(org): batch-invite 用户名自动解析为数字ID,修正 Showcase 默认值
- batch.go: user_id 字段从传字符串改为传 int64 数字 ID
- 新增 resolveUserIDs() 自动通过 /users/list 搜索解析用户名
- 支持混合输入:用户名自动查 ID,纯数字直接用,找不到的报错
- showcase: 默认组织 ID 从 1 改为 151783,示例用户改为 chroe
2026-06-04 11:01:41 +08:00
chroe ce7bb8ad19 chore: batch-delete默认改用fork后的仓库路径 2026-06-04 10:18:01 +08:00
chroe 831bd74be6 chore: 更新仓库管理默认示例为真实仓库 2026-06-04 10:00:55 +08:00
chroe f6467c5e84 chore: 恢复repo批量管理卡片 2026-06-04 09:53:32 +08:00
chroe 452c87dca7 fix: 移除batch-milestone(v1 PATCH实际不生效) 2026-06-04 09:44:58 +08:00
chroe 7f0652fd79 fix: 移除batch-label(GitLink API不支持issue标签关联) 2026-06-04 09:33:04 +08:00
chroe 73b5a557bc fix: 移除assign/batch-assign(GitLink API不支持) + 添加issue list命令 2026-06-04 09:25:22 +08:00
chroe cb8dfd5537 fix: assign传递数字ID + 保留status_id防止状态被重置 2026-06-04 09:08:53 +08:00
chroe 49c4b2931f Merge branch 'master' of https://gitlink.org.cn/chroe/gitlink-cli 2026-06-04 09:00:24 +08:00
chroe 623e065efa feat: 添加reopen/batch-reopen命令 + 修复assign使用PATCH代替PUT 2026-06-04 09:00:23 +08:00
chroe b1bd9759ce delete test file
Release / release (push) Failing after 56m48s Details
2026-06-03 23:06:17 +08:00
chroe c229513584 add test file 2026-06-03 23:04:03 +08:00
chroe 1004bddc8d fix: watch/star 模块 resolveProjectID 兼容 project_id 字段 2026-06-03 22:57:04 +08:00
chroe ad30d3b233 Merge branch 'master' of https://gitlink.org.cn/chroe/gitlink-cli 2026-06-03 22:45:51 +08:00
chroe 3558a1626c feat: 成员管理卡片添加按用户名搜索ID功能 2026-06-03 22:45:39 +08:00
chroe 72f1a7d6f5 delete test file 2026-06-03 22:38:48 +08:00
chroe 9ca2ab9530 add test file 2026-06-03 22:38:11 +08:00
chroe ac78457f9e fix: 移除index.html多余的花括号导致JS语法错误 2026-06-03 22:37:19 +08:00
chroe cf9f56feb3 Merge branch 'master' of https://gitlink.org.cn/chroe/gitlink-cli 2026-06-03 21:19:38 +08:00
chroe c1155c2262 perf: 移除流水线docker build --no-cache + 更新项目状态 2026-06-03 21:18:23 +08:00
Martin 4e4e3fb7c9 fix: Windows credential path follows system convention, remove duplicate install.js
Release / release (push) Failing after 1h9m56s Details
- ConfigDir() uses %AppData%\gitlink-cli on Windows instead of ~/.config/
- credentialPath() reuses config.ConfigDir() for path consistency
- credential path now respects GITLINK_CONFIG_DIR env override
- Remove stale scripts/install.js (npm/scripts/install.js is canonical)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 20:58:35 +08:00
chroe f627c3d813 merge: 解决 showcase/index.html 合并冲突,保留独立模块卡片+布尔参数修复+null处理优化 2026-06-03 20:24:47 +08:00
yetja bfd8cb0ba5 修复展示页两个bug:错误提示显示null、pflag bool参数解析错误 2026-06-03 20:20:47 +08:00
chroe 1a5dbe9e8e fix: showcase布尔参数用下拉框、移除重复owner/repo参数、改善null输出显示、member模块增加ID查找提示 2026-06-03 20:16:44 +08:00
yetja 37264ea7ec 合并批量操作为一个大类,修复错误提示显示null的bug 2026-06-03 20:01:01 +08:00
yetja fca5e9b821 新增展示页信息 2026-06-03 19:46:16 +08:00
Martin 7cd995dffa feat: add FreeBSD (amd64/arm64) to build and distribution pipeline
- Add freebsd/amd64 and freebsd/arm64 to build-npm.sh PLATFORMS
- Add FreeBSD targets to CI release workflow
- Fix CI Go version mismatch (1.22 -> 1.26 to match go.mod)
- Add freebsd platform mapping in npm postinstall script

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 19:36:58 +08:00
chroe d072d54995 Merge branch 'master' of https://gitlink.org.cn/chroe/gitlink-cli 2026-06-03 16:54:16 +08:00
chroe ad215e292d feat: wiki模块(5命令+sidebar自动清理)、流水线迁移新服务器、showcase参数修复、client.DoRaw
- 新增 wiki 模块:list/view/create/update/delete(含 sidebar 死链接自动清理)
- 新增 client.DoRaw() 方法(不带.json后缀,支持 gateway API)
- 修复 showcase 参数截断问题(支持引号包裹多词参数)
- 流水线迁移至新服务器 118.31.4.168
- Dockerfile 添加 GOPROXY=https://goproxy.cn,direct
- 更新 PROJECT_STATUS.md 项目文档
2026-06-03 09:05:53 +08:00
yetja d6c1a92fe3 增加批量管理仓库、批量邀请成员两个批量操作,优化现有命令的错误提示 2026-06-02 15:39:49 +08:00
Martin 999e721bd8 feat: add file, member, watch, star shortcut modules (15 commands)
- file: +list, +tree, +get, +create, +delete (5 commands, 9 tests)
- member: +list, +add, +remove, +update (4 commands, 6 tests)
- watch: +watch, +unwatch, +watchers (3 commands, 3 tests)
- star: +star, +unstar, +stars (3 commands, 3 tests)

Auto-resolve project-id for watch/star. v1 API paths preferred.
All 21 new tests passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 08:54:18 +08:00
chroe a82717bbf3 refactor: .devops/构建部署Showcase.yml 2026-05-28 16:14:04 +08:00
chroe d63ea089ba feat: .devops/构建部署Showcase.yml 2026-05-28 16:08:44 +08:00
chroe 74f75a55ca feat(showcase): add interactive dashboard with Dockerfile and CI/CD pipeline
Interactive web dashboard that demonstrates new CLI commands with real
API calls. Includes:
- Go web server with embedded HTML (showcase/main.go)
- Dashboard UI with 4 modules, Chinese descriptions, live execution
- Dockerfile for multi-stage build
- Pipeline config for automated deployment on push

Access at http://121.41.210.165:9090 after deployment
2026-05-28 16:01:01 +08:00
yetja e29a02a3d0 新增24个测试用例:issue批量操作/CRUD、org/user/pr全命令、各模块view命令。覆盖率达到74.1% 2026-05-28 15:35:17 +08:00
chroe 2f943d9a53 docs(showcase): add feature showcase page and deploy script
Dark-themed showcase page displaying:
- Core stats (40+ to 70+ commands, 12 shortcut groups, 36+ tests)
- Team member assignments and architecture diagram
- Live command demo outputs and test results
- Deploy script for Nginx container on ECS
2026-05-28 14:42:34 +08:00
chroe 6042a7ea93 feat(commit): add commit shortcut module with list/view/diff/blame commands
Add 4 new commands for commit operations:
- commit list: list commits with sha/page/limit filters
- commit view: view files changed in a commit
- commit diff: show diff for a commit
- commit blame: show line-by-line file blame

Includes 5 unit tests covering all commands.
2026-05-28 14:42:34 +08:00
yetja 60b6ea6f6c 解决issue3:优化 table 输出格式及 batch-close
同时新增batch-close: 新增 Duration 字段记录批量操作耗时
2026-05-26 15:14:57 +08:00
yetja 1276744ee2 解决 issue #2: 新增 issue batch-assign、batch-label、batch-milestone 三个批量操作命令 2026-05-25 11:18:57 +08:00
yetja f024db9878 新增 7 个 Shortcut 命令(search+issues, issue+assign, issue+label, pr+review, release+download, repo+settings, repo+clone)
补全 doc/design.md Phase 3 规划的 7 个缺失命令:
- search +issues:跨仓库搜索 Issue,支持关键词/状态/所有者过滤
- issue +assign:将 Issue 指派给指定用户
- issue +label:给 Issue 添加/移除标签(逗号分隔多 ID)
- pr +review:提交 PR Review(approve/comment/request-changes)
- release +download:下载 Release 附件,不指定文件名则列出全部
- repo +settings:查看/修改仓库设置(可见性/默认分支/描述)
- repo +clone:从 GitLink 克隆仓库(支持 URL 和 owner/repo 格式)

包含 27 个新增单元测试,全部 55 个 shortcut 测试通过。
2026-05-22 20:19:18 +08:00
chroe 65f9f49e00 新增里程碑、Webhook 和标签快捷命令(16个命令)
新增三个快捷命令分组,覆盖之前只能通过原始 API 访问的 GitLink 平台功能:

- milestone(里程碑):列表、查看、创建、更新、关闭、删除
- webhook(Webhook):列表、创建、查看、更新、删除、测试推送
- label(标签):列表、创建、更新、删除

每个模块均包含 httptest 单元测试,全部 31 个测试通过(新增 17 个 + 原有 14 个)。命令总数从 40+ 增长到 56+。
2026-05-21 17:49:33 +08:00
58 changed files with 8066 additions and 308 deletions

View File

@ -0,0 +1,31 @@
version: 2
name: 构建部署Showcase
description: "代码提交自动触发在服务器上拉取代码、构建Docker镜像并部署"
global:
concurrent: 1
trigger:
webhook: gitlink@1.0.0
event:
- ref: push
ruleset-operator: AND
workflow:
- ref: start
name: 开始
task: start
- ref: ssh_cmd_0
name: SSH部署到服务器
task: ssh_cmd@1.1.1
input:
ssh_pass: ((deploy_server.server_password))
ssh_ip: '"118.31.4.168"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: >-
"cd /opt/gitlink-cli && git fetch origin && git reset --hard origin/master && docker build -f showcase/Dockerfile -t gitlink-cli-showcase . && docker stop gitlink-cli-showcase || true && docker rm gitlink-cli-showcase || true && docker run -d -p 9090:9090 --name gitlink-cli-showcase --restart unless-stopped -e GITLINK_TOKEN='cookie:autologin_trustie=56c6d2b4378588465c97d48679eed7bd2495ff1a' gitlink-cli-showcase"
needs:
- start
- ref: end
name: 结束
task: end
needs:
- ssh_cmd_0

View File

@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: '1.22' go-version: '1.26'
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
@ -36,7 +36,9 @@ jobs:
"linux amd64" \ "linux amd64" \
"linux arm64" \ "linux arm64" \
"windows amd64" \ "windows amd64" \
"windows arm64"; do "windows arm64" \
"freebsd amd64" \
"freebsd arm64"; do
GOOS=$(echo "$pair" | cut -d' ' -f1) GOOS=$(echo "$pair" | cut -d' ' -f1)
GOARCH=$(echo "$pair" | cut -d' ' -f2) GOARCH=$(echo "$pair" | cut -d' ' -f2)
OUT="gitlink-cli" OUT="gitlink-cli"

279
PROJECT_STATUS.md Normal file
View File

@ -0,0 +1,279 @@
# GitLink-CLI 项目状态总结
> 最后更新2026-06-03
> 仓库:`D:\自用\self\word\大三下\软件演化\gitlink-cli`
> 远程:`https://gitlink.org.cn/chroe/gitlink-cli.git`
---
## 一、课程任务背景
**课程**:《软件演化与运维》课程实践
**进阶任务**GitLink 智能化能力提升项目
**当前阶段**:子任务一 — 增加和完善 GitLink-CLI 能力50%,截止 6月4日
### 子任务一交付要求
- 向 gitlink-cli 主仓库提交 PR可多个
- 每个 PR 包含:功能代码 + 单元测试 + 命令帮助文档更新
- 提供变更说明文档
- 撰写《软件分析及建模报告》、《新需求构思报告》、《变更影响分析及测试报告》
---
## 二、技术栈与架构
- **语言**Go 1.26.1
- **CLI 框架**spf13/cobra
- **密钥存储**zalando/go-keyringOS keychain + 文件 fallback
- **配置**gopkg.in/yaml.v3存放于 `~/.config/gitlink-cli/config.yaml`
- **三层命令架构**
1. **基础命令**cmd/`auth`, `api`, `config`, `version`
2. **Shortcut 命令**shortcuts/`repo +list`, `issue +create` 等 94 个命令
3. **Raw API**`api GET /path`
### Shortcut 模块开发模式
每个模块位于 `shortcuts/<name>/<name>.go`,结构固定:
```go
package <name>
import "github.com/gitlink-org/gitlink-cli/shortcuts/common"
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "Description here",
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { return err }
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/something", nil)
if err != nil { return err }
return ctx.Output(env)
},
},
}
}
```
注册到 `shortcuts/register.go`
```go
import "github.com/gitlink-org/gitlink-cli/shortcuts/<name>"
// 在 RegisterAll() 中添加:
// "<name>": <name>.Shortcuts(),
// descriptions["<name>"] = "Description",
```
### 单元测试模式httptest mock
```go
func TestXxx(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 校验 method/path/query返回 mock JSON
}))
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Owner: "owner", Repo: "repo", Format: "json", Args: args,
}
err := findShortcut(t, "list").Run(ctx)
// 断言
}
```
---
## 三、已实现的 18 个 Shortcut 模块94 个命令)
| 模块 | 命令数 | 文件 | 测试 |
|------|--------|------|------|
| repo | 9含 batch-delete/fork | shortcuts/repo/ | ✅ |
| issue | 12含 batch-close/assign/label/milestone | shortcuts/issue/ | ✅ |
| pr | 9 | shortcuts/pr/ | ✅ |
| release | 5 | shortcuts/release/ | ✅ |
| branch | 5 | shortcuts/branch/ | ✅ |
| ci | 4 | shortcuts/ci/ | ✅ |
| commit | 4 | shortcuts/commit/ | ✅ |
| file | 5 | shortcuts/file/ | ✅ |
| member | 4 | shortcuts/member/ | ✅ |
| star | 3 | shortcuts/star/ | ✅ |
| watch | 3 | shortcuts/watch/ | ✅ |
| label | 4 | shortcuts/label/ | ✅ |
| milestone | 6 | shortcuts/milestone/ | ✅ |
| webhook | 6 | shortcuts/webhook/ | ✅ |
| org | 5含 batch-invite | shortcuts/org/ | ✅ |
| search | 3 | shortcuts/search/ | ✅ |
| user | 2 | shortcuts/user/ | ✅ |
| wiki 🆕 | 5 | shortcuts/wiki/ | ✅ 12个测试 |
### 批量操作汇总7个
| 批量命令 | 所属模块 | 输入方式 | 预览模式 |
|----------|----------|----------|----------|
| batch-close | issue | --numbers 逗号 / --from CSV | --dry-run |
| batch-assign | issue | 同上 | --dry-run |
| batch-label | issue | 同上 | --dry-run |
| batch-milestone | issue | 同上 | --dry-run |
| batch-delete | repo | --repos owner/repo 列表 / --from CSV | --dry-run |
| batch-fork | repo | 同上 | --dry-run |
| batch-invite | org | --users 用户名列表 / --from CSV | --dry-run |
---
## 四、Wiki 模块开发记录
### Wiki API 关键发现
| 发现 | 说明 |
|------|------|
| API 域名 | `gateway.gitlink.org.cn` 而非 `www.gitlink.org.cn` |
| API 路径 | `/wiki/open/...`(有 `/open/` 中间段) |
| 无 `.json` 后缀 | Gateway 不接受 `.json`,需要 `client.DoRaw()` |
| Sidebar 名称 | `_Sidebar`(大写 S非小写 `_sidebar` |
| projectId 字段 | 仓库返回 `project_id` 而非 `id` |
| create/update 需要 `title` | API 必填字段 |
| delete 异步重建 sidebar | 需延迟 2 秒后再清理 sidebar |
| `data.data` 格式 | `DoRaw` 返回 dict 而非 string解析需兼容 |
### delete 命令的 sidebar 自动清理流程
1. 删除 wiki 页面DELETE
2. 等待 2 秒(让 GitLink 完成异步 sidebar 重建)
3. 获取 `_Sidebar` 内容
4. 移除已删页面的 `[[PageName]]` 链接
5. 更新 `_Sidebar`
---
## 五、开发踩坑记录
### 修改 CLI 代码后必须同时重建 showcase
showcase 通过 `findCLIBinary()` 在运行时查找 `gitlink-cli.exe`。每次修改 CLI 代码后需要:
```bash
cd gitlink-cli
go build -o gitlink-cli.exe . # 重建 CLI
cd showcase && go build -o showcase.exe . # 重建 showcase更新嵌入的 HTML
# 然后重启 showcase.exe
```
如果只重建 showcase 不重建 CLIshowcase 用的还是旧版二进制。
### Showcase 参数传递坑
showcase 后端曾用 `strings.Split(args, " ")` 按空格切割参数,导致 `"Hello Wiki!"` 被截断为 `"Hello"`
修复:前端给含空格的值加引号,后端用支持引号解析的 `parseShellArgs()` 替代简单 Split。
### Showcase 布尔参数坑(已修复)
Cobra 的 BoolP 标志:`--dry-run false`(空格分隔)会被解析为 `--dry-run=true` + 多余参数 "false"。
所以批量操作 `--dry-run false` 实际上是在预览模式下运行,不会真正执行。
修复:布尔参数改用 `<select>` 下拉框JS 只在选"是"时才传 `--flag`,选"否"时不传。
### Showcase 重复参数坑(已修复)
服务器后端自动添加 `--owner chroe --repo gitlink-cli`。如果前端卡片也传 `--owner`/`--repo`
会导致参数重复。修复watch +watchers 和 star +stars 移除了多余的 owner/repo 输入框。
### Showcase null 显示(已修复)
CLI 命令返回空数据时,前端显示 "null"。修复JS 层对 null/空对象/空字符串统一显示"操作完成,无返回数据"。
---
## 六、CI/CD 流水线
### 部署架构
```
代码 push → GitLink 触发流水线 → SSH 到服务器 → git fetch + reset → docker build → 重启容器
```
### 流水线文件
- `.devops/构建部署Showcase.yml` — GitLink DevOps 流水线push 自动触发)
### 流水线关键配置
- 使用 `git fetch + git reset --hard origin/master` 避免 git pull 的本地修改冲突
- 使用 `docker build --no-cache` 确保每次用最新代码构建
- 容器启动时注入 `-e GITLINK_TOKEN=cookie:autologin_trustie=...` 解决认证问题
- GitLink 密钥管理:`deploy_server.server_password` = 服务器密码
### 服务器信息
- **IP**: 118.31.4.168
- **用户**: root
- **部署路径**: /opt/gitlink-cli
- **容器名**: gitlink-cli-showcase
- **端口**: 9090
- **访问地址**: http://118.31.4.168:9090
- **Showcase 展示模块数**: 12 个模块卡片milestone/webhook/label/commit/wiki/file/member/watch/star/issue批量/repo批量/org批量
### Docker 构建注意
Dockerfile 已配置 `ENV GOPROXY=https://goproxy.cn,direct`,解决国内网络 Go 模块下载问题。
服务器 Docker daemon 已配置国内镜像加速器(`/etc/docker/daemon.json`)。
---
## 七、已完成工作
### ✅ 子任务一完成项
- [x] 基础命令框架auth/api/config/version
- [x] 18 个 Shortcut 模块94 个命令),其中 wiki 为新增
- [x] 7 个批量操作命令batch-close/assign/label/milestone/delete/fork/invite
- [x] 12 个 AI Agent Skills
- [x] Showcase Dashboard 在线展示12 个模块卡片,支持真实运行)
- [x] GitHub CI/CDrelease + npm publish + FreeBSD 支持)
- [x] GitLink DevOps 流水线push 自动部署,已配置密钥,正常运行)
- [x] 单元测试wiki 12个 + 其他模块 47+
- [x] client.DoRaw 方法(不带 .json 后缀的原始 API 调用)
- [x] Showcase 布尔参数/重复参数/null显示 修复
### 待写文档 📄
- [ ] 《软件分析及建模报告》
- [ ] 《新需求构思报告》
- [ ] 《变更影响分析及测试报告》
---
## 八、小组分工
| 角色 | 负责人 | 负责内容 |
|------|--------|----------|
| 组长 A | — | commit(4命令)、milestone(6)、webhook(6)、label(4)、Showcase |
| 同学 B | — | file(5)、member(4)、watch(3)、star(3) |
| 同学 C | — | 批量操作增强(batch-assign/label/milestone/delete/fork/invite)、table 输出优化、测试覆盖、FreeBSD构建 |
| 全员 | — | wiki(5命令)、流水线部署 |
---
## 九、快速开始
```bash
# 构建
cd gitlink-cli && make build
# 运行测试
go test ./shortcuts/... -v
# 登录
./gitlink-cli auth login
# 使用示例
./gitlink-cli repo +list --owner gitlink
./gitlink-cli issue +list --owner gitlink --repo gitlink-cli
./gitlink-cli wiki +list --owner chroe --repo gitlink_help_center
```
---
## 十、当前进度 & 下一步
### 已完成 ✅
1. 全部 18 个 Shortcut 模块 + 94 个命令
2. 7 个批量操作命令
3. Showcase 展示页12 个模块,可在线运行)
4. GitLink DevOps 流水线push 自动部署)
5. 单元测试 47+ 个
6. Showcase 各种 bug 修复布尔参数、null 显示、重复参数、member ID 提示)
### 下一步 📋
1. 向 gitlink-cli 主仓库提交 PR
2. 撰写《软件分析及建模报告》
3. 撰写《新需求构思报告》
4. 撰写《变更影响分析及测试报告》
5. 变更说明文档

335
README_TASKB.md Normal file
View File

@ -0,0 +1,335 @@
# gitlink-cli 新增功能使用指南
> 任务B — file / member / watch / star 四大模块15 个新命令
---
## 一、file — 仓库文件操作
### file +list — 列出仓库文件
```bash
gitlink-cli file +list --owner <owner> --repo <repo> [--ref <分支>] [--search <关键词>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--owner` | 否* | 仓库拥有者 |
| `--repo` | 否* | 仓库名 |
| `--ref`, `-r` | 否 | 分支/标签/commit SHA |
| `--search`, `-s` | 否 | 搜索关键词 |
\* 在 git 仓库目录下运行时自动解析,无需手动指定
**示例:**
```bash
# 列出根目录文件
gitlink-cli file +list --owner chroe --repo gitlink-cli
# 列出 dev 分支的文件
gitlink-cli file +list --owner chroe --repo gitlink-cli --ref dev
# 搜索包含 "test" 的文件
gitlink-cli file +list --owner chroe --repo gitlink-cli --search test
```
### file +tree — 查看文件树
```bash
gitlink-cli file +tree --owner <owner> --repo <repo> [--sha <分支>] [--recursive] [--page <n>] [--limit <n>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--sha`, `-s` | 否 | 分支/标签/commit默认 `master` |
| `--recursive` | 否 | 递归展开所有子目录(`true`/`false` |
| `--page`, `-p` | 否 | 页码,默认 `1` |
| `--limit`, `-l` | 否 | 每页条数,默认 `20` |
**示例:**
```bash
# 默认 tree 输出table 格式)
gitlink-cli file +tree --owner chroe --repo gitlink-cli --sha master
# 递归列出所有文件JSON 格式)
gitlink-cli file +tree --owner chroe --repo gitlink-cli --sha master --recursive true --format json
```
### file +get — 查看文件/目录内容
```bash
gitlink-cli file +get --owner <owner> --repo <repo> --path <路径> [--ref <分支>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--path`, `-p` | **是** | 文件或目录路径 |
| `--ref`, `-r` | 否 | 分支/标签/commit默认 `master` |
**示例:**
```bash
# 查看 README.md 内容
gitlink-cli file +get --owner chroe --repo gitlink-cli --path README.md
# 查看 src 目录的子条目
gitlink-cli file +get --owner chroe --repo gitlink-cli --path src
# 查看 dev 分支上的文件
gitlink-cli file +get --owner chroe --repo gitlink-cli --path main.go --ref dev
```
### file +create — 新建文件
```bash
gitlink-cli file +create --owner <owner> --repo <repo> --path <路径> --content <内容> --message <提交信息> [--branch <分支>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--path`, `-p` | **是** | 文件路径(含文件名) |
| `--content`, `-c` | **是** | 文件内容(明文,自动 Base64 编码) |
| `--message`, `-m` | **是** | Git 提交信息 |
| `--branch`, `-b` | 否 | 目标分支,默认 `master` |
**示例:**
```bash
gitlink-cli file +create \
--owner caoweiqiong --repo Aether \
--path docs/readme.txt \
--content "Hello GitLink!" \
--message "添加文档" \
--branch master
```
### file +delete — 删除文件
```bash
gitlink-cli file +delete --owner <owner> --repo <repo> --path <路径> --sha <BlobSHA> --message <提交信息> [--branch <分支>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--path`, `-p` | **是** | 要删除的文件路径 |
| `--sha`, `-s` | **是** | 文件的 blob SHA`file +list` 获取) |
| `--message`, `-m` | **是** | Git 提交信息 |
| `--branch`, `-b` | 否 | 目标分支,默认 `master` |
**示例:**
```bash
# 先列出文件获取 SHA
gitlink-cli file +list --owner caoweiqiong --repo Aether
# 然后删除指定文件
gitlink-cli file +delete \
--owner caoweiqiong --repo Aether \
--path docs/old.txt \
--sha abc123def456 \
--message "删除过期文档"
```
---
## 二、member — 项目成员管理
### member +list — 列出成员
```bash
gitlink-cli member +list --owner <owner> --repo <repo> [--keyword <搜索>]
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--keyword`, `-k` | 否 | 按用户名搜索成员 |
**示例:**
```bash
gitlink-cli member +list --owner chroe --repo gitlink-cli --format table
# 搜索特定成员
gitlink-cli member +list --owner chroe --repo gitlink-cli --keyword caoweiqiong
```
输出示例:
```
id login role_name
-- ----- ---------
149027 chroe Manager
141645 caoweiqiong Developer
148915 yetja Developer
```
### member +add — 添加成员
```bash
gitlink-cli member +add --owner <owner> --repo <repo> --user-id <数字ID>
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--user-id`, `-u` | **是** | 用户的数字 ID`user +info` 获取) |
**示例:**
```bash
# 先查到目标用户的 user_id
gitlink-cli user +info --login zhangsan
# 然后添加
gitlink-cli member +add --owner caoweiqiong --repo Aether --user-id 123456
```
### member +remove — 移除成员
```bash
gitlink-cli member +remove --owner <owner> --repo <repo> --user-id <数字ID>
```
**示例:**
```bash
gitlink-cli member +remove --owner caoweiqiong --repo Aether --user-id 123456
```
### member +update — 修改成员角色
```bash
gitlink-cli member +update --owner <owner> --repo <repo> --user-id <数字ID> --role <角色>
```
| 参数 | 必需 | 说明 |
|------|------|------|
| `--user-id`, `-u` | **是** | 用户数字 ID |
| `--role`, `-r` | **是** | 三种角色之一:`Manager` / `Developer` / `Reporter` |
**示例:**
```bash
gitlink-cli member +update --owner caoweiqiong --repo Aether --user-id 123456 --role Developer
```
---
## 三、watch — 关注仓库
### watch +watch — 关注仓库
```bash
gitlink-cli watch +watch --owner <owner> --repo <repo>
```
无需手动传 project-id命令内部自动解析。
**示例:**
```bash
gitlink-cli watch +watch --owner chroe --repo gitlink-cli
# → 返回 {"watched": true}
```
### watch +unwatch — 取消关注
```bash
gitlink-cli watch +unwatch --owner <owner> --repo <repo>
```
**示例:**
```bash
gitlink-cli watch +unwatch --owner chroe --repo gitlink-cli
# → 返回 {"watched": false}
```
### watch +watchers — 查看关注者列表
```bash
gitlink-cli watch +watchers --owner <owner> --repo <repo>
```
**示例:**
```bash
gitlink-cli watch +watchers --owner chroe --repo gitlink-cli --format table
```
---
## 四、star — 点赞仓库
### star +star — 点赞仓库
```bash
gitlink-cli star +star --owner <owner> --repo <repo>
```
无需手动传 project-id命令内部自动解析。
**示例:**
```bash
gitlink-cli star +star --owner chroe --repo gitlink-cli
```
### star +unstar — 取消点赞
```bash
gitlink-cli star +unstar --owner <owner> --repo <repo>
```
**示例:**
```bash
gitlink-cli star +unstar --owner chroe --repo gitlink-cli
```
### star +stars — 查看点赞者列表
```bash
gitlink-cli star +stars --owner <owner> --repo <repo>
```
**示例:**
```bash
gitlink-cli star +stars --owner chroe --repo gitlink-cli --format table
```
---
## 全局参数
所有命令都支持以下全局参数:
| 参数 | 说明 | 示例 |
|------|------|------|
| `--owner` | 仓库拥有者git 目录下自动解析) | `--owner chroe` |
| `--repo` | 仓库名git 目录下自动解析) | `--repo gitlink-cli` |
| `--format` | 输出格式:`json` / `table` / `yaml` | `--format table` |
| `--debug` | 开启调试输出 | `--debug` |
## 使用技巧
1. **利用自动解析**:在 git 克隆的目录下直接运行,无需写 `--owner``--repo`
```bash
cd my-project
gitlink-cli file +list # 自动识别当前仓库
gitlink-cli member +list # 同上
```
2. **JSON 输出用于脚本**
```bash
gitlink-cli file +tree --owner chroe --repo gitlink-cli --sha master --format json | jq '.data.entries[] | .name'
```
3. **file 创建自动 Base64**:传 `--content` 时直接写明文,命令会自动转为 Base64不用自己编码。
4. **watch/star 透明化**`watch` 和 `star` 命令会自动查 project-id你只需关心 owner/repo与其他命令体验一致。

View File

@ -10,6 +10,7 @@ import (
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api" apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
"github.com/gitlink-org/gitlink-cli/shortcuts" "github.com/gitlink-org/gitlink-cli/shortcuts"
) )
@ -47,7 +48,10 @@ var versionCmd = &cobra.Command{
func Execute() error { func Execute() error {
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintf(os.Stderr, "错误: %s\n", err)
if s := clierrors.FindSuggestion(err); s != "" {
fmt.Fprintf(os.Stderr, "建议: %s\n", s)
}
return err return err
} }
return nil return nil

View File

@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/zalando/go-keyring" "github.com/zalando/go-keyring"
) )
@ -41,8 +42,7 @@ func DeleteToken() error {
// File-based fallback // File-based fallback
func credentialPath() string { func credentialPath() string {
home, _ := os.UserHomeDir() return filepath.Join(config.ConfigDir(), "credentials")
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
} }
func storeTokenFile(token string) error { func storeTokenFile(token string) error {

View File

@ -24,12 +24,17 @@ type APIError struct {
StatusCode int StatusCode int
Code interface{} Code interface{}
Message string Message string
Suggestion string
} }
func (e *APIError) Error() string { func (e *APIError) Error() string {
return fmt.Sprintf("[%v] %s", e.Code, e.Message) return fmt.Sprintf("[%v] %s", e.Code, e.Message)
} }
func (e *APIError) Suggest() string {
return e.Suggestion
}
func New() (*Client, error) { func New() (*Client, error) {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
@ -77,6 +82,10 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
return nil, err return nil, err
} }
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Debug { if c.Debug {
fmt.Printf("→ %s %s\n", method, fullURL) fmt.Printf("→ %s %s\n", method, fullURL)
} }
@ -98,10 +107,12 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
// Check HTTP-level errors // Check HTTP-level errors
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
suggestion := suggestFix(resp.StatusCode)
return nil, &APIError{ return nil, &APIError{
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
Code: resp.StatusCode, Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))), Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Suggestion: suggestion,
} }
} }
@ -128,6 +139,7 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
StatusCode: int(statusCode), StatusCode: int(statusCode),
Code: int(statusCode), Code: int(statusCode),
Message: msg, Message: msg,
Suggestion: suggestion,
} }
} }
} }
@ -174,16 +186,100 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error)
return c.Do("DELETE", path, nil, query) return c.Do("DELETE", path, nil, query)
} }
// DoRaw sends a request without appending the .json suffix.
func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
fullURL := c.BaseURL + path
if query != nil && len(query) > 0 {
fullURL += "?" + query.Encode()
}
var bodyReader io.Reader
if body != nil {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequest(method, fullURL, bodyReader)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.Debug {
fmt.Printf("→ %s %s\n", method, fullURL)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if c.Debug {
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
}
if resp.StatusCode >= 400 {
return nil, &APIError{
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
}
}
var raw map[string]interface{}
if err := json.Unmarshal(respData, &raw); err != nil {
return output.SuccessEnvelope(string(respData), nil), nil
}
// Check error-in-body pattern
if status, ok := raw["status"]; ok {
var statusCode float64
switch v := status.(type) {
case float64:
statusCode = v
case int:
statusCode = float64(v)
}
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
msg, _ := raw["message"].(string)
return output.ErrorEnvelope(int(statusCode), msg, ""), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
}
}
}
return output.SuccessEnvelope(raw, nil), nil
}
func suggestFix(code int) string { func suggestFix(code int) string {
switch code { switch code {
case 400:
return "请求参数格式错误,请检查参数值是否正确"
case 401: case 401:
return "请先运行 gitlink-cli auth login 登录" return "请先运行 gitlink-cli auth login 登录"
case 403: case 403:
return "权限不足,请确认账户权限或联系项目管理员" return "权限不足,请确认账户权限或联系项目管理员"
case 404: case 404:
return "资源不存在,请检查 owner/repo/id 是否正确" return "资源不存在,请检查 owner/repo/id 是否正确"
case 409:
return "资源冲突,可能存在同名资源"
case 422: case 422:
return "参数校验失败,请检查请求参数" return "参数校验失败,请检查请求参数"
case 500, 502, 503:
return "服务器内部错误,请稍后重试或联系平台管理员"
default: default:
return "" return ""
} }

View File

@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -30,6 +31,11 @@ func ConfigDir() string {
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" { if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
return dir return dir
} }
if runtime.GOOS == "windows" {
if appData := os.Getenv("AppData"); appData != "" {
return filepath.Join(appData, "gitlink-cli")
}
}
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli") return filepath.Join(home, ".config", "gitlink-cli")
} }

View File

@ -17,7 +17,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) {
owner, repo, err := fromGitRemote() owner, repo, err := fromGitRemote()
if err != nil { if err != nil {
if flagOwner == "" || flagRepo == "" { if flagOwner == "" || flagRepo == "" {
return "", "", fmt.Errorf("cannot detect owner/repo from git remote: %w\nUse --owner and --repo flags to specify explicitly", err) return "", "", fmt.Errorf("无法从 git remote 自动检测 owner/repo: %w\n请使用 --owner 和 --repo 参数手动指定", err)
} }
} }
@ -34,7 +34,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) {
func fromGitRemote() (string, string, error) { func fromGitRemote() (string, string, error) {
out, err := exec.Command("git", "remote", "get-url", "origin").Output() out, err := exec.Command("git", "remote", "get-url", "origin").Output()
if err != nil { if err != nil {
return "", "", fmt.Errorf("not a git repository or no remote 'origin'") return "", "", fmt.Errorf("当前目录不是 git 仓库或未配置 remote 'origin'")
} }
remote := strings.TrimSpace(string(out)) remote := strings.TrimSpace(string(out))
return parseRemoteURL(remote) return parseRemoteURL(remote)
@ -45,7 +45,7 @@ func parseRemoteURL(remote string) (string, string, error) {
if strings.HasPrefix(remote, "git@") { if strings.HasPrefix(remote, "git@") {
parts := strings.SplitN(remote, ":", 2) parts := strings.SplitN(remote, ":", 2)
if len(parts) != 2 { if len(parts) != 2 {
return "", "", fmt.Errorf("cannot parse SSH remote: %s", remote) return "", "", fmt.Errorf("无法解析 SSH 远程地址: %s", remote)
} }
return parsePathSegments(parts[1]) return parsePathSegments(parts[1])
} }
@ -53,7 +53,7 @@ func parseRemoteURL(remote string) (string, string, error) {
// HTTPS format: https://www.gitlink.org.cn/owner/repo.git // HTTPS format: https://www.gitlink.org.cn/owner/repo.git
u, err := url.Parse(remote) u, err := url.Parse(remote)
if err != nil { if err != nil {
return "", "", fmt.Errorf("cannot parse remote URL: %s", remote) return "", "", fmt.Errorf("无法解析远程地址 URL: %s", remote)
} }
return parsePathSegments(u.Path) return parsePathSegments(u.Path)
} }
@ -63,7 +63,7 @@ func parsePathSegments(path string) (string, string, error) {
path = strings.TrimSuffix(path, ".git") path = strings.TrimSuffix(path, ".git")
parts := strings.SplitN(path, "/", 3) parts := strings.SplitN(path, "/", 3)
if len(parts) < 2 { if len(parts) < 2 {
return "", "", fmt.Errorf("cannot extract owner/repo from path: %s", path) return "", "", fmt.Errorf("无法从路径提取 owner/repo: %s", path)
} }
return parts[0], parts[1], nil return parts[0], parts[1], nil
} }

61
internal/errors/errors.go Normal file
View File

@ -0,0 +1,61 @@
package errors
import (
"errors"
"fmt"
)
// Suggester is implemented by errors that carry a user-facing suggestion.
type Suggester interface {
Suggest() string
}
// Error is a CLI error with an optional suggestion for the user.
type Error struct {
Message string
Suggestion string
Err error
}
func (e *Error) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *Error) Unwrap() error {
return e.Err
}
func (e *Error) Suggest() string {
return e.Suggestion
}
// New creates a new Error with an optional suggestion.
func New(message, suggestion string) *Error {
return &Error{Message: message, Suggestion: suggestion}
}
// Wrapf wraps an error with a formatted message and optional suggestion.
func Wrapf(err error, suggestion, format string, args ...interface{}) *Error {
return &Error{
Message: fmt.Sprintf(format, args...),
Suggestion: suggestion,
Err: err,
}
}
// Wrap wraps an error with a message and optional suggestion.
func Wrap(err error, message, suggestion string) *Error {
return &Error{Message: message, Suggestion: suggestion, Err: err}
}
// FindSuggestion walks the error chain and returns the first suggestion found.
func FindSuggestion(err error) string {
var s Suggester
if errors.As(err, &s) {
return s.Suggest()
}
return ""
}

View File

@ -66,22 +66,44 @@ func printTable(w io.Writer, envelope *Envelope) error {
return nil return nil
} }
// Try to render as table if data is a slice of maps
switch data := envelope.Data.(type) { switch data := envelope.Data.(type) {
case []interface{}: case []interface{}:
return printSliceTable(w, data) return printSliceTable(w, data)
case map[string]interface{}: case map[string]interface{}:
// For maps with nested structures, prefer JSON if slice := findSliceInMap(data); slice != nil {
return printSliceTable(w, slice)
}
if hasComplexValues(data) { if hasComplexValues(data) {
return printJSON(w, envelope) return printJSON(w, envelope)
} }
return printMapTable(w, data) return printMapTable(w, data)
default: default:
// Fallback to JSON
return printJSON(w, envelope) return printJSON(w, envelope)
} }
} }
func findSliceInMap(m map[string]interface{}) []interface{} {
for _, key := range []string{
"issues", "pull_requests", "milestones", "webhooks", "issue_tags",
"commits", "files", "members", "collaborators", "users", "branches",
"releases", "entries", "tags", "watchers",
} {
if v, ok := m[key]; ok {
if slice, ok := v.([]interface{}); ok && len(slice) > 0 {
return slice
}
}
}
for _, v := range m {
if slice, ok := v.([]interface{}); ok && len(slice) > 0 {
if _, isMap := slice[0].(map[string]interface{}); isMap {
return slice
}
}
}
return nil
}
func hasComplexValues(m map[string]interface{}) bool { func hasComplexValues(m map[string]interface{}) bool {
for _, v := range m { for _, v := range m {
switch v.(type) { switch v.(type) {
@ -98,7 +120,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
return nil return nil
} }
// Collect headers from first item
first, ok := items[0].(map[string]interface{}) first, ok := items[0].(map[string]interface{})
if !ok { if !ok {
data, _ := json.MarshalIndent(items, "", " ") data, _ := json.MarshalIndent(items, "", " ")
@ -109,7 +130,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
headers := collectKeys(first) headers := collectKeys(first)
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
// Print headers
fmt.Fprintln(tw, strings.Join(headers, "\t")) fmt.Fprintln(tw, strings.Join(headers, "\t"))
dashes := make([]string, len(headers)) dashes := make([]string, len(headers))
for i, h := range headers { for i, h := range headers {
@ -117,7 +137,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
} }
fmt.Fprintln(tw, strings.Join(dashes, "\t")) fmt.Fprintln(tw, strings.Join(dashes, "\t"))
// Print rows
for _, item := range items { for _, item := range items {
m, ok := item.(map[string]interface{}) m, ok := item.(map[string]interface{})
if !ok { if !ok {
@ -144,7 +163,6 @@ func printMapTable(w io.Writer, m map[string]interface{}) error {
func collectKeys(m map[string]interface{}) []string { func collectKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m)) keys := make([]string, 0, len(m))
// Prefer common keys first
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"} priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
seen := map[string]bool{} seen := map[string]bool{}
for _, k := range priority { for _, k := range priority {
@ -170,10 +188,21 @@ func formatValue(v interface{}) string {
case reflect.Map, reflect.Slice: case reflect.Map, reflect.Slice:
data, _ := json.Marshal(v) data, _ := json.Marshal(v)
s := string(data) s := string(data)
if len(s) > 60 { if len(s) > 50 {
return s[:57] + "..." return s[:47] + "..."
} }
return s return s
case reflect.Bool:
if v.(bool) {
return "yes"
}
return "no"
case reflect.Float64:
f := v.(float64)
if f == float64(int64(f)) {
return fmt.Sprintf("%d", int64(f))
}
return fmt.Sprintf("%v", v)
default: default:
return fmt.Sprintf("%v", v) return fmt.Sprintf("%v", v)
} }

View File

@ -25,6 +25,7 @@ function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
darwin: "darwin", darwin: "darwin",
linux: "linux", linux: "linux",
win32: "windows", win32: "windows",
freebsd: "freebsd",
}; };
const archMap = { const archMap = {

View File

@ -27,6 +27,8 @@ PLATFORMS=(
"linux/arm64" "linux/arm64"
"windows/amd64" "windows/amd64"
"windows/arm64" "windows/arm64"
"freebsd/amd64"
"freebsd/arm64"
) )
cd "$PROJECT_DIR" cd "$PROJECT_DIR"

View File

@ -1,150 +0,0 @@
#!/usr/bin/env node
"use strict";
const os = require("os");
const path = require("path");
const fs = require("fs");
const https = require("https");
const http = require("http");
const { execSync } = require("child_process");
const PACKAGE = require("../package.json");
const VERSION = PACKAGE.version;
const BINARY_NAME = "gitlink-cli";
const RELEASE_BASE = "https://www.gitlink.org.cn";
const REPO_OWNER = "Gitlink";
const REPO_NAME = "gitlink-cli";
function getPlatformInfo() {
const platform = os.platform();
const arch = os.arch();
const platformMap = { darwin: "darwin", linux: "linux", win32: "windows" };
const archMap = { x64: "amd64", arm64: "arm64" };
const goPlatform = platformMap[platform];
const goArch = archMap[arch];
if (!goPlatform || !goArch) {
throw new Error(`Unsupported platform: ${platform}-${arch}`);
}
return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" };
}
function fetch(url, options = {}) {
return new Promise((resolve, reject) => {
const maxRedirects = options.maxRedirects || 5;
let redirectCount = 0;
function doRequest(currentUrl) {
const mod = currentUrl.startsWith("https") ? https : http;
const req = mod.get(currentUrl, (res) => {
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
redirectCount++;
if (redirectCount > maxRedirects) { reject(new Error("Too many redirects")); return; }
let redirectUrl = res.headers.location;
if (redirectUrl.startsWith("/")) {
const parsed = new URL(currentUrl);
redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`;
}
doRequest(redirectUrl);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}
if (options.json) {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
try { resolve(JSON.parse(body)); } catch (e) { reject(new Error("JSON parse failed")); }
});
} else {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => resolve(Buffer.concat(chunks)));
}
});
req.on("error", reject);
req.setTimeout(30000, () => { req.destroy(); reject(new Error("Timeout")); });
}
doRequest(url);
});
}
async function findReleaseAsset(platform, arch) {
const archiveName = `gitlink-cli_${VERSION}_${platform}_${arch}.tar.gz`;
const tagName = `v${VERSION}`;
const apiUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`;
try {
const releases = await fetch(apiUrl, { json: true });
const rlist = Array.isArray(releases) ? releases : (releases && releases.releases ? releases.releases : []);
let release = rlist.find(r => r.tag_name === tagName || r.tag_name === VERSION);
if (!release && rlist.length > 0) release = rlist[0];
if (release && release.attachments) {
let asset = release.attachments.find(a => a.title === archiveName || a.filename === archiveName);
if (!asset) {
const pattern = `_${platform}_${arch}.tar.gz`;
asset = release.attachments.find(a => (a.title || a.filename || "").endsWith(pattern));
}
if (asset) {
let url = asset.url || `${RELEASE_BASE}/api/attachments/${asset.id}`;
if (url.startsWith("/")) url = RELEASE_BASE + url;
return url;
}
}
} catch (e) {}
return `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${tagName}/assets/${archiveName}`;
}
async function downloadAndExtract(url, destDir, platform) {
const data = await fetch(url);
const archivePath = path.join(destDir, "download.tar.gz");
fs.writeFileSync(archivePath, data);
execSync(`tar -xzf "${archivePath}" -C "${destDir}"`, { stdio: "pipe" });
fs.unlinkSync(archivePath);
const binaryPath = path.join(destDir, BINARY_NAME);
if (!fs.existsSync(binaryPath)) {
const files = fs.readdirSync(destDir);
for (const file of files) {
const subPath = path.join(destDir, file, BINARY_NAME);
if (fs.existsSync(subPath)) { fs.renameSync(subPath, binaryPath); break; }
}
}
if (!fs.existsSync(binaryPath)) throw new Error("Binary not found after extraction");
fs.chmodSync(binaryPath, 0o755);
}
async function main() {
const { platform, arch } = getPlatformInfo();
const binDir = path.join(__dirname, "..", "bin");
if (!fs.existsSync(binDir)) { fs.mkdirSync(binDir, { recursive: true }); }
const binaryPath = path.join(binDir, BINARY_NAME);
if (fs.existsSync(binaryPath)) {
try {
const output = execSync(`"${binaryPath}" version`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 });
if (output.includes(VERSION)) {
console.log(`${BINARY_NAME} v${VERSION} already installed.`);
return;
}
} catch (e) {}
fs.unlinkSync(binaryPath);
}
try {
const downloadUrl = await findReleaseAsset(platform, arch);
await downloadAndExtract(downloadUrl, binDir, platform);
console.log(`${BINARY_NAME} v${VERSION} installed.`);
} catch (err) {
// Don't fail npm install — binary can be installed later
console.warn(`${BINARY_NAME} binary download failed: ${err.message}`);
console.warn(` Skills are installed. You can install the binary manually later:`);
console.warn(` npm run postinstall`);
}
}
main();

View File

@ -18,14 +18,14 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
q := url.Values{} q := url.Values{}
q.Set("page", ctx.Arg("page")) q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit")) q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q) env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
if err != nil { if err != nil {
return err return fmt.Errorf("获取分支列表失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -39,9 +39,12 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, err := ctx.RequireArg("name")
if err != nil {
return err return err
} }
name, _ := ctx.RequireArg("name")
from := ctx.Arg("from") from := ctx.Arg("from")
if from == "" { if from == "" {
from = "master" from = "master"
@ -52,7 +55,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload) env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
if err != nil { if err != nil {
return err return fmt.Errorf("创建分支失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -65,15 +68,18 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, err := ctx.RequireArg("name")
if err != nil {
return err return err
} }
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{ payload := map[string]interface{}{
"branch_name": name, "branch_name": name,
} }
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload) env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
if err != nil { if err != nil {
return err return fmt.Errorf("删除分支失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -86,15 +92,18 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, err := ctx.RequireArg("name")
if err != nil {
return err return err
} }
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{ payload := map[string]interface{}{
"branch_name": name, "branch_name": name,
} }
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload) env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
if err != nil { if err != nil {
return err return fmt.Errorf("设置分支保护失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -107,13 +116,16 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
name, _ := ctx.RequireArg("name") name, err := ctx.RequireArg("name")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
if err != nil { if err != nil {
return err return err
} }
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
if err != nil {
return fmt.Errorf("取消分支保护失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },

140
shortcuts/branch/branch_test.go Executable file
View File

@ -0,0 +1,140 @@
package branch
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 TestBranchList(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/branches.json" {
writeJSON(t, w, []map[string]interface{}{
{"name": "master", "protected": true},
{"name": "develop", "protected": false},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBranchShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("branch list failed: %v", err)
}
}
func TestBranchCreate(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/branches.json" {
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"name": "feature-x"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBranchShortcut(t, server, "create", map[string]string{
"name": "feature-x",
"from": "master",
})
if err != nil {
t.Fatalf("branch create failed: %v", err)
}
if body["new_branch_name"] != "feature-x" {
t.Fatalf("expected new_branch_name=feature-x, got %v", body["new_branch_name"])
}
if body["old_branch_name"] != "master" {
t.Fatalf("expected old_branch_name=master, got %v", body["old_branch_name"])
}
}
func TestBranchDelete(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/branches/delete.json" {
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBranchShortcut(t, server, "delete", map[string]string{"name": "feature-x"})
if err != nil {
t.Fatalf("branch delete failed: %v", err)
}
if body["branch_name"] != "feature-x" {
t.Fatalf("expected branch_name=feature-x, got %v", body["branch_name"])
}
}
func TestBranchProtect(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/protected_branches.json" {
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "protected"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runBranchShortcut(t, server, "protect", map[string]string{"name": "master"})
if err != nil {
t.Fatalf("branch protect failed: %v", err)
}
if body["branch_name"] != "master" {
t.Fatalf("expected branch_name=master, got %v", body["branch_name"])
}
}
func runBranchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findBranchShortcut(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 findBranchShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
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)
}
}

View File

@ -18,14 +18,14 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
q := url.Values{} q := url.Values{}
q.Set("page", ctx.Arg("page")) q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit")) q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
if err != nil { if err != nil {
return err return fmt.Errorf("获取构建列表失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -40,9 +40,12 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
build, err := ctx.RequireArg("build")
if err != nil {
return err return err
} }
build, _ := ctx.RequireArg("build")
stage := ctx.Arg("stage") stage := ctx.Arg("stage")
step := ctx.Arg("step") step := ctx.Arg("step")
if stage == "" { if stage == "" {
@ -53,7 +56,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil) env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil)
if err != nil { if err != nil {
return err return fmt.Errorf("获取构建日志失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -66,13 +69,16 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
build, _ := ctx.RequireArg("build") build, err := ctx.RequireArg("build")
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
if err != nil { if err != nil {
return err return err
} }
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
if err != nil {
return fmt.Errorf("重启构建失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },
@ -84,13 +90,16 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
build, _ := ctx.RequireArg("build") build, err := ctx.RequireArg("build")
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
if err != nil { if err != nil {
return err return err
} }
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
if err != nil {
return fmt.Errorf("停止构建失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },

110
shortcuts/commit/commit.go Normal file
View File

@ -0,0 +1,110 @@
package commit
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List commits in a repository",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA"},
{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 fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if sha := ctx.Arg("sha"); sha != "" {
q.Set("sha", sha)
}
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo), q)
if err != nil {
return fmt.Errorf("获取提交列表失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View files changed in a commit",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
{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 fmt.Errorf("解析仓库信息失败: %w", err)
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), q)
if err != nil {
return fmt.Errorf("查看提交详情失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "diff",
Description: "Show diff for a commit",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Commit SHA", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil)
if err != nil {
return fmt.Errorf("获取提交 Diff 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "blame",
Description: "Show blame for a file",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
q := url.Values{}
q.Set("filepath", filePath)
q.Set("sha", ctx.Arg("sha"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo), q)
if err != nil {
return fmt.Errorf("获取 Blame 信息失败: %w", err)
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,160 @@
package commit
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 TestCommitList(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/commits.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"commits": []map[string]interface{}{
{"sha": "abc123", "commit_message": "initial commit"},
},
})
}))
defer server.Close()
err := runCommitShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestCommitListWithSHA(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/commits.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.URL.Query().Get("sha") != "develop" {
t.Fatalf("expected sha=develop, got %s", r.URL.Query().Get("sha"))
}
writeJSON(t, w, map[string]interface{}{
"total_count": 0,
"commits": []map[string]interface{}{},
})
}))
defer server.Close()
err := runCommitShortcut(t, server, "list", map[string]string{"sha": "develop"})
if err != nil {
t.Fatalf("list with sha failed: %v", err)
}
}
func TestCommitView(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/commits/abc123/files.json" {
writeJSON(t, w, map[string]interface{}{
"file_nums": 1,
"files": []map[string]interface{}{
{"filename": "main.go", "additions": 10, "deletions": 2},
},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runCommitShortcut(t, server, "view", map[string]string{"sha": "abc123"})
if err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
}
func TestCommitDiff(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/commits/abc123/diff.json" {
writeJSON(t, w, map[string]interface{}{
"file_nums": 1,
"total_addition": 10,
"total_deletion": 2,
"files": []map[string]interface{}{{"name": "main.go"}},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runCommitShortcut(t, server, "diff", map[string]string{"sha": "abc123"})
if err != nil {
t.Fatalf("diff shortcut failed: %v", err)
}
}
func TestCommitBlame(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/blame.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.URL.Query().Get("filepath") != "main.go" {
t.Fatalf("expected filepath=main.go, got %s", r.URL.Query().Get("filepath"))
}
writeJSON(t, w, map[string]interface{}{
"file_name": "main.go",
"num_lines": 20,
})
}))
defer server.Close()
err := runCommitShortcut(t, server, "blame", map[string]string{"path": "main.go", "sha": "master"})
if err != nil {
t.Fatalf("blame shortcut failed: %v", err)
}
}
// === helpers ===
func runCommitShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findCommitShortcut(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 findCommitShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
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)
}
}
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper()
if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

179
shortcuts/file/file.go Normal file
View File

@ -0,0 +1,179 @@
package file
import (
"encoding/base64"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List repository files",
Flags: []common.Flag{
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA"},
{Name: "search", Short: "s", Usage: "Search keyword"},
},
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 search := ctx.Arg("search"); search != "" {
q.Set("search", search)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/files", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "tree",
Description: "List file tree for a branch or commit",
Flags: []common.Flag{
{Name: "sha", Short: "s", Usage: "Branch, tag, or commit SHA", Default: "master"},
{Name: "recursive", Usage: "Recursively list all files", Bool: true, Default: "false"},
{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
}
sha := ctx.Arg("sha")
if sha == "" {
sha = "master"
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if ctx.Arg("recursive") == "true" {
q.Set("recursive", "true")
}
env, err := ctx.CallAPIWithQuery("GET",
fmt.Sprintf("/v1/%s/%s/git/trees/%s", ctx.Owner, ctx.Repo, sha), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "get",
Description: "Get file or directory contents",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File or directory path", Required: true},
{Name: "ref", Short: "r", Usage: "Branch, tag, or commit SHA", Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
q := url.Values{}
q.Set("filepath", filePath)
q.Set("ref", ctx.Arg("ref"))
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/sub_entries", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a new file in the repository",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "content", Short: "c", Usage: "File content (plain text, auto Base64 encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
branch := ctx.Arg("branch")
if branch == "" {
branch = "master"
}
body := map[string]interface{}{
"filepath": filePath,
"content": base64.StdEncoding.EncodeToString([]byte(content)),
"message": message,
"branch": branch,
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/create_file", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a file from the repository",
Flags: []common.Flag{
{Name: "path", Short: "p", Usage: "File path", Required: true},
{Name: "sha", Short: "s", Usage: "File blob SHA (from file +list)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message", Required: true},
{Name: "branch", Short: "b", Usage: "Target branch", Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
filePath, err := ctx.RequireArg("path")
if err != nil {
return err
}
sha, err := ctx.RequireArg("sha")
if err != nil {
return err
}
message, err := ctx.RequireArg("message")
if err != nil {
return err
}
branch := ctx.Arg("branch")
if branch == "" {
branch = "master"
}
body := map[string]interface{}{
"filepath": filePath,
"sha": sha,
"message": message,
"branch": branch,
}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/delete_file", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

202
shortcuts/file/file_test.go Normal file
View File

@ -0,0 +1,202 @@
package file
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 TestFileList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/files.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, []map[string]interface{}{
{"name": "README.md", "path": "README.md", "type": "file"},
{"name": "src", "path": "src", "type": "dir"},
})
}))
defer server.Close()
if err := runFileShortcut(t, server, "list", map[string]string{}); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestFileListWithRef(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("ref") != "dev" {
t.Fatalf("expected ref=dev, got %s", r.URL.Query().Get("ref"))
}
writeJSON(t, w, []map[string]interface{}{})
}))
defer server.Close()
if err := runFileShortcut(t, server, "list", map[string]string{"ref": "dev"}); err != nil {
t.Fatalf("list with ref failed: %v", err)
}
}
func TestFileTree(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/git/trees/master.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"entries": []map[string]interface{}{{"name": "main.go", "type": "file"}},
})
}))
defer server.Close()
if err := runFileShortcut(t, server, "tree", map[string]string{}); err != nil {
t.Fatalf("tree failed: %v", err)
}
}
func TestFileTreeRecursive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("recursive") != "true" {
t.Fatalf("expected recursive=true")
}
writeJSON(t, w, map[string]interface{}{"entries": []map[string]interface{}{}})
}))
defer server.Close()
if err := runFileShortcut(t, server, "tree", map[string]string{"recursive": "true"}); err != nil {
t.Fatalf("tree recursive failed: %v", err)
}
}
func TestFileGet(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/sub_entries.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.URL.Query().Get("filepath") != "README.md" {
t.Fatalf("expected filepath=README.md, got %s", r.URL.Query().Get("filepath"))
}
writeJSON(t, w, map[string]interface{}{"name": "README.md", "type": "file"})
}))
defer server.Close()
err := runFileShortcut(t, server, "get", map[string]string{"path": "README.md"})
if err != nil {
t.Fatalf("get failed: %v", err)
}
}
func TestFileGetRequiresPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --path")
}))
defer server.Close()
err := runFileShortcut(t, server, "get", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --path")
}
}
func TestFileCreate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/create_file.json" {
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["filepath"] != "test.txt" {
t.Fatalf("expected filepath=test.txt, got %v", payload["filepath"])
}
if payload["message"] != "add test" {
t.Fatalf("expected message=add test, got %v", payload["message"])
}
if payload["branch"] != "master" {
t.Fatalf("expected branch=master, got %v", payload["branch"])
}
if _, ok := payload["content"].(string); !ok || payload["content"] == "" {
t.Fatal("content should be a non-empty Base64 string")
}
writeJSON(t, w, map[string]interface{}{"name": "test.txt", "sha": "abc123"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runFileShortcut(t, server, "create", map[string]string{
"path": "test.txt", "content": "hello world", "message": "add test",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
}
func TestFileDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/delete_file.json" {
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["filepath"] != "old.txt" {
t.Fatalf("expected filepath=old.txt, got %v", payload["filepath"])
}
if payload["sha"] != "def456" {
t.Fatalf("expected sha=def456, got %v", payload["sha"])
}
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runFileShortcut(t, server, "delete", map[string]string{
"path": "old.txt", "sha": "def456", "message": "remove old",
})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
}
func TestFileDeleteRequiresPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --path")
}))
defer server.Close()
err := runFileShortcut(t, server, "delete", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --path")
}
}
// === helpers ===
func runFileShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findFileShortcut(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 findFileShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
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")
json.NewEncoder(w).Encode(payload)
}

View File

@ -6,26 +6,29 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/common"
) )
const closedIssueStatusID = 5 const closedIssueStatusID = 5
const openIssueStatusID = 1
type batchCloseResult struct { type batchResult struct {
Number string `json:"number" yaml:"number"` Number string `json:"number" yaml:"number"`
Action string `json:"action" yaml:"action"` Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"` Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"` Error string `json:"error,omitempty" yaml:"error,omitempty"`
} }
type batchCloseSummary struct { type batchSummary struct {
Repository string `json:"repository" yaml:"repository"` Repository string `json:"repository" yaml:"repository"`
DryRun bool `json:"dry_run" yaml:"dry_run"` DryRun bool `json:"dry_run" yaml:"dry_run"`
Total int `json:"total" yaml:"total"` Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"` Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"` Failed int `json:"failed" yaml:"failed"`
Results []batchCloseResult `json:"results" yaml:"results"` Duration string `json:"duration" yaml:"duration"`
Results []batchResult `json:"results" yaml:"results"`
} }
func newBatchCloseShortcut() *common.Shortcut { func newBatchCloseShortcut() *common.Shortcut {
@ -43,27 +46,29 @@ func newBatchCloseShortcut() *common.Shortcut {
func runBatchClose(ctx *common.RuntimeContext) error { func runBatchClose(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
start := time.Now()
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil { if err != nil {
return err return err
} }
if len(numbers) == 0 { if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
} }
dryRun := parseBool(ctx.Arg("dry-run")) dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchCloseSummary{ summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun, DryRun: dryRun,
Total: len(numbers), Total: len(numbers),
Results: make([]batchCloseResult, 0, len(numbers)), Results: make([]batchResult, 0, len(numbers)),
} }
for _, number := range numbers { for _, number := range numbers {
result := batchCloseResult{Number: number, Action: "close"} result := batchResult{Number: number, Action: "close"}
if dryRun { if dryRun {
result.Status = "planned" result.Status = "planned"
summary.Succeeded++ summary.Succeeded++
@ -82,11 +87,13 @@ func runBatchClose(ctx *common.RuntimeContext) error {
summary.Results = append(summary.Results, result) summary.Results = append(summary.Results, result)
} }
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil { if err := ctx.OutputData(summary); err != nil {
return err return err
} }
if summary.Failed > 0 { if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total) return fmt.Errorf("%d / %d 个 Issue 关闭失败", summary.Failed, summary.Total)
} }
return nil return nil
} }
@ -94,7 +101,7 @@ func runBatchClose(ctx *common.RuntimeContext) error {
func closeIssue(ctx *common.RuntimeContext, number string) error { func closeIssue(ctx *common.RuntimeContext, number string) error {
current, err := fetchExistingIssue(ctx, number) current, err := fetchExistingIssue(ctx, number)
if err != nil { if err != nil {
return fmt.Errorf("fetch issue: %w", err) return fmt.Errorf("获取 Issue 详情: %w", err)
} }
body := map[string]interface{}{ body := map[string]interface{}{
@ -103,11 +110,383 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
"status_id": closedIssueStatusID, "status_id": closedIssueStatusID,
} }
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("close issue: %w", err) return fmt.Errorf("关闭 Issue: %w", err)
} }
return nil return nil
} }
func newBatchReopenShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-reopen",
Description: "Reopen multiple closed issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "dry-run", Usage: "Preview the issues that would be reopened without changing them", Bool: true, Default: "false"},
},
Run: runBatchReopen,
}
}
func runBatchReopen(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
start := time.Now()
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchResult{Number: number, Action: "reopen"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := reopenIssue(ctx, number); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "reopened"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个 Issue 重新打开失败", summary.Failed, summary.Total)
}
return nil
}
func reopenIssue(ctx *common.RuntimeContext, number string) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("获取 Issue 详情: %w", err)
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": openIssueStatusID,
}
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("重新打开 Issue: %w", err)
}
return nil
}
func newBatchAssignShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-assign",
Description: "Assign multiple issues to a user by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "user", Short: "u", Usage: "Assignee user ID (numeric)", Required: true},
{Name: "dry-run", Usage: "Preview the issues that would be assigned without changing them", Bool: true, Default: "false"},
},
Run: runBatchAssign,
}
}
func runBatchAssign(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
user, err := ctx.RequireArg("user")
if err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchResult{Number: number, Action: "assign"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := assignIssue(ctx, number, user); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "assigned"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个 Issue 分配失败", summary.Failed, summary.Total)
}
return nil
}
func assignIssue(ctx *common.RuntimeContext, number, user string) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("获取 Issue 详情: %w", err)
}
userID, err := strconv.Atoi(user)
if err != nil {
return fmt.Errorf("user 参数必须是数字 ID而不是用户名")
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"assigned_to_id": userID,
}
if current.StatusID != nil {
body["status_id"] = current.StatusID
}
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("分配 Issue: %w", err)
}
return nil
}
func newBatchLabelShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-label",
Description: "Add or remove labels on multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "add", Short: "a", Usage: "Comma-separated label IDs to add"},
{Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"},
{Name: "dry-run", Usage: "Preview the issues that would be labeled without changing them", Bool: true, Default: "false"},
},
Run: runBatchLabel,
}
}
func runBatchLabel(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
addIDs := ctx.Arg("add")
removeIDs := ctx.Arg("remove")
if addIDs == "" && removeIDs == "" {
return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个")
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchResult, 0, len(numbers)),
}
addLabels := parseCommaSeparated(addIDs)
removeLabels := parseCommaSeparated(removeIDs)
for _, number := range numbers {
result := batchResult{Number: number, Action: "label"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := labelIssue(ctx, number, addLabels, removeLabels); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "labeled"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个 Issue 标签操作失败", summary.Failed, summary.Total)
}
return nil
}
func labelIssue(ctx *common.RuntimeContext, number string, addLabels, removeLabels []string) error {
var errs []string
for _, labelID := range addLabels {
body := map[string]interface{}{
"tag_id": labelID,
}
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil {
errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err))
}
}
for _, labelID := range removeLabels {
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil {
errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err))
}
}
if len(errs) > 0 {
return fmt.Errorf("%s", strings.Join(errs, "\n"))
}
return nil
}
func newBatchMilestoneShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-milestone",
Description: "Set milestone on multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
{Name: "milestone", Short: "m", Usage: "Milestone ID", Required: true},
{Name: "dry-run", Usage: "Preview the issues that would be updated without changing them", Bool: true, Default: "false"},
},
Run: runBatchMilestone,
}
}
func runBatchMilestone(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
milestoneID, err := ctx.RequireArg("milestone")
if err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
DryRun: dryRun,
Total: len(numbers),
Results: make([]batchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := batchResult{Number: number, Action: "milestone"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := setMilestone(ctx, number, milestoneID); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "milestoned"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个 Issue 设置里程碑失败", summary.Failed, summary.Total)
}
return nil
}
func setMilestone(ctx *common.RuntimeContext, number, milestoneID string) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("获取 Issue 详情: %w", err)
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"fixed_version_id": milestoneID,
}
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("设置里程碑: %w", err)
}
return nil
}
func parseCommaSeparated(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
var result []string
for _, item := range strings.Split(value, ",") {
if trimmed := strings.TrimSpace(item); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := parseIssueNumbers(numbersValue) numbers, err := parseIssueNumbers(numbersValue)
if err != nil { if err != nil {
@ -134,7 +513,7 @@ func parseIssueNumbers(value string) ([]string, error) {
func readIssueNumbersFromCSV(path string) ([]string, error) { func readIssueNumbersFromCSV(path string) ([]string, error) {
file, err := os.Open(path) file, err := os.Open(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("read issue numbers from CSV: %w", err) return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
} }
defer file.Close() defer file.Close()
@ -142,7 +521,7 @@ func readIssueNumbersFromCSV(path string) ([]string, error) {
reader.TrimLeadingSpace = true reader.TrimLeadingSpace = true
records, err := reader.ReadAll() records, err := reader.ReadAll()
if err != nil { if err != nil {
return nil, fmt.Errorf("parse issue numbers from CSV: %w", err) return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
} }
if len(records) == 0 { if len(records) == 0 {
return nil, nil return nil, nil
@ -180,7 +559,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) {
continue continue
} }
if _, err := strconv.ParseInt(number, 10, 64); err != nil { if _, err := strconv.ParseInt(number, 10, 64); err != nil {
return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number) return nil, fmt.Errorf("无效的 Issue 编号 %q: Issue 编号必须是整数", number)
} }
if seen[number] { if seen[number] {
continue continue

View File

@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/common"
) )
@ -17,11 +18,16 @@ func v1RepoPath(ctx *common.RuntimeContext) string {
type existingIssue struct { type existingIssue struct {
Subject string Subject string
Description string Description string
StatusID interface{}
} }
func Shortcuts() []*common.Shortcut { func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{ return []*common.Shortcut{
newBatchCloseShortcut(), newBatchCloseShortcut(),
newBatchReopenShortcut(),
newBatchAssignShortcut(),
newBatchLabelShortcut(),
newBatchMilestoneShortcut(),
{ {
Name: "list", Name: "list",
Description: "List issues", Description: "List issues",
@ -32,7 +38,7 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
q := url.Values{} q := url.Values{}
q.Set("page", ctx.Arg("page")) q.Set("page", ctx.Arg("page"))
@ -42,7 +48,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil { if err != nil {
return err return fmt.Errorf("获取 Issue 列表失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -59,7 +65,7 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
title, err := ctx.RequireArg("title") title, err := ctx.RequireArg("title")
if err != nil { if err != nil {
@ -82,7 +88,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil { if err != nil {
return err return fmt.Errorf("创建 Issue 失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -95,7 +101,7 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
number, err := ctx.RequireArg("number") number, err := ctx.RequireArg("number")
if err != nil { if err != nil {
@ -103,7 +109,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil { if err != nil {
return err return fmt.Errorf("查看 Issue 详情失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -116,7 +122,7 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
number, err := ctx.RequireArg("number") number, err := ctx.RequireArg("number")
if err != nil { if err != nil {
@ -124,7 +130,7 @@ func Shortcuts() []*common.Shortcut {
} }
current, err := fetchExistingIssue(ctx, number) current, err := fetchExistingIssue(ctx, number)
if err != nil { if err != nil {
return err return fmt.Errorf("关闭 Issue 失败: %w", err)
} }
body := map[string]interface{}{ body := map[string]interface{}{
@ -133,9 +139,40 @@ func Shortcuts() []*common.Shortcut {
"status_id": 5, // 5 = closed "status_id": 5, // 5 = closed
} }
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return fmt.Errorf("关闭 Issue 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "reopen",
Description: "Reopen a closed issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
number, err := ctx.RequireArg("number")
if err != nil { if err != nil {
return err return err
} }
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("重新打开 Issue 失败: %w", err)
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": 1, // 1 = open
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return fmt.Errorf("重新打开 Issue 失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },
@ -150,7 +187,7 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
number, err := ctx.RequireArg("number") number, err := ctx.RequireArg("number")
if err != nil { if err != nil {
@ -160,12 +197,12 @@ func Shortcuts() []*common.Shortcut {
description := ctx.Arg("body") description := ctx.Arg("body")
state := ctx.Arg("state") state := ctx.Arg("state")
if title == "" && description == "" && state == "" { if title == "" && description == "" && state == "" {
return fmt.Errorf("at least one of --title, --body, or --state is required") return fmt.Errorf("至少需要指定 --title、--body 或 --state 中的一个")
} }
current, err := fetchExistingIssue(ctx, number) current, err := fetchExistingIssue(ctx, number)
if err != nil { if err != nil {
return err return fmt.Errorf("更新 Issue 失败: %w", err)
} }
body := map[string]interface{}{ body := map[string]interface{}{
@ -187,11 +224,109 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil { if err != nil {
return err return fmt.Errorf("更新 Issue 失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
}, },
{
Name: "assign",
Description: "Assign an issue to a user",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "user", Short: "u", Usage: "Assignee user ID (numeric)", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
user, err := ctx.RequireArg("user")
if err != nil {
return err
}
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("分配 Issue 失败: %w", err)
}
userID, err := strconv.Atoi(user)
if err != nil {
return fmt.Errorf("user 参数必须是数字 ID而不是用户名")
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"assigned_to_id": userID,
}
if current.StatusID != nil {
body["status_id"] = current.StatusID
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return fmt.Errorf("分配 Issue 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "label",
Description: "Add or remove labels on an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "add", Short: "a", Usage: "Comma-separated label IDs to add"},
{Name: "remove", Short: "r", Usage: "Comma-separated label IDs to remove"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
number, err := ctx.RequireArg("number")
if err != nil {
return err
}
addIDs := ctx.Arg("add")
removeIDs := ctx.Arg("remove")
if addIDs == "" && removeIDs == "" {
return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个")
}
var errs []string
if addIDs != "" {
for _, labelID := range strings.Split(addIDs, ",") {
labelID = strings.TrimSpace(labelID)
if labelID == "" {
continue
}
body := map[string]interface{}{
"tag_id": labelID,
}
if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil {
errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err))
}
}
}
if removeIDs != "" {
for _, labelID := range strings.Split(removeIDs, ",") {
labelID = strings.TrimSpace(labelID)
if labelID == "" {
continue
}
if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil {
errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err))
}
}
}
if len(errs) > 0 {
return fmt.Errorf("标签操作失败:\n%s", strings.Join(errs, "\n"))
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "标签操作成功",
}, nil))
},
},
{ {
Name: "comment", Name: "comment",
Description: "Add a comment to an issue", Description: "Add a comment to an issue",
@ -201,7 +336,7 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
number, err := ctx.RequireArg("number") number, err := ctx.RequireArg("number")
if err != nil { if err != nil {
@ -216,7 +351,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload) env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload)
if err != nil { if err != nil {
return err return fmt.Errorf("添加评论失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -227,20 +362,28 @@ func Shortcuts() []*common.Shortcut {
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) { func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("获取 Issue 详情: %w", err)
} }
issueData, ok := getEnv.Data.(map[string]interface{}) issueData, ok := getEnv.Data.(map[string]interface{})
if !ok { if !ok {
return nil, fmt.Errorf("failed to parse issue data") return nil, fmt.Errorf("解析 Issue 数据失败")
} }
subject, _ := issueData["subject"].(string) subject, _ := issueData["subject"].(string)
if subject == "" { if subject == "" {
return nil, fmt.Errorf("failed to parse issue subject") return nil, fmt.Errorf("解析 Issue 标题失败")
} }
description, _ := issueData["description"].(string) description, _ := issueData["description"].(string)
statusID := issueData["status_id"]
if statusID == nil {
// Try nested status object
if status, ok := issueData["status"].(map[string]interface{}); ok {
statusID = status["id"]
}
}
return &existingIssue{ return &existingIssue{
Subject: subject, Subject: subject,
Description: description, Description: description,
StatusID: statusID,
}, nil }, nil
} }
@ -254,6 +397,6 @@ func normalizeIssueStatus(state string) (interface{}, error) {
if id, err := strconv.Atoi(state); err == nil { if id, err := strconv.Atoi(state); err == nil {
return id, nil return id, nil
} }
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state) return nil, fmt.Errorf("无效的 --state 值 %q: 请使用 open、closed 或数字 status_id", state)
} }
} }

354
shortcuts/issue/issue_test.go Normal file → Executable file
View File

@ -186,3 +186,357 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
} }
} }
func TestIssueList(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json" {
writeJSON(t, w, map[string]interface{}{
"issues": []map[string]interface{}{
{"id": float64(1), "subject": "bug", "status_id": float64(1)},
{"id": float64(2), "subject": "feature", "status_id": float64(5)},
},
"total_count": float64(2),
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runIssueShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("issue list failed: %v", err)
}
}
func TestIssueCreate(t *testing.T) {
var createPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues.json" {
createPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(99), "subject": "new bug",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runIssueShortcut(t, server, "create", map[string]string{
"title": "new bug",
"body": "steps to reproduce",
"assignee": "42",
})
if err != nil {
t.Fatalf("issue create failed: %v", err)
}
assertEqual(t, createPayload["subject"], "new bug")
assertEqual(t, createPayload["description"], "steps to reproduce")
assertEqual(t, createPayload["assigned_to_id"], "42")
}
func TestIssueView(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "subject": "test issue", "status_id": float64(1),
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runIssueShortcut(t, server, "view", map[string]string{"number": "1"})
if err != nil {
t.Fatalf("issue view failed: %v", err)
}
}
func TestIssueComment(t *testing.T) {
var commentPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/1/journals.json" {
commentPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "notes": "looks good",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runIssueShortcut(t, server, "comment", map[string]string{
"number": "1",
"body": "looks good",
})
if err != nil {
t.Fatalf("issue comment failed: %v", err)
}
assertEqual(t, commentPayload["notes"], "looks good")
}
func TestIssueAssignSendsCorrectUser(t *testing.T) {
var assignPath string
var assignPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/issues/42/assignees.json":
assignPath = r.URL.Path
assignPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"message": "指派成功",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "assign", map[string]string{
"number": "42",
"user": "zhangsan",
})
if err != nil {
t.Fatalf("assign shortcut failed: %v", err)
}
if assignPath == "" {
t.Fatal("assign endpoint was not called")
}
assertEqual(t, assignPayload["assigned_to_id"], "zhangsan")
}
func TestIssueAssignRequiresNumberAndUser(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
writeJSON(t, w, map[string]interface{}{
"message": "ok",
})
})
defer server.Close()
err := runIssueShortcut(t, server, "assign", map[string]string{"number": ""})
if err == nil {
t.Fatal("assign shortcut should error when required flags are missing")
}
}
func TestIssueLabelAddSendsCorrectTagIDs(t *testing.T) {
var capturedPaths []string
var capturedPayloads []map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/42/tags.json":
capturedPaths = append(capturedPaths, r.URL.Path)
capturedPayloads = append(capturedPayloads, decodeJSON(t, r))
writeJSON(t, w, map[string]interface{}{
"message": "标签添加成功",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "label", map[string]string{
"number": "42",
"add": "5,8",
})
if err != nil {
t.Fatalf("label shortcut failed: %v", err)
}
if len(capturedPaths) != 2 {
t.Fatalf("expected 2 tag add calls, got %d", len(capturedPaths))
}
assertEqual(t, capturedPayloads[0]["tag_id"], "5")
assertEqual(t, capturedPayloads[1]["tag_id"], "8")
}
func TestIssueLabelRemoveSendsDeleteRequests(t *testing.T) {
var capturedPaths []string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/42/tags/3.json":
capturedPaths = append(capturedPaths, r.URL.Path)
writeJSON(t, w, map[string]interface{}{
"message": "标签删除成功",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "label", map[string]string{
"number": "42",
"remove": "3",
})
if err != nil {
t.Fatalf("label shortcut failed: %v", err)
}
if len(capturedPaths) != 1 {
t.Fatalf("expected 1 tag delete call, got %d", len(capturedPaths))
}
}
func TestBatchAssign(t *testing.T) {
var assignPath string
var assignPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/issues/1/assignees.json":
assignPath = r.URL.Path
assignPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "指派成功"})
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/issues/2/assignees.json":
writeJSON(t, w, map[string]interface{}{"message": "指派成功"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-assign", map[string]string{
"numbers": "1,2",
"user": "zhangsan",
"dry-run": "false",
})
if err != nil {
t.Fatalf("batch-assign failed: %v", err)
}
if assignPath == "" {
t.Fatal("assign endpoint was not called")
}
assertEqual(t, assignPayload["assigned_to_id"], "zhangsan")
}
func TestBatchAssignDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made in dry-run mode")
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-assign", map[string]string{
"numbers": "1,2,3",
"user": "zhangsan",
"dry-run": "true",
})
if err != nil {
t.Fatalf("batch-assign dry-run failed: %v", err)
}
}
func TestBatchLabelAddAndRemove(t *testing.T) {
var capturedPaths []string
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issues/1/tags.json":
capturedPaths = append(capturedPaths, r.URL.Path)
writeJSON(t, w, map[string]interface{}{"message": "标签添加成功"})
case r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/1/tags/3.json":
capturedPaths = append(capturedPaths, r.URL.Path)
writeJSON(t, w, map[string]interface{}{"message": "标签删除成功"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-label", map[string]string{
"numbers": "1",
"add": "5",
"remove": "3",
"dry-run": "false",
})
if err != nil {
t.Fatalf("batch-label failed: %v", err)
}
if len(capturedPaths) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(capturedPaths))
}
}
func TestBatchLabelDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made in dry-run mode")
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-label", map[string]string{
"numbers": "1,2",
"add": "5,8",
"dry-run": "true",
})
if err != nil {
t.Fatalf("batch-label dry-run failed: %v", err)
}
}
func TestBatchMilestone(t *testing.T) {
var patchPayload map[string]interface{}
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json":
writeJSON(t, w, map[string]interface{}{
"subject": "Test issue", "description": "desc",
})
case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json":
patchPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "success"})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-milestone", map[string]string{
"numbers": "1",
"milestone": "5",
"dry-run": "false",
})
if err != nil {
t.Fatalf("batch-milestone failed: %v", err)
}
assertEqual(t, patchPayload["fixed_version_id"], "5")
assertEqual(t, patchPayload["subject"], "Test issue")
assertEqual(t, patchPayload["description"], "desc")
}
func TestBatchMilestoneDryRun(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made in dry-run mode")
})
defer server.Close()
err := runIssueShortcut(t, server, "batch-milestone", map[string]string{
"numbers": "1,2,3",
"milestone": "5",
"dry-run": "true",
})
if err != nil {
t.Fatalf("batch-milestone dry-run failed: %v", err)
}
}
func TestIssueLabelRequiresAddOrRemove(t *testing.T) {
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("should not make any API calls")
})
defer server.Close()
err := runIssueShortcut(t, server, "label", map[string]string{
"number": "42",
})
if err == nil {
t.Fatal("expected error when neither --add nor --remove is provided")
}
}

133
shortcuts/label/label.go Normal file
View File

@ -0,0 +1,133 @@
package label
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List issue labels",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{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 fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q)
if err != nil {
return fmt.Errorf("获取标签列表失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create an issue label",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Label name", Required: true},
{Name: "color", Short: "c", Usage: "Label color (hex, e.g. #FF0000)", Default: "#F17013"},
{Name: "description", Short: "d", Usage: "Label description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
body := map[string]interface{}{
"name": name,
"color": ctx.Arg("color"),
}
if desc := ctx.Arg("description"); desc != "" {
body["description"] = desc
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issue_tags", body)
if err != nil {
return fmt.Errorf("创建标签失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update an issue label",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
{Name: "name", Short: "n", Usage: "New name"},
{Name: "color", Short: "c", Usage: "New color (hex, e.g. #FF0000)"},
{Name: "description", Short: "d", Usage: "New description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
name := ctx.Arg("name")
color := ctx.Arg("color")
desc := ctx.Arg("description")
if name == "" && color == "" && desc == "" {
return fmt.Errorf("至少需要指定 --name、--color 或 --description 中的一个")
}
body := map[string]interface{}{}
if name != "" {
body["name"] = name
}
if color != "" {
body["color"] = color
}
if desc != "" {
body["description"] = desc
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), body)
if err != nil {
return fmt.Errorf("更新标签失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete an issue label",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return fmt.Errorf("删除标签失败: %w", err)
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,202 @@
package label
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 TestLabelList(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/issue_tags.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 2,
"issue_tags": []map[string]interface{}{
{"id": 1, "name": "bug", "color": "#FF0000"},
{"id": 2, "name": "feature", "color": "#00FF00"},
},
})
}))
defer server.Close()
err := runLabelShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestLabelCreate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issue_tags.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runLabelShortcut(t, server, "create", map[string]string{
"name": "enhancement",
"color": "#0000FF",
"description": "New feature",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["name"], "enhancement")
assertEqual(t, payload["color"], "#0000FF")
assertEqual(t, payload["description"], "New feature")
}
func TestLabelCreateWithDefaultColor(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/issue_tags.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runLabelShortcut(t, server, "create", map[string]string{
"name": "bug",
"color": "#F17013",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["color"], "#F17013")
}
func TestLabelUpdate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issue_tags/3.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runLabelShortcut(t, server, "update", map[string]string{
"id": "3",
"name": "critical",
"color": "#FF0000",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, payload["name"], "critical")
assertEqual(t, payload["color"], "#FF0000")
}
func TestLabelUpdateRequiresAtLeastOneField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made when no fields are provided")
}))
defer server.Close()
err := runLabelShortcut(t, server, "update", map[string]string{
"id": "3",
})
if err == nil {
t.Fatal("expected error when no update fields provided, got nil")
}
}
func TestLabelDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issue_tags/3.json" {
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runLabelShortcut(t, server, "delete", map[string]string{
"id": "3",
})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func runLabelShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findLabelShortcut(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 findLabelShortcut(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 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 fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

115
shortcuts/member/member.go Normal file
View File

@ -0,0 +1,115 @@
package member
import (
"fmt"
"net/url"
"strconv"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List project members",
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 k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k)
}
env, err := ctx.CallAPIWithQuery("GET",
fmt.Sprintf("/v1/%s/%s/collaborators", ctx.Owner, ctx.Repo), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "add",
Description: "Add a member to the project",
Flags: []common.Flag{
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
userIDStr, _ := ctx.RequireArg("user-id")
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
}
body := map[string]interface{}{"user_id": userID}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "remove",
Description: "Remove a member from the project",
Flags: []common.Flag{
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
userIDStr, _ := ctx.RequireArg("user-id")
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
}
body := map[string]interface{}{"user_id": userID}
env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Change a member's role",
Flags: []common.Flag{
{Name: "user-id", Short: "u", Usage: "Numeric user ID", Required: true},
{Name: "role", Short: "r", Usage: "Role: Manager, Developer, Reporter", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
userIDStr, _ := ctx.RequireArg("user-id")
role, _ := ctx.RequireArg("role")
userID, err := strconv.ParseInt(userIDStr, 10, 64)
if err != nil {
return fmt.Errorf("invalid user-id %q: must be an integer", userIDStr)
}
switch role {
case "Manager", "Developer", "Reporter":
default:
return fmt.Errorf("invalid role %q: must be Manager, Developer, or Reporter", role)
}
body := map[string]interface{}{
"user_id": userID,
"role": role,
}
env, err := ctx.CallAPI("PUT", ctx.RepoPath()+"/collaborators/change_role", body)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,152 @@
package member
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 TestMemberList(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/collaborators.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 2,
"collaborators": []map[string]interface{}{
{"id": 1, "login": "alice", "role_name": "Manager"},
{"id": 2, "login": "bob", "role_name": "Developer"},
},
})
}))
defer server.Close()
if err := runMemberShortcut(t, server, "list", map[string]string{}); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestMemberAdd(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/collaborators.json" {
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["user_id"].(float64) != 42 {
t.Fatalf("expected user_id=42, got %v", payload["user_id"])
}
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
if err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "42"}); err != nil {
t.Fatalf("add failed: %v", err)
}
}
func TestMemberAddRejectsInvalidID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made with invalid ID")
}))
defer server.Close()
err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "abc"})
if err == nil {
t.Fatal("expected error for invalid user-id")
}
}
func TestMemberRemove(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/collaborators/remove.json" {
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["user_id"].(float64) != 42 {
t.Fatalf("expected user_id=42")
}
writeJSON(t, w, map[string]interface{}{})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
if err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "42"}); err != nil {
t.Fatalf("remove failed: %v", err)
}
}
func TestMemberUpdate(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PUT" && r.URL.Path == "/owner/repo/collaborators/change_role.json" {
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["user_id"].(float64) != 42 {
t.Fatalf("expected user_id=42")
}
if payload["role"] != "Developer" {
t.Fatalf("expected role=Developer, got %v", payload["role"])
}
writeJSON(t, w, map[string]interface{}{})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMemberShortcut(t, server, "update", map[string]string{
"user-id": "42", "role": "Developer",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
}
func TestMemberUpdateRejectsInvalidRole(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made with invalid role")
}))
defer server.Close()
err := runMemberShortcut(t, server, "update", map[string]string{
"user-id": "42", "role": "Admin",
})
if err == nil {
t.Fatal("expected error for invalid role")
}
}
// === helpers ===
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,
}
return shortcut.Run(ctx)
}
func findMemberShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
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")
json.NewEncoder(w).Encode(payload)
}

View File

@ -0,0 +1,192 @@
package milestone
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List milestones",
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: opening, closed, all", Default: "opening"},
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{Name: "sort", Usage: "Sort by: created_on, updated_on, effective_date, issues_count, percent", Default: "created_on"},
{Name: "direction", Short: "d", Usage: "Sort direction: asc, desc", Default: "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 fmt.Errorf("解析仓库信息失败: %w", err)
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" && s != "all" {
q.Set("category", s)
}
if k := ctx.Arg("keyword"); k != "" {
q.Set("keyword", k)
}
if s := ctx.Arg("sort"); s != "" {
q.Set("sort_by", s)
}
if d := ctx.Arg("direction"); d != "" {
q.Set("sort_direction", d)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/milestones", q)
if err != nil {
return fmt.Errorf("获取里程碑列表失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View milestone details",
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 fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return fmt.Errorf("查看里程碑详情失败: %w", 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"},
{Name: "due", Usage: "Due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
body := map[string]interface{}{
"name": name,
}
if desc := ctx.Arg("description"); desc != "" {
body["description"] = desc
}
if due := ctx.Arg("due"); due != "" {
body["effective_date"] = due
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/milestones", body)
if err != nil {
return fmt.Errorf("创建里程碑失败: %w", 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: "New name"},
{Name: "description", Short: "d", Usage: "New description"},
{Name: "due", Usage: "New due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
name := ctx.Arg("name")
desc := ctx.Arg("description")
due := ctx.Arg("due")
if name == "" && desc == "" && due == "" {
return fmt.Errorf("至少需要指定 --name、--description 或 --due 中的一个")
}
body := map[string]interface{}{}
if name != "" {
body["name"] = name
}
if desc != "" {
body["description"] = desc
}
if due != "" {
body["effective_date"] = due
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), body)
if err != nil {
return fmt.Errorf("更新里程碑失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "close",
Description: "Close 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 fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body := map[string]interface{}{
"status": "closed",
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/milestones/%s/update_status", ctx.Owner, ctx.Repo, id), body)
if err != nil {
return fmt.Errorf("关闭里程碑失败: %w", 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 fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return fmt.Errorf("删除里程碑失败: %w", err)
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,221 @@
package milestone
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 TestMilestoneList(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/milestones.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if r.URL.Query().Get("category") != "opening" {
t.Fatalf("expected category=opening, got %s", r.URL.Query().Get("category"))
}
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"milestones": []map[string]interface{}{
{"id": 1, "name": "v1.0", "status": "open"},
},
})
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "list", map[string]string{
"state": "opening",
})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestMilestoneCreate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/milestones.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "create", map[string]string{
"name": "v2.0",
"description": "Next release",
"due": "2026-06-01",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["name"], "v2.0")
assertEqual(t, payload["description"], "Next release")
assertEqual(t, payload["effective_date"], "2026-06-01")
}
func TestMilestoneUpdate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/milestones/5.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "update", map[string]string{
"id": "5",
"name": "v3.0",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
assertEqual(t, payload["name"], "v3.0")
}
func TestMilestoneClose(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/milestones/5/update_status.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "操作成功",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "close", map[string]string{
"id": "5",
})
if err != nil {
t.Fatalf("close shortcut failed: %v", err)
}
assertEqual(t, payload["status"], "closed")
}
func TestMilestoneDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/milestones/5.json" {
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "delete", map[string]string{
"id": "5",
})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestMilestoneView(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/milestones/1.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "name": "v1.0", "status": "open",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "view", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("milestone view failed: %v", err)
}
}
func TestMilestoneUpdateRequiresAtLeastOneField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made when no fields are provided")
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "update", map[string]string{
"id": "5",
})
if err == nil {
t.Fatal("expected error when no update fields provided, got nil")
}
}
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,
}
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 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 fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

336
shortcuts/org/batch.go Normal file
View File

@ -0,0 +1,336 @@
package org
import (
"encoding/csv"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type batchInviteResult struct {
User string `json:"user" yaml:"user"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
type batchInviteSummary struct {
Org string `json:"org" yaml:"org"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Duration string `json:"duration" yaml:"duration"`
Results []batchInviteResult `json:"results" yaml:"results"`
}
func newBatchInviteShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-invite",
Description: "批量邀请成员加入组织,支持逗号分隔列表或 CSV 文件",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "组织 ID 或 login", Required: true},
{Name: "users", Short: "u", Usage: "逗号分隔的用户名或 ID例如: alice,bob,charlie"},
{Name: "from", Usage: "从 CSV 文件读取用户名。支持 user/login/user_id 列名或无表头首列"},
{Name: "role", Short: "r", Usage: "成员角色: member 或 admin", Default: "member"},
{Name: "dry-run", Usage: "仅预览将要邀请的成员,不实际执行", Bool: true, Default: "false"},
},
Run: runBatchInvite,
}
}
func runBatchInvite(ctx *common.RuntimeContext) error {
start := time.Now()
orgID, err := ctx.RequireArg("id")
if err != nil {
return err
}
role := ctx.Arg("role")
if role == "" {
role = "member"
}
if role != "member" && role != "admin" {
return fmt.Errorf("无效的角色 %q: 必须为 member 或 admin", role)
}
userInputs, err := collectUsers(ctx.Arg("users"), ctx.Arg("from"))
if err != nil {
return err
}
if len(userInputs) == 0 {
return fmt.Errorf("未提供用户名,请使用 --users alice,bob 或 --from users.csv")
}
// 将用户名解析为数字 ID如果传入的已经是数字则直接使用
resolvedUsers, resolveErrors := resolveUserIDs(ctx, userInputs)
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchInviteSummary{
Org: orgID,
DryRun: dryRun,
Total: len(userInputs),
Results: make([]batchInviteResult, 0, len(userInputs)),
}
// 先记录解析失败的
for input, errMsg := range resolveErrors {
summary.Results = append(summary.Results, batchInviteResult{
User: input,
Action: "invite",
Status: "failed",
Error: errMsg,
})
summary.Failed++
}
for _, ru := range resolvedUsers {
displayName := ru.Input
if ru.Input != ru.UserID {
displayName = fmt.Sprintf("%s (ID:%s)", ru.Input, ru.UserID)
}
result := batchInviteResult{User: displayName, Action: "invite"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
userIDInt, _ := strconv.ParseInt(ru.UserID, 10, 64)
body := map[string]interface{}{
"user_id": userIDInt,
"role": role,
}
path := fmt.Sprintf("/organizations/%s/organization_users", orgID)
if _, err := ctx.CallAPI("POST", path, body); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "invited"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个成员邀请失败", summary.Failed, summary.Total)
}
return nil
}
func collectUsers(usersValue, csvPath string) ([]string, error) {
users, err := parseUserList(usersValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return users, nil
}
csvUsers, err := readUsersFromCSV(csvPath)
if err != nil {
return nil, err
}
return mergeUserLists(users, csvUsers), nil
}
func parseUserList(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return normalizeUserIDs(strings.Split(value, ","))
}
func readUsersFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
}
if len(records) == 0 {
return nil, nil
}
userCol := -1
startRow := 0
for i, cell := range records[0] {
switch strings.ToLower(strings.TrimSpace(cell)) {
case "user", "login", "user_id", "username":
userCol = i
startRow = 1
}
}
if userCol == -1 {
userCol = 0
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if userCol >= len(record) {
continue
}
values = append(values, record[userCol])
}
return normalizeUserIDs(values)
}
func normalizeUserIDs(values []string) ([]string, error) {
users := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
user := strings.TrimSpace(value)
if user == "" {
continue
}
if seen[user] {
continue
}
seen[user] = true
users = append(users, user)
}
return users, nil
}
func parseBool(value string) bool {
if strings.EqualFold(strings.TrimSpace(value), "true") {
return true
}
return false
}
func mergeUserLists(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
for _, users := range values {
for _, u := range users {
if seen[u] {
continue
}
seen[u] = true
merged = append(merged, u)
}
}
return merged
}
// resolvedUser holds the mapping from user input to numeric ID.
type resolvedUser struct {
Input string // original input (username or numeric string)
UserID string // resolved numeric user ID
}
// resolveUserIDs converts usernames to numeric user IDs via the search API.
// If an input is already numeric, it is used directly.
func resolveUserIDs(ctx *common.RuntimeContext, inputs []string) ([]resolvedUser, map[string]string) {
results := make([]resolvedUser, 0, len(inputs))
errors := make(map[string]string)
for _, input := range inputs {
// 如果已经是纯数字,直接使用
if _, err := strconv.Atoi(input); err == nil {
results = append(results, resolvedUser{Input: input, UserID: input})
continue
}
// 通过搜索 API 查找用户名对应的数字 ID
q := url.Values{}
q.Set("search", input)
q.Set("limit", "5")
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
if err != nil {
errors[input] = fmt.Sprintf("查找用户失败: %v", err)
continue
}
// env.Data 是 {"total_count":N, "users":[...]} 的 map 结构
users := extractUsers(env.Data)
if len(users) == 0 {
errors[input] = fmt.Sprintf("未找到用户 %q", input)
continue
}
// 精确匹配用户名
matched := users[0]
for _, u := range users {
if u.Login == input {
matched = u
break
}
}
results = append(results, resolvedUser{
Input: input,
UserID: strconv.Itoa(matched.UserID),
})
}
return results, errors
}
// searchUser holds a parsed user from search results.
type searchUser struct {
Login string
UserID int
}
// extractUsers extracts the user list from the search API response data.
// data is expected to be map[string]interface{} with a "users" key containing a slice.
func extractUsers(data interface{}) []searchUser {
if data == nil {
return nil
}
m, ok := data.(map[string]interface{})
if !ok {
return nil
}
rawUsers, ok := m["users"]
if !ok {
return nil
}
usersSlice, ok := rawUsers.([]interface{})
if !ok {
return nil
}
var result []searchUser
for _, item := range usersSlice {
um, ok := item.(map[string]interface{})
if !ok {
continue
}
login, _ := um["login"].(string)
userID := 0
switch v := um["user_id"].(type) {
case float64:
userID = int(v)
case int:
userID = v
case string:
userID, _ = strconv.Atoi(v)
}
if login != "" && userID > 0 {
result = append(result, searchUser{Login: login, UserID: userID})
}
}
return result
}

View File

@ -9,6 +9,7 @@ import (
func Shortcuts() []*common.Shortcut { func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{ return []*common.Shortcut{
newBatchInviteShortcut(),
{ {
Name: "list", Name: "list",
Description: "List organizations", Description: "List organizations",
@ -22,7 +23,7 @@ func Shortcuts() []*common.Shortcut {
q.Set("limit", ctx.Arg("limit")) q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/organizations", q) env, err := ctx.CallAPIWithQuery("GET", "/organizations", q)
if err != nil { if err != nil {
return err return fmt.Errorf("获取组织列表失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -34,11 +35,14 @@ func Shortcuts() []*common.Shortcut {
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true}, {Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
id, _ := ctx.RequireArg("id") id, err := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
if err != nil { if err != nil {
return err return err
} }
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
if err != nil {
return fmt.Errorf("查看组织详情失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },
@ -51,13 +55,16 @@ func Shortcuts() []*common.Shortcut {
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
id, _ := ctx.RequireArg("id") id, err := ctx.RequireArg("id")
if err != nil {
return err
}
q := url.Values{} q := url.Values{}
q.Set("page", ctx.Arg("page")) q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit")) q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q) env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q)
if err != nil { if err != nil {
return err return fmt.Errorf("获取组织成员列表失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -70,7 +77,10 @@ func Shortcuts() []*common.Shortcut {
{Name: "description", Short: "d", Usage: "Description"}, {Name: "description", Short: "d", Usage: "Description"},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
name, _ := ctx.RequireArg("name") name, err := ctx.RequireArg("name")
if err != nil {
return err
}
payload := map[string]interface{}{ payload := map[string]interface{}{
"name": name, "name": name,
} }
@ -79,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("POST", "/organizations", payload) env, err := ctx.CallAPI("POST", "/organizations", payload)
if err != nil { if err != nil {
return err return fmt.Errorf("创建组织失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },

138
shortcuts/org/org_test.go Executable file
View File

@ -0,0 +1,138 @@
package org
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 TestOrgList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/organizations.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "name": "org1"},
{"id": float64(2), "name": "org2"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runOrgShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("org list failed: %v", err)
}
}
func TestOrgInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/organizations/1.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "name": "myorg", "description": "A test org",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runOrgShortcut(t, server, "info", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("org info failed: %v", err)
}
}
func TestOrgMembers(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/organizations/1/organization_users.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "login": "user1", "role": "admin"},
{"id": float64(2), "login": "user2", "role": "member"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runOrgShortcut(t, server, "members", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("org members failed: %v", err)
}
}
func TestOrgCreate(t *testing.T) {
var createPayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/organizations.json" {
createPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(99), "name": "new-org",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runOrgShortcut(t, server, "create", map[string]string{
"name": "new-org",
"description": "My new organization",
})
if err != nil {
t.Fatalf("org create failed: %v", err)
}
if createPayload["name"] != "new-org" {
t.Fatalf("expected name=new-org, got %v", createPayload["name"])
}
if createPayload["description"] != "My new organization" {
t.Fatalf("expected description='My new organization', got %v", createPayload["description"])
}
}
func runOrgShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findOrgShortcut(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 findOrgShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
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)
}
}

View File

@ -20,7 +20,7 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
q := url.Values{} q := url.Values{}
q.Set("page", ctx.Arg("page")) q.Set("page", ctx.Arg("page"))
@ -30,7 +30,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
if err != nil { if err != nil {
return err return fmt.Errorf("获取 PR 列表失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -46,10 +46,16 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
title, err := ctx.RequireArg("title")
if err != nil {
return err
}
head, err := ctx.RequireArg("head")
if err != nil {
return err return err
} }
title, _ := ctx.RequireArg("title")
head, _ := ctx.RequireArg("head")
base := ctx.Arg("base") base := ctx.Arg("base")
if base == "" { if base == "" {
base = "master" base = "master"
@ -64,7 +70,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload) env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
if err != nil { if err != nil {
return err return fmt.Errorf("创建 PR 失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -77,13 +83,16 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
id, _ := ctx.RequireArg("id") id, err := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil { if err != nil {
return err return err
} }
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("查看 PR 详情失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },
@ -96,9 +105,12 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err return err
} }
id, _ := ctx.RequireArg("id")
method := ctx.Arg("method") method := ctx.Arg("method")
if method == "" { if method == "" {
method = "merge" method = "merge"
@ -108,7 +120,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload) env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload)
if err != nil { if err != nil {
return err return fmt.Errorf("合并 PR 失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -121,13 +133,16 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
id, _ := ctx.RequireArg("id") id, err := ctx.RequireArg("id")
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
if err != nil { if err != nil {
return err return err
} }
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("关闭 PR 失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },
@ -139,13 +154,16 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
id, _ := ctx.RequireArg("id") id, err := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil { if err != nil {
return err return err
} }
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 PR 变更文件失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },
@ -157,13 +175,53 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
id, _ := ctx.RequireArg("id") id, err := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil { if err != nil {
return err return err
} }
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 PR Diff 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "review",
Description: "Submit a review on a pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Review comment body", Required: true},
{Name: "action", Short: "a", Usage: "Review action: approve, comment, request-changes", Default: "comment"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err
}
action := ctx.Arg("action")
if action == "" {
action = "comment"
}
reviewAction := mapAction(action)
payload := map[string]interface{}{
"body": body,
"action": reviewAction,
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload)
if err != nil {
return fmt.Errorf("提交 PR Review 失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },
@ -176,14 +234,20 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body, err := ctx.RequireArg("body")
if err != nil {
return err return err
} }
id, _ := ctx.RequireArg("id")
body, _ := ctx.RequireArg("body")
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if err != nil { if err != nil {
return fmt.Errorf("fetch PR: %w", err) return fmt.Errorf("获取 PR 详情: %w", err)
} }
issueID, err := extractIssueID(prEnv) issueID, err := extractIssueID(prEnv)
if err != nil { if err != nil {
@ -195,7 +259,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload) env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload)
if err != nil { if err != nil {
return err return fmt.Errorf("添加 PR 评论失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -203,18 +267,29 @@ func Shortcuts() []*common.Shortcut {
} }
} }
func mapAction(action string) string {
switch action {
case "approve":
return "approved"
case "request-changes":
return "changes_requested"
default:
return "commented"
}
}
func extractIssueID(env *output.Envelope) (int64, error) { func extractIssueID(env *output.Envelope) (int64, error) {
data, ok := env.Data.(map[string]interface{}) data, ok := env.Data.(map[string]interface{})
if !ok { if !ok {
return 0, fmt.Errorf("unexpected PR response format") return 0, fmt.Errorf("PR 响应格式异常")
} }
issue, ok := data["issue"].(map[string]interface{}) issue, ok := data["issue"].(map[string]interface{})
if !ok { if !ok {
return 0, fmt.Errorf("PR response missing issue field") return 0, fmt.Errorf("PR 响应缺少 issue 字段")
} }
idFloat, ok := issue["id"].(float64) idFloat, ok := issue["id"].(float64)
if !ok { if !ok {
return 0, fmt.Errorf("PR response missing issue.id field") return 0, fmt.Errorf("PR 响应缺少 issue.id 字段")
} }
return int64(idFloat), nil return int64(idFloat), nil
} }

235
shortcuts/pr/pr_test.go Normal file → Executable file
View File

@ -141,3 +141,238 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
} }
} }
func TestPRList(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.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "title": "fix bug", "state": "open"},
{"id": float64(2), "title": "add feature", "state": "merged"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("pr list failed: %v", err)
}
}
func TestPRCreate(t *testing.T) {
var createPayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls.json" {
createPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "title": "new feature",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "create", map[string]string{
"title": "new feature",
"head": "feature-branch",
"base": "master",
"body": "This adds a new feature",
})
if err != nil {
t.Fatalf("pr create failed: %v", err)
}
assertEqual(t, createPayload["title"], "new feature")
assertEqual(t, createPayload["head"], "feature-branch")
assertEqual(t, createPayload["base"], "master")
assertEqual(t, createPayload["body"], "This adds a new feature")
}
func TestPRView(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/1.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "title": "fix bug", "state": "open",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "view", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("pr view failed: %v", err)
}
}
func TestPRMerge(t *testing.T) {
var mergePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/1/pr_merge.json" {
mergePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"message": "merged"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "merge", map[string]string{
"id": "1",
"method": "squash",
})
if err != nil {
t.Fatalf("pr merge failed: %v", err)
}
assertEqual(t, mergePayload["do"], "squash")
}
func TestPRClose(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/1/refuse_merge.json" {
writeJSON(t, w, map[string]interface{}{"message": "closed"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "close", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("pr close failed: %v", err)
}
}
func TestPRFiles(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/1/files.json" {
writeJSON(t, w, []map[string]interface{}{
{"filename": "src/main.go", "status": "modified"},
{"filename": "src/test.go", "status": "added"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "files", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("pr files failed: %v", err)
}
}
func TestPRDiff(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/1/files.json" {
writeJSON(t, w, map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"filename": "main.go", "patch": "@@ -1,3 +1,4 @@"},
},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runPRShortcut(t, server, "diff", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("pr diff failed: %v", err)
}
}
func TestPRReviewSubmitsApproveAction(t *testing.T) {
var reviewPath string
var reviewPayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
reviewPath = r.URL.Path
reviewPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"state": "approved",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runPRShortcut(t, server, "review", map[string]string{
"id": "13",
"body": "LGTM!",
"action": "approve",
})
if err != nil {
t.Fatalf("review shortcut failed: %v", err)
}
if reviewPath == "" {
t.Fatal("review endpoint was not called")
}
assertEqual(t, reviewPayload["body"], "LGTM!")
assertEqual(t, reviewPayload["action"], "approved")
}
func TestPRReviewDefaultsToCommentAction(t *testing.T) {
var reviewPayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
reviewPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(2),
"state": "commented",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runPRShortcut(t, server, "review", map[string]string{
"id": "13",
"body": "Looks good",
})
if err != nil {
t.Fatalf("review shortcut failed: %v", err)
}
assertEqual(t, reviewPayload["action"], "commented")
}
func TestPRReviewSubmitsRequestChangesAction(t *testing.T) {
var reviewPayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "POST" && r.URL.Path == "/owner/repo/pulls/13/reviews.json":
reviewPayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(3),
"state": "changes_requested",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runPRShortcut(t, server, "review", map[string]string{
"id": "13",
"body": "Please fix the null check",
"action": "request-changes",
})
if err != nil {
t.Fatalf("review shortcut failed: %v", err)
}
assertEqual(t, reviewPayload["action"], "changes_requested")
assertEqual(t, reviewPayload["body"], "Please fix the null check")
}

View File

@ -5,40 +5,67 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/file"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"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/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/release"
"github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/repo"
"github.com/gitlink-org/gitlink-cli/shortcuts/search" "github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/star"
"github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/watch"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/wiki"
) )
// RegisterAll mounts all shortcut groups onto the root command. // RegisterAll mounts all shortcut groups onto the root command.
func RegisterAll(root *cobra.Command) { func RegisterAll(root *cobra.Command) {
groups := map[string][]*common.Shortcut{ groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(), "repo": repo.Shortcuts(),
"issue": issue.Shortcuts(), "issue": issue.Shortcuts(),
"pr": pr.Shortcuts(), "pr": pr.Shortcuts(),
"release": release.Shortcuts(), "release": release.Shortcuts(),
"branch": branch.Shortcuts(), "branch": branch.Shortcuts(),
"org": org.Shortcuts(), "org": org.Shortcuts(),
"user": user.Shortcuts(), "user": user.Shortcuts(),
"search": search.Shortcuts(), "search": search.Shortcuts(),
"ci": ci.Shortcuts(), "ci": ci.Shortcuts(),
"commit": commit.Shortcuts(),
"milestone": milestone.Shortcuts(),
"webhook": webhook.Shortcuts(),
"label": label.Shortcuts(),
"file": file.Shortcuts(),
"member": member.Shortcuts(),
"watch": watch.Shortcuts(),
"star": star.Shortcuts(),
"wiki": wiki.Shortcuts(),
} }
descriptions := map[string]string{ descriptions := map[string]string{
"repo": "Repository operations", "repo": "Repository operations",
"issue": "Issue operations", "issue": "Issue operations",
"pr": "Pull request operations", "pr": "Pull request operations",
"release": "Release operations", "release": "Release operations",
"branch": "Branch operations", "branch": "Branch operations",
"org": "Organization operations", "org": "Organization operations",
"user": "User operations", "user": "User operations",
"search": "Search operations", "search": "Search operations",
"ci": "CI/CD operations", "ci": "CI/CD operations",
"commit": "Commit operations",
"milestone": "Milestone operations",
"webhook": "Webhook operations",
"label": "Issue label operations",
"file": "Repository file operations",
"member": "Project member management",
"watch": "Watch repository operations",
"star": "Star repository operations",
"wiki": "Wiki page operations",
} }
for name, shortcuts := range groups { for name, shortcuts := range groups {

View File

@ -2,7 +2,10 @@ package release
import ( import (
"fmt" "fmt"
"io"
"net/url" "net/url"
"os"
"path/filepath"
"github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -19,14 +22,14 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
q := url.Values{} q := url.Values{}
q.Set("page", ctx.Arg("page")) q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit")) q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q)
if err != nil { if err != nil {
return err return fmt.Errorf("获取 Release 列表失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -43,10 +46,16 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
tag, err := ctx.RequireArg("tag")
if err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err return err
} }
tag, _ := ctx.RequireArg("tag")
name, _ := ctx.RequireArg("name")
payload := map[string]interface{}{ payload := map[string]interface{}{
"tag_name": tag, "tag_name": tag,
"name": name, "name": name,
@ -62,7 +71,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload) env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload)
if err != nil { if err != nil {
return err return fmt.Errorf("创建 Release 失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -75,16 +84,122 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
id, _ := ctx.RequireArg("id") id, err := ctx.RequireArg("id")
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if err != nil { if err != nil {
return err return err
} }
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("查看 Release 详情失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },
{
Name: "download",
Description: "Download release assets",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Release ID or tag", Required: true},
{Name: "asset", Short: "a", Usage: "Asset file name (omit to list all assets)"},
{Name: "dir", Short: "d", Usage: "Download directory", Default: "."},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
assetName := ctx.Arg("asset")
dlDir := ctx.Arg("dir")
if dlDir == "" {
dlDir = "."
}
releaseEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if err != nil {
return fmt.Errorf("获取 Release 信息失败: %w", err)
}
releaseData, ok := releaseEnv.Data.(map[string]interface{})
if !ok {
return fmt.Errorf("Release 响应格式异常")
}
assets, ok := releaseData["assets"].([]interface{})
if !ok || len(assets) == 0 {
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "该 Release 没有附件资源",
}, nil))
}
if assetName == "" {
var assetList []map[string]interface{}
for _, a := range assets {
asset, _ := a.(map[string]interface{})
assetList = append(assetList, map[string]interface{}{
"name": asset["name"],
"size": asset["size"],
})
}
return ctx.OutputData(map[string]interface{}{
"assets": assetList,
})
}
var downloadURL string
for _, a := range assets {
asset, _ := a.(map[string]interface{})
name, _ := asset["name"].(string)
if name == assetName {
downloadURL, _ = asset["download_url"].(string)
if downloadURL == "" {
downloadURL, _ = asset["url"].(string)
}
break
}
}
if downloadURL == "" {
return fmt.Errorf("Release 中未找到附件 %q", assetName)
}
if err := os.MkdirAll(dlDir, 0755); err != nil {
return fmt.Errorf("创建目录 %s 失败: %w", dlDir, err)
}
resp, err := ctx.Client.HTTP.Get(downloadURL)
if err != nil {
return fmt.Errorf("下载失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
}
destPath := filepath.Join(dlDir, assetName)
file, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("创建文件 %s 失败: %w", destPath, err)
}
defer file.Close()
written, err := io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "下载完成",
"path": destPath,
"size": written,
}, nil))
},
},
{ {
Name: "delete", Name: "delete",
Description: "Delete a release", Description: "Delete a release",
@ -93,22 +208,21 @@ func Shortcuts() []*common.Shortcut {
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err return err
} }
id, _ := ctx.RequireArg("id")
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) _, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if delErr != nil { if delErr != nil {
// GitLink API bug: delete succeeds but returns error status.
// Verify by checking if the release still exists.
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) _, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
if viewErr != nil { if viewErr != nil {
// Release no longer exists — delete actually succeeded
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功", "message": "删除成功",
}, nil)) }, nil))
} }
// Release still exists — delete truly failed return fmt.Errorf("删除 Release 失败: %w", delErr)
return delErr
} }
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "删除成功", "message": "删除成功",

277
shortcuts/release/release_test.go Executable file
View File

@ -0,0 +1,277 @@
package release
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestReleaseDownloadListsAssetsWhenNoAssetFlag(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"tag": "v1.0",
"assets": []interface{}{
map[string]interface{}{
"name": "app-linux-amd64",
"size": float64(1048576),
"download_url": "https://example.com/dl/app-linux-amd64",
},
map[string]interface{}{
"name": "app-darwin-amd64",
"size": float64(2097152),
"download_url": "https://example.com/dl/app-darwin-amd64",
},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runReleaseShortcut(t, server, "download", map[string]string{
"id": "v1.0",
})
if err != nil {
t.Fatalf("download shortcut failed: %v", err)
}
}
func TestReleaseDownloadNoAssetsReturnsMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"tag": "v1.0",
"assets": []interface{}{},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runReleaseShortcut(t, server, "download", map[string]string{
"id": "v1.0",
})
if err != nil {
t.Fatalf("download shortcut failed: %v", err)
}
}
func TestReleaseDownloadFetchesAssetURL(t *testing.T) {
assetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte("fake-binary-content"))
}))
defer assetServer.Close()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"tag": "v1.0",
"assets": []interface{}{
map[string]interface{}{
"name": "app-linux-amd64",
"size": float64(1048576),
"download_url": assetServer.URL + "/dl/app-linux-amd64",
},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
tmpDir := t.TempDir()
err := runReleaseShortcut(t, server, "download", map[string]string{
"id": "v1.0",
"asset": "app-linux-amd64",
"dir": tmpDir,
})
if err != nil {
t.Fatalf("download shortcut failed: %v", err)
}
destPath := filepath.Join(tmpDir, "app-linux-amd64")
data, err := os.ReadFile(destPath)
if err != nil {
t.Fatalf("failed to read downloaded file: %v", err)
}
if string(data) != "fake-binary-content" {
t.Fatalf("got %q, want %q", string(data), "fake-binary-content")
}
}
func TestReleaseDownloadAssetNotFound(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json":
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"tag": "v1.0",
"assets": []interface{}{
map[string]interface{}{
"name": "app-linux-amd64",
"size": float64(1048576),
},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runReleaseShortcut(t, server, "download", map[string]string{
"id": "v1.0",
"asset": "nonexistent.tar.gz",
})
if err == nil {
t.Fatal("expected error for non-existent asset, got nil")
}
}
func TestReleaseList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "tag_name": "v1.0", "name": "First release"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runReleaseShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("release list failed: %v", err)
}
}
func TestReleaseCreate(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/releases.json" {
body = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "tag_name": "v2.0",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runReleaseShortcut(t, server, "create", map[string]string{
"tag": "v2.0",
"name": "Version 2.0",
"body": "Release notes here",
})
if err != nil {
t.Fatalf("release create failed: %v", err)
}
assertEqual(t, body["tag_name"], "v2.0")
assertEqual(t, body["name"], "Version 2.0")
assertEqual(t, body["body"], "Release notes here")
}
func TestReleaseView(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases/v1.0.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "tag_name": "v1.0", "name": "First Release",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runReleaseShortcut(t, server, "view", map[string]string{"id": "v1.0"})
if err != nil {
t.Fatalf("release view failed: %v", err)
}
}
func TestReleaseDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/owner/repo/releases/1.json" {
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runReleaseShortcut(t, server, "delete", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("release delete failed: %v", err)
}
}
func runReleaseShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findReleaseShortcut(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 findReleaseShortcut(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 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 fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

279
shortcuts/repo/batch.go Normal file
View File

@ -0,0 +1,279 @@
package repo
import (
"encoding/csv"
"fmt"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type batchRepoResult struct {
Repo string `json:"repo" yaml:"repo"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
type batchRepoSummary struct {
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Duration string `json:"duration" yaml:"duration"`
Results []batchRepoResult `json:"results" yaml:"results"`
}
func newBatchDeleteShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-delete",
Description: "批量删除仓库,支持逗号分隔列表或 CSV 文件",
Flags: []common.Flag{
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo例如: alice/proj1,bob/proj2"},
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo、owner,repo 双列或 owner/repo 单列格式"},
{Name: "dry-run", Usage: "仅预览将要删除的仓库,不实际执行", Bool: true, Default: "false"},
},
Run: runBatchDelete,
}
}
func runBatchDelete(ctx *common.RuntimeContext) error {
start := time.Now()
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
if err != nil {
return err
}
if len(repos) == 0 {
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
dryRunVal := false
if ctx.Arg("dry-run") != "" {
dryRunVal = dryRun
}
_ = dryRunVal
summary := batchRepoSummary{
Total: len(repos),
Results: make([]batchRepoResult, 0, len(repos)),
}
for _, repoID := range repos {
parts := strings.SplitN(repoID, "/", 2)
result := batchRepoResult{Repo: repoID, Action: "delete"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
path := fmt.Sprintf("/%s/%s", parts[0], parts[1])
if _, err := ctx.CallAPI("DELETE", path, nil); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "deleted"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个仓库删除失败", summary.Failed, summary.Total)
}
return nil
}
func newBatchForkShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-fork",
Description: "批量 Fork 仓库,支持逗号分隔列表或 CSV 文件",
Flags: []common.Flag{
{Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo例如: alice/proj1,bob/proj2"},
{Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo、owner,repo 双列或 owner/repo 单列格式"},
{Name: "dry-run", Usage: "仅预览将要 Fork 的仓库,不实际执行", Bool: true, Default: "false"},
},
Run: runBatchFork,
}
}
func runBatchFork(ctx *common.RuntimeContext) error {
start := time.Now()
repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from"))
if err != nil {
return err
}
if len(repos) == 0 {
return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := batchRepoSummary{
Total: len(repos),
Results: make([]batchRepoResult, 0, len(repos)),
}
for _, repoID := range repos {
parts := strings.SplitN(repoID, "/", 2)
result := batchRepoResult{Repo: repoID, Action: "fork"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
path := fmt.Sprintf("/%s/%s/forks", parts[0], parts[1])
if _, err := ctx.CallAPI("POST", path, nil); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "forked"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
summary.Duration = time.Since(start).String()
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d / %d 个仓库 Fork 失败", summary.Failed, summary.Total)
}
return nil
}
func collectRepos(reposValue, csvPath string) ([]string, error) {
repos, err := parseRepoList(reposValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return repos, nil
}
csvRepos, err := readReposFromCSV(csvPath)
if err != nil {
return nil, err
}
return mergeRepoLists(repos, csvRepos), nil
}
func parseRepoList(value string) ([]string, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
return normalizeRepoIDs(strings.Split(value, ","))
}
func readReposFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("解析 CSV 文件失败: %w", err)
}
if len(records) == 0 {
return nil, nil
}
// Detect column layout: single owner/repo column, or owner+repo dual columns
singleCol, ownerCol, repoCol := -1, -1, -1
startRow := 0
for i, cell := range records[0] {
switch strings.ToLower(strings.TrimSpace(cell)) {
case "owner/repo", "full_name":
singleCol = i
startRow = 1
case "owner":
ownerCol = i
startRow = 1
case "repo", "repository", "name":
repoCol = i
startRow = 1
}
}
// Fallback: no header — first column is owner/repo
if singleCol == -1 && ownerCol == -1 && repoCol == -1 {
singleCol = 0
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
var repoID string
if singleCol >= 0 && singleCol < len(record) {
repoID = record[singleCol]
} else if ownerCol >= 0 && repoCol >= 0 && ownerCol < len(record) && repoCol < len(record) {
repoID = record[ownerCol] + "/" + record[repoCol]
} else {
continue
}
values = append(values, repoID)
}
return normalizeRepoIDs(values)
}
func normalizeRepoIDs(values []string) ([]string, error) {
repos := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
repoID := strings.TrimSpace(value)
if repoID == "" {
continue
}
parts := strings.SplitN(repoID, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, fmt.Errorf("无效的仓库标识 %q: 请使用 owner/repo 格式", repoID)
}
if seen[repoID] {
continue
}
seen[repoID] = true
repos = append(repos, repoID)
}
return repos, nil
}
func mergeRepoLists(values ...[]string) []string {
merged := []string{}
seen := map[string]bool{}
for _, repos := range values {
for _, r := range repos {
if seen[r] {
continue
}
seen[r] = true
merged = append(merged, r)
}
}
return merged
}
func parseBool(value string) bool {
if strings.EqualFold(strings.TrimSpace(value), "true") {
return true
}
return false
}

View File

@ -3,12 +3,17 @@ package repo
import ( import (
"fmt" "fmt"
"net/url" "net/url"
"os"
"os/exec"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/common"
) )
func Shortcuts() []*common.Shortcut { func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{ return []*common.Shortcut{
newBatchDeleteShortcut(),
newBatchForkShortcut(),
{ {
Name: "list", Name: "list",
Description: "List repositories for a user or organization", Description: "List repositories for a user or organization",
@ -33,21 +38,72 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPIWithQuery("GET", path, q) env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil { if err != nil {
return err return fmt.Errorf("获取仓库列表失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
}, },
{
Name: "clone",
Description: "Clone a repository from GitLink",
Flags: []common.Flag{
{Name: "url", Short: "u", Usage: "Repository URL or owner/repo format", Required: true},
{Name: "dir", Short: "d", Usage: "Target directory (default: repo name)"},
},
Run: func(ctx *common.RuntimeContext) error {
rawURL, err := ctx.RequireArg("url")
if err != nil {
return err
}
targetDir := ctx.Arg("dir")
var cloneURL string
var repoName string
if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") {
cloneURL = rawURL
if !strings.HasSuffix(cloneURL, ".git") {
cloneURL += ".git"
}
parts := strings.Split(strings.TrimSuffix(rawURL, ".git"), "/")
repoName = parts[len(parts)-1]
} else {
parts := strings.SplitN(rawURL, "/", 2)
if len(parts) != 2 {
return fmt.Errorf("无效的仓库格式 %q: 请使用 owner/repo 格式", rawURL)
}
webBase := strings.TrimSuffix(strings.TrimSuffix(ctx.Client.BaseURL, "/"), "/api")
cloneURL = fmt.Sprintf("%s/%s/%s.git", webBase, parts[0], parts[1])
repoName = parts[1]
}
if targetDir == "" {
targetDir = repoName
}
fmt.Printf("正在克隆 %s 到 %s...\n", cloneURL, targetDir)
cmd := exec.Command("git", "clone", cloneURL, targetDir)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
return fmt.Errorf("克隆失败: %w", err)
}
return nil
},
},
{ {
Name: "info", Name: "info",
Description: "Show repository details", Description: "Show repository details",
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil { if err != nil {
return err return fmt.Errorf("获取仓库详情失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -65,15 +121,14 @@ func Shortcuts() []*common.Shortcut {
if err != nil { if err != nil {
return err return err
} }
// Get current user login for the create path
userEnv, err := ctx.CallAPI("GET", "/users/me", nil) userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to get current user: %w", err) return fmt.Errorf("获取当前用户信息失败: %w", err)
} }
userData, _ := userEnv.Data.(map[string]interface{}) userData, _ := userEnv.Data.(map[string]interface{})
login, _ := userData["login"].(string) login, _ := userData["login"].(string)
if login == "" { if login == "" {
return fmt.Errorf("cannot determine current user login") return fmt.Errorf("无法获取当前用户名")
} }
userID, _ := userData["user_id"].(float64) userID, _ := userData["user_id"].(float64)
body := map[string]interface{}{ body := map[string]interface{}{
@ -89,7 +144,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body) env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body)
if err != nil { if err != nil {
return err return fmt.Errorf("创建仓库失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -99,11 +154,57 @@ func Shortcuts() []*common.Shortcut {
Description: "Fork a repository", Description: "Fork a repository",
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil) env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil)
if err != nil { if err != nil {
return err return fmt.Errorf("Fork 仓库失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "settings",
Description: "View or update repository settings",
Flags: []common.Flag{
{Name: "visibility", Usage: "Set visibility: public, private"},
{Name: "default-branch", Usage: "Set default branch"},
{Name: "description", Short: "d", Usage: "Update repository description"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
visibility := ctx.Arg("visibility")
defaultBranch := ctx.Arg("default-branch")
description := ctx.Arg("description")
if visibility == "" && defaultBranch == "" && description == "" {
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return fmt.Errorf("获取仓库设置失败: %w", err)
}
return ctx.Output(env)
}
body := map[string]interface{}{}
if visibility != "" {
if visibility != "public" && visibility != "private" {
return fmt.Errorf("无效的 visibility 值 %q: 必须为 public 或 private", visibility)
}
body["visibility"] = visibility
}
if defaultBranch != "" {
body["default_branch"] = defaultBranch
}
if description != "" {
body["description"] = description
}
env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body)
if err != nil {
return fmt.Errorf("更新仓库设置失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -113,11 +214,11 @@ func Shortcuts() []*common.Shortcut {
Description: "Delete a repository", Description: "Delete a repository",
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil { if err := ctx.ResolveOwnerRepo(); err != nil {
return err return fmt.Errorf("解析仓库信息失败: %w", err)
} }
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil) env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
if err != nil { if err != nil {
return err return fmt.Errorf("删除仓库失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },

446
shortcuts/repo/repo_test.go Executable file
View File

@ -0,0 +1,446 @@
package repo
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestRepoSettingsViewShowsCurrentSettings(t *testing.T) {
var capturedMethod string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedMethod = r.Method
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{
"name": "repo",
"visibility": "public",
"default_branch": "master",
"description": "A test repo",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{})
if err != nil {
t.Fatalf("settings shortcut failed: %v", err)
}
if capturedMethod != "GET" {
t.Fatalf("expected GET for viewing settings, got %s", capturedMethod)
}
}
func TestRepoSettingsUpdateVisibility(t *testing.T) {
var updateMethod string
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
updateMethod = r.Method
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"name": "repo",
"visibility": "private",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{
"visibility": "private",
})
if err != nil {
t.Fatalf("settings shortcut failed: %v", err)
}
if updateMethod != "PATCH" {
t.Fatalf("expected PATCH for updating settings, got %s", updateMethod)
}
assertEqual(t, updatePayload["visibility"], "private")
}
func TestRepoSettingsUpdateDescription(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"description": "Updated description",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{
"description": "Updated description",
})
if err != nil {
t.Fatalf("settings shortcut failed: %v", err)
}
assertEqual(t, updatePayload["description"], "Updated description")
}
func TestRepoSettingsUpdateDefaultBranch(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"default_branch": "main",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{
"default-branch": "main",
})
if err != nil {
t.Fatalf("settings shortcut failed: %v", err)
}
assertEqual(t, updatePayload["default_branch"], "main")
}
func TestRepoSettingsRejectsInvalidVisibility(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("should not make API call with invalid visibility")
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{
"visibility": "invalid",
})
if err == nil {
t.Fatal("expected error for invalid visibility, got nil")
}
}
func TestRepoSettingsUpdateMultipleFields(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "PATCH" && r.URL.Path == "/owner/repo.json":
updatePayload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"visibility": "private",
"default_branch": "main",
"description": "new desc",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "settings", map[string]string{
"visibility": "private",
"default-branch": "main",
"description": "new desc",
})
if err != nil {
t.Fatalf("settings shortcut failed: %v", err)
}
assertEqual(t, updatePayload["visibility"], "private")
assertEqual(t, updatePayload["default_branch"], "main")
assertEqual(t, updatePayload["description"], "new desc")
}
func runRepoShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findRepoShortcut(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 findRepoShortcut(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 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 fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func TestRepoCloneParsesOwnerRepoFormat(t *testing.T) {
tmpDir := t.TempDir()
fakeGit := createFakeGit(t, tmpDir)
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
"url": "myorg/myrepo",
})
if err != nil {
t.Fatalf("clone shortcut failed: %v", err)
}
}
func TestRepoCloneParsesFullURL(t *testing.T) {
tmpDir := t.TempDir()
fakeGit := createFakeGit(t, tmpDir)
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
"url": "https://www.gitlink.org.cn/myorg/myrepo",
})
if err != nil {
t.Fatalf("clone shortcut failed: %v", err)
}
}
func TestRepoCloneCustomTargetDir(t *testing.T) {
tmpDir := t.TempDir()
fakeGit := createFakeGit(t, tmpDir)
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
"url": "myorg/myrepo",
"dir": "/custom/dir",
})
if err != nil {
t.Fatalf("clone shortcut failed: %v", err)
}
}
func TestRepoCloneInvalidFormat(t *testing.T) {
tmpDir := t.TempDir()
fakeGit := createFakeGit(t, tmpDir)
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
"url": "invalidformat",
})
if err == nil {
t.Fatal("expected error for invalid format, got nil")
}
if !strings.Contains(err.Error(), "owner/repo") {
t.Fatalf("expected error mentioning owner/repo, got: %v", err)
}
}
func TestRepoCloneURLWithGitSuffix(t *testing.T) {
tmpDir := t.TempDir()
fakeGit := createFakeGit(t, tmpDir)
err := runRepoCloneShortcut(t, fakeGit, map[string]string{
"url": "https://www.gitlink.org.cn/myorg/myrepo.git",
})
if err != nil {
t.Fatalf("clone shortcut failed: %v", err)
}
}
func createFakeGit(t *testing.T, dir string) string {
t.Helper()
fakeGitPath := filepath.Join(dir, "git")
script := `#!/bin/bash
# Record arguments for test verification
echo "git $@" > ` + filepath.Join(dir, "git_args.txt") + `
exit 0
`
if err := os.WriteFile(fakeGitPath, []byte(script), 0755); err != nil {
t.Fatalf("failed to create fake git: %v", err)
}
return fakeGitPath
}
func runRepoCloneShortcut(t *testing.T, fakeGitPath string, args map[string]string) error {
t.Helper()
shortcut := findRepoShortcut(t, "clone")
// Set up PATH to include fake git
origPath := os.Getenv("PATH")
gitDir := filepath.Dir(fakeGitPath)
os.Setenv("PATH", gitDir+":"+origPath)
defer os.Setenv("PATH", origPath)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: nil,
BaseURL: "https://www.gitlink.org.cn/api",
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func TestRepoListWithoutUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/projects.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "name": "test-repo"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("repo list failed: %v", err)
}
}
func TestRepoListWithUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/users/testuser/projects.json" {
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "name": "test-repo"},
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "list", map[string]string{"user": "testuser"})
if err != nil {
t.Fatalf("repo list failed: %v", err)
}
}
func TestRepoInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/owner/repo.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "name": "test-repo", "visibility": "public",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "info", map[string]string{})
if err != nil {
t.Fatalf("repo info failed: %v", err)
}
}
func TestRepoCreate(t *testing.T) {
var createPath string
var createBody map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/users/me.json":
writeJSON(t, w, map[string]interface{}{
"login": "testuser",
"user_id": float64(42),
})
case r.Method == "POST" && r.URL.Path == "/testuser/myrepo.json":
createPath = r.URL.Path
createBody = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": float64(99), "name": "myrepo",
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runRepoShortcut(t, server, "create", map[string]string{
"name": "myrepo",
"description": "test desc",
"private": "true",
})
if err != nil {
t.Fatalf("repo create failed: %v", err)
}
if createPath == "" {
t.Fatal("create endpoint was not called")
}
assertEqual(t, createBody["name"], "myrepo")
assertEqual(t, createBody["description"], "test desc")
assertEqual(t, createBody["private"], true)
}
func TestRepoFork(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/owner/repo/forks.json" {
writeJSON(t, w, map[string]interface{}{"message": "forked"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "fork", map[string]string{})
if err != nil {
t.Fatalf("repo fork failed: %v", err)
}
}
func TestRepoDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/owner/repo.json" {
writeJSON(t, w, map[string]interface{}{"message": "deleted"})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runRepoShortcut(t, server, "delete", map[string]string{})
if err != nil {
t.Fatalf("repo delete failed: %v", err)
}
}

View File

@ -1,6 +1,7 @@
package search package search
import ( import (
"fmt"
"net/url" "net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -17,14 +18,17 @@ func Shortcuts() []*common.Shortcut {
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
keyword, _ := ctx.RequireArg("keyword") keyword, err := ctx.RequireArg("keyword")
if err != nil {
return err
}
q := url.Values{} q := url.Values{}
q.Set("search", keyword) q.Set("search", keyword)
q.Set("page", ctx.Arg("page")) q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit")) q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/projects", q) env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
if err != nil { if err != nil {
return err return fmt.Errorf("搜索仓库失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -38,15 +42,50 @@ func Shortcuts() []*common.Shortcut {
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
}, },
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
keyword, _ := ctx.RequireArg("keyword") keyword, err := ctx.RequireArg("keyword")
if err != nil {
return err
}
q := url.Values{} q := url.Values{}
q.Set("search", keyword) q.Set("search", keyword)
q.Set("page", ctx.Arg("page")) q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit")) q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", "/users/list", q) env, err := ctx.CallAPIWithQuery("GET", "/users/list", q)
if err != nil {
return fmt.Errorf("搜索用户失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "issues",
Description: "Search issues across repositories",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "all"},
{Name: "owner", Short: "o", Usage: "Limit to owner's repositories"},
{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 {
keyword, err := ctx.RequireArg("keyword")
if err != nil { if err != nil {
return err return err
} }
q := url.Values{}
q.Set("search", keyword)
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if s := ctx.Arg("state"); s != "" && s != "all" {
q.Set("state", s)
}
if o := ctx.Arg("owner"); o != "" {
q.Set("owner", o)
}
env, err := ctx.CallAPIWithQuery("GET", "/issues", q)
if err != nil {
return fmt.Errorf("搜索 Issue 失败: %w", err)
}
return ctx.Output(env) return ctx.Output(env)
}, },
}, },

209
shortcuts/search/search_test.go Executable file
View File

@ -0,0 +1,209 @@
package search
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestSearchIssuesSendsKeywordAndState(t *testing.T) {
var capturedPath string
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
capturedQuery = r.URL.Query()
writeJSON(t, w, map[string]interface{}{
"data": []map[string]interface{}{
{"id": float64(1), "subject": "bug found"},
},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "issues", map[string]string{
"keyword": "bug",
"state": "open",
})
if err != nil {
t.Fatalf("issues shortcut failed: %v", err)
}
if capturedPath != "/issues.json" {
t.Fatalf("expected path /issues.json, got %s", capturedPath)
}
if capturedQuery.Get("search") != "bug" {
t.Fatalf("expected search=bug, got %s", capturedQuery.Get("search"))
}
if capturedQuery.Get("state") != "open" {
t.Fatalf("expected state=open, got %s", capturedQuery.Get("state"))
}
}
func TestSearchIssuesOmitsStateWhenAll(t *testing.T) {
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.Query()
writeJSON(t, w, map[string]interface{}{
"data": []map[string]interface{}{},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "issues", map[string]string{
"keyword": "bug",
"state": "all",
})
if err != nil {
t.Fatalf("issues shortcut failed: %v", err)
}
if capturedQuery.Get("state") != "" {
t.Fatalf("expected no state filter, got %s", capturedQuery.Get("state"))
}
}
func TestSearchIssuesSendsOwnerWhenProvided(t *testing.T) {
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.Query()
writeJSON(t, w, map[string]interface{}{
"data": []map[string]interface{}{},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "issues", map[string]string{
"keyword": "bug",
"owner": "myorg",
})
if err != nil {
t.Fatalf("issues shortcut failed: %v", err)
}
if capturedQuery.Get("owner") != "myorg" {
t.Fatalf("expected owner=myorg, got %s", capturedQuery.Get("owner"))
}
}
func TestSearchIssuesDefaultsPageAndLimit(t *testing.T) {
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.Query()
writeJSON(t, w, map[string]interface{}{
"data": []map[string]interface{}{},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "issues", map[string]string{
"keyword": "bug",
"page": "1",
"limit": "20",
})
if err != nil {
t.Fatalf("issues shortcut failed: %v", err)
}
if capturedQuery.Get("page") != "1" {
t.Fatalf("expected page=1, got %s", capturedQuery.Get("page"))
}
if capturedQuery.Get("limit") != "20" {
t.Fatalf("expected limit=20, got %s", capturedQuery.Get("limit"))
}
}
func TestSearchRepos(t *testing.T) {
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.Query()
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "name": "search-result"},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "repos", map[string]string{
"keyword": "machine-learning",
})
if err != nil {
t.Fatalf("search repos failed: %v", err)
}
if capturedQuery.Get("search") != "machine-learning" {
t.Fatalf("expected search=machine-learning, got %s", capturedQuery.Get("search"))
}
}
func TestSearchUsers(t *testing.T) {
var capturedPath string
var capturedQuery url.Values
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
capturedQuery = r.URL.Query()
writeJSON(t, w, []map[string]interface{}{
{"id": float64(1), "login": "zhangsan"},
})
}))
defer server.Close()
err := runSearchShortcut(t, server, "users", map[string]string{
"keyword": "zhangsan",
})
if err != nil {
t.Fatalf("search users failed: %v", err)
}
if capturedPath != "/users/list.json" {
t.Fatalf("expected path /users/list.json, got %s", capturedPath)
}
if capturedQuery.Get("search") != "zhangsan" {
t.Fatalf("expected search=zhangsan, got %s", capturedQuery.Get("search"))
}
}
func runSearchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findSearchShortcut(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 findSearchShortcut(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 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)
}
}

85
shortcuts/star/star.go Normal file
View File

@ -0,0 +1,85 @@
package star
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "star",
Description: "Star (like) a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
env, err := ctx.CallAPI("POST",
fmt.Sprintf("/projects/%d/praise_tread/like", projectID), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "unstar",
Description: "Unstar (unlike) a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE",
fmt.Sprintf("/projects/%d/praise_tread/unlike", projectID), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "stars",
Description: "List stargazers of a repository",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Repository owner", Required: true},
{Name: "repo", Short: "r", Usage: "Repository name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
owner, _ := ctx.RequireArg("owner")
repo, _ := ctx.RequireArg("repo")
env, err := ctx.CallAPI("GET",
fmt.Sprintf("/%s/%s/stargazers", owner, repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func resolveProjectID(ctx *common.RuntimeContext) (int64, error) {
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return 0, fmt.Errorf("failed to get project info: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected project info response")
}
for _, key := range []string{"id", "project_id", "repo_id"} {
if id, ok := data[key].(float64); ok {
return int64(id), nil
}
}
return 0, fmt.Errorf("cannot find project id in response")
}

101
shortcuts/star/star_test.go Normal file
View File

@ -0,0 +1,101 @@
package star
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 TestStar(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
case r.Method == "POST" && r.URL.Path == "/projects/100/praise_tread/like.json":
writeJSON(t, w, map[string]interface{}{})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runStarShortcut(t, server, "star", map[string]string{}); err != nil {
t.Fatalf("star failed: %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 API calls, got %d", callCount)
}
}
func TestUnstar(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
case r.Method == "DELETE" && r.URL.Path == "/projects/100/praise_tread/unlike.json":
writeJSON(t, w, map[string]interface{}{})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runStarShortcut(t, server, "unstar", map[string]string{}); err != nil {
t.Fatalf("unstar failed: %v", err)
}
}
func TestStars(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/stargazers.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"count": 1,
"users": []map[string]interface{}{{"login": "alice"}},
})
}))
defer server.Close()
err := runStarShortcut(t, server, "stars", map[string]string{
"owner": "owner", "repo": "repo",
})
if err != nil {
t.Fatalf("stars failed: %v", err)
}
}
func runStarShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findStarShortcut(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 findStarShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
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")
json.NewEncoder(w).Encode(payload)
}

View File

@ -14,7 +14,7 @@ func Shortcuts() []*common.Shortcut {
Run: func(ctx *common.RuntimeContext) error { Run: func(ctx *common.RuntimeContext) error {
env, err := ctx.CallAPI("GET", "/users/me", nil) env, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil { if err != nil {
return err return fmt.Errorf("获取当前用户信息失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },
@ -32,7 +32,7 @@ func Shortcuts() []*common.Shortcut {
} }
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil) env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
if err != nil { if err != nil {
return err return fmt.Errorf("查看用户详情失败: %w", err)
} }
return ctx.Output(env) return ctx.Output(env)
}, },

90
shortcuts/user/user_test.go Executable file
View File

@ -0,0 +1,90 @@
package user
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 TestUserMe(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/users/me.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1),
"login": "testuser",
"name": "Test User",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runUserShortcut(t, server, "me", map[string]string{})
if err != nil {
t.Fatalf("user me failed: %v", err)
}
}
func TestUserInfo(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && r.URL.Path == "/users/zhangsan.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(42),
"login": "zhangsan",
"name": "Zhang San",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runUserShortcut(t, server, "info", map[string]string{"login": "zhangsan"})
if err != nil {
t.Fatalf("user info failed: %v", err)
}
}
func runUserShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findUserShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findUserShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
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)
}
}

91
shortcuts/watch/watch.go Normal file
View File

@ -0,0 +1,91 @@
package watch
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "watch",
Description: "Watch a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("target_type", "project")
q.Set("id", fmt.Sprintf("%d", projectID))
env, err := ctx.CallAPIWithQuery("POST", "/watchers/follow", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "unwatch",
Description: "Unwatch a repository",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := resolveProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("target_type", "project")
q.Set("id", fmt.Sprintf("%d", projectID))
env, err := ctx.CallAPIWithQuery("DELETE", "/watchers/unfollow", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "watchers",
Description: "List watchers of a repository",
Flags: []common.Flag{
{Name: "owner", Short: "o", Usage: "Repository owner", Required: true},
{Name: "repo", Short: "r", Usage: "Repository name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
owner, _ := ctx.RequireArg("owner")
repo, _ := ctx.RequireArg("repo")
ctx.Owner = owner
ctx.Repo = repo
env, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s/watchers", owner, repo), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func resolveProjectID(ctx *common.RuntimeContext) (int64, error) {
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return 0, fmt.Errorf("failed to get project info: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected project info response")
}
for _, key := range []string{"id", "project_id", "repo_id"} {
if id, ok := data[key].(float64); ok {
return int64(id), nil
}
}
return 0, fmt.Errorf("cannot find project id in response")
}

View File

@ -0,0 +1,109 @@
package watch
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 TestWatch(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(100), "name": "repo"})
case r.Method == "POST" && r.URL.Path == "/watchers/follow.json":
if r.URL.Query().Get("target_type") != "project" {
t.Fatal("expected target_type=project")
}
if r.URL.Query().Get("id") != "100" {
t.Fatalf("expected id=100, got %s", r.URL.Query().Get("id"))
}
writeJSON(t, w, map[string]interface{}{"watched": true})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runWatchShortcut(t, server, "watch", map[string]string{}); err != nil {
t.Fatalf("watch failed: %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 calls (GET project + POST watch), got %d", callCount)
}
}
func TestUnwatch(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(100)})
case r.Method == "DELETE" && r.URL.Path == "/watchers/unfollow.json":
writeJSON(t, w, map[string]interface{}{"watched": false})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runWatchShortcut(t, server, "unwatch", map[string]string{}); err != nil {
t.Fatalf("unwatch failed: %v", err)
}
}
func TestWatchers(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/watchers.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"count": 1,
"users": []map[string]interface{}{{"login": "alice", "is_watch": true}},
})
}))
defer server.Close()
err := runWatchShortcut(t, server, "watchers", map[string]string{
"owner": "owner", "repo": "repo",
})
if err != nil {
t.Fatalf("watchers failed: %v", err)
}
}
// === helpers ===
func runWatchShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findWatchShortcut(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 findWatchShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
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")
json.NewEncoder(w).Encode(payload)
}

View File

@ -0,0 +1,196 @@
package webhook
import (
"fmt"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func v1RepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List webhooks",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/webhooks", nil)
if err != nil {
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a webhook",
Flags: []common.Flag{
{Name: "url", Short: "u", Usage: "Webhook URL", Required: true},
{Name: "events", Short: "e", Usage: "Comma-separated events (push,create,delete,issues_only,pull_request_only)", Default: "push"},
{Name: "content-type", Usage: "Content type: json, form", Default: "json"},
{Name: "secret", Short: "s", Usage: "Webhook secret"},
{Name: "branch-filter", Short: "b", Usage: "Branch filter glob pattern", Default: "*"},
{Name: "active", Usage: "Enable webhook (true/false)", Default: "true"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
webhookURL, err := ctx.RequireArg("url")
if err != nil {
return err
}
eventsStr := ctx.Arg("events")
events := []string{}
for _, e := range strings.Split(eventsStr, ",") {
e = strings.TrimSpace(e)
if e != "" {
events = append(events, e)
}
}
body := map[string]interface{}{
"url": webhookURL,
"type": "gitea",
"active": ctx.Arg("active") == "true",
"content_type": ctx.Arg("content-type"),
"http_method": "POST",
"branch_filter": ctx.Arg("branch-filter"),
"events": events,
}
if secret := ctx.Arg("secret"); secret != "" {
body["secret"] = secret
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/webhooks", body)
if err != nil {
return fmt.Errorf("创建 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View webhook details",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return fmt.Errorf("查看 Webhook 详情失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a webhook",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
{Name: "url", Short: "u", Usage: "New webhook URL"},
{Name: "events", Short: "e", Usage: "Comma-separated events"},
{Name: "content-type", Usage: "Content type: json, form"},
{Name: "secret", Short: "s", Usage: "New webhook secret"},
{Name: "branch-filter", Short: "b", Usage: "Branch filter glob pattern"},
{Name: "active", Usage: "Enable webhook (true/false)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
body := map[string]interface{}{}
if u := ctx.Arg("url"); u != "" {
body["url"] = u
}
if eventsStr := ctx.Arg("events"); eventsStr != "" {
events := []string{}
for _, e := range strings.Split(eventsStr, ",") {
e = strings.TrimSpace(e)
if e != "" {
events = append(events, e)
}
}
body["events"] = events
}
if ct := ctx.Arg("content-type"); ct != "" {
body["content_type"] = ct
}
if secret := ctx.Arg("secret"); secret != "" {
body["secret"] = secret
}
if bf := ctx.Arg("branch-filter"); bf != "" {
body["branch_filter"] = bf
}
if active := ctx.Arg("active"); active != "" {
body["active"] = active == "true"
}
if len(body) == 0 {
return fmt.Errorf("至少需要指定一个要更新的字段")
}
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), body)
if err != nil {
return fmt.Errorf("更新 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a webhook",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil)
if err != nil {
return fmt.Errorf("删除 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
},
{
Name: "test",
Description: "Test a webhook delivery",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("解析仓库信息失败: %w", err)
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", v1RepoPath(ctx), id), nil)
if err != nil {
return fmt.Errorf("测试 Webhook 失败: %w", err)
}
return ctx.Output(env)
},
},
}
}

197
shortcuts/webhook/webhook_test.go Executable file
View File

@ -0,0 +1,197 @@
package webhook
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 TestWebhookList(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/webhooks.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"total_count": 1,
"webhooks": []map[string]interface{}{
{"id": 1, "url": "https://example.com/hook", "is_active": true},
},
})
}))
defer server.Close()
err := runWebhookShortcut(t, server, "list", map[string]string{})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestWebhookCreate(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks.json" {
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{
"id": 42,
"url": "https://example.com/hook",
"active": true,
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runWebhookShortcut(t, server, "create", map[string]string{
"url": "https://example.com/hook",
"events": "push,create",
"secret": "mysecret",
"active": "true",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["url"], "https://example.com/hook")
assertEqual(t, payload["active"], true)
assertEqual(t, payload["type"], "gitea")
assertEqual(t, payload["secret"], "mysecret")
events, ok := payload["events"].([]interface{})
if !ok {
t.Fatalf("events should be a slice, got %T", payload["events"])
}
if len(events) != 2 {
t.Fatalf("expected 2 events, got %d", len(events))
}
}
func TestWebhookDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/webhooks/42.json" {
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runWebhookShortcut(t, server, "delete", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestWebhookTest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks/42/tests.json" {
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runWebhookShortcut(t, server, "test", map[string]string{
"id": "42",
})
if err != nil {
t.Fatalf("test shortcut failed: %v", err)
}
}
func TestWebhookView(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/webhooks/1.json" {
writeJSON(t, w, map[string]interface{}{
"id": float64(1), "url": "https://example.com/hook", "is_active": true,
})
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runWebhookShortcut(t, server, "view", map[string]string{"id": "1"})
if err != nil {
t.Fatalf("webhook view failed: %v", err)
}
}
func TestWebhookUpdateRequiresAtLeastOneField(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made when no fields are provided")
}))
defer server.Close()
err := runWebhookShortcut(t, server, "update", map[string]string{
"id": "42",
})
if err == nil {
t.Fatal("expected error when no update fields provided, got nil")
}
}
func runWebhookShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findWebhookShortcut(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 findWebhookShortcut(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 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 fmt.Sprintf("%v", got) != fmt.Sprintf("%v", want) {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

302
shortcuts/wiki/wiki.go Normal file
View File

@ -0,0 +1,302 @@
package wiki
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const wikiBaseURL = "https://gateway.gitlink.org.cn/api"
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List wiki pages",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
projectID, err := fetchProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", fmt.Sprintf("%d", projectID))
return callWikiAPI(ctx, "GET", "/wiki/open/wikiPages", nil, q)
},
},
{
Name: "view",
Description: "View a wiki page",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
projectID, err := fetchProjectID(ctx)
if err != nil {
return err
}
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", fmt.Sprintf("%d", projectID))
q.Set("pageName", name)
return callWikiAPI(ctx, "GET", "/wiki/open/getWiki", nil, q)
},
},
{
Name: "create",
Description: "Create a wiki page",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
{Name: "content", Short: "c", Usage: "Page content (will be base64 encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
projectID, err := fetchProjectID(ctx)
if err != nil {
return err
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": projectID,
"pageName": name,
"title": name,
"message": ctx.Arg("message"),
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
}
return callWikiAPI(ctx, "POST", "/wiki/open/createWiki", body, nil)
},
},
{
Name: "update",
Description: "Update a wiki page",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
{Name: "content", Short: "c", Usage: "New page content (will be base64 encoded)", Required: true},
{Name: "message", Short: "m", Usage: "Commit message"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
content, err := ctx.RequireArg("content")
if err != nil {
return err
}
projectID, err := fetchProjectID(ctx)
if err != nil {
return err
}
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": projectID,
"pageName": name,
"title": name,
"message": ctx.Arg("message"),
"content_base64": base64.StdEncoding.EncodeToString([]byte(content)),
}
return callWikiAPI(ctx, "PUT", "/wiki/open/updateWiki", body, nil)
},
},
{
Name: "delete",
Description: "Delete a wiki page and remove it from sidebar",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Wiki page name", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
name, err := ctx.RequireArg("name")
if err != nil {
return err
}
projectID, err := fetchProjectID(ctx)
if err != nil {
return err
}
// Step 1: Delete the wiki page
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": projectID,
"pageName": name,
}
if err := callWikiAPISilent(ctx, "DELETE", "/wiki/open/deleteWiki", body, nil); err != nil {
return err
}
// Step 2: Wait for GitLink async sidebar rebuild, then clean up
time.Sleep(2 * time.Second)
cleanSidebar(ctx, projectID, name)
fmt.Printf("Wiki page %q deleted successfully.\n", name)
return nil
},
},
}
}
// callWikiAPI sends a request to the wiki gateway.
// It switches the client BaseURL to the wiki gateway for the duration of the call,
// but skips the switch during tests (local httptest server).
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error {
origBase := ctx.Client.BaseURL
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
ctx.Client.BaseURL = wikiBaseURL
}
defer func() { ctx.Client.BaseURL = origBase }()
env, err := ctx.Client.DoRaw(method, path, body, query)
if err != nil {
return err
}
return ctx.Output(env)
}
// fetchProjectID resolves the numeric project ID from the repo info API.
func fetchProjectID(ctx *common.RuntimeContext) (int64, error) {
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
if err != nil {
return 0, fmt.Errorf("failed to get project info: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return 0, fmt.Errorf("unexpected project info response")
}
for _, key := range []string{"project_id", "repo_id", "id"} {
if id, ok := data[key].(float64); ok {
return int64(id), nil
}
}
return 0, fmt.Errorf("project id not found in response")
}
// callWikiAPISilent is like callWikiAPI but does not print output.
func callWikiAPISilent(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error {
origBase := ctx.Client.BaseURL
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
ctx.Client.BaseURL = wikiBaseURL
}
defer func() { ctx.Client.BaseURL = origBase }()
_, err := ctx.Client.DoRaw(method, path, body, query)
return err
}
const sidebarPageName = "_Sidebar" // GitLink uses capital S for the sidebar page
// cleanSidebar fetches the wiki sidebar, removes the deleted page link, and updates it.
func cleanSidebar(ctx *common.RuntimeContext, projectID int64, pageName string) {
// Fetch sidebar
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", fmt.Sprintf("%d", projectID))
q.Set("pageName", sidebarPageName)
origBase := ctx.Client.BaseURL
if !strings.HasPrefix(origBase, "http://127.0.0.1") && !strings.HasPrefix(origBase, "http://localhost") {
ctx.Client.BaseURL = wikiBaseURL
}
defer func() { ctx.Client.BaseURL = origBase }()
env, err := ctx.Client.DoRaw("GET", "/wiki/open/getWiki", nil, q)
if err != nil {
return // sidebar might not exist, silently skip
}
// Extract content_base64 from response.
// DoRaw auto-parses JSON, so env.Data is a map with "data" as either
// a nested dict (already parsed) or a JSON string (needs parsing).
outer, ok := env.Data.(map[string]interface{})
if !ok {
return
}
var inner map[string]interface{}
switch v := outer["data"].(type) {
case map[string]interface{}:
inner = v
case string:
if err := json.Unmarshal([]byte(v), &inner); err != nil {
return
}
default:
return
}
contentB64, ok := inner["content_base64"].(string)
if !ok {
return
}
contentBytes, err := base64.StdEncoding.DecodeString(contentB64)
if err != nil {
return
}
sidebar := string(contentBytes)
// Remove the line containing [[pageName]]
target := "[[" + pageName + "]]"
lines := strings.Split(sidebar, "\n")
var newLines []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != target {
newLines = append(newLines, line)
}
}
newSidebar := strings.Join(newLines, "\n")
// No change needed
if newSidebar == sidebar {
return
}
// Update sidebar
body := map[string]interface{}{
"owner": ctx.Owner,
"repo": ctx.Repo,
"projectId": projectID,
"pageName": sidebarPageName,
"title": sidebarPageName,
"message": "Remove deleted page " + pageName + " from sidebar",
"content_base64": base64.StdEncoding.EncodeToString([]byte(newSidebar)),
}
ctx.Client.DoRaw("PUT", "/wiki/open/updateWiki", body, nil)
}

325
shortcuts/wiki/wiki_test.go Normal file
View File

@ -0,0 +1,325 @@
package wiki
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// --- list ---
func TestWikiList(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages":
if r.URL.Query().Get("projectId") != "42" {
t.Fatalf("expected projectId=42, got %s", r.URL.Query().Get("projectId"))
}
writeJSON(t, w, map[string]interface{}{
"data": []interface{}{
map[string]interface{}{"title": "Home", "sub_url": "Home"},
map[string]interface{}{"title": "Guide", "sub_url": "Guide"},
},
})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runWikiShortcut(t, server, "list", map[string]string{}); err != nil {
t.Fatalf("list failed: %v", err)
}
}
func TestWikiListWithProjectID(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"project_id": float64(99)})
case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages":
if r.URL.Query().Get("projectId") != "99" {
t.Fatalf("expected projectId=99, got %s", r.URL.Query().Get("projectId"))
}
writeJSON(t, w, map[string]interface{}{"data": []interface{}{}})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
if err := runWikiShortcut(t, server, "list", map[string]string{}); err != nil {
t.Fatalf("list with project_id failed: %v", err)
}
}
// --- view ---
func TestWikiView(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki":
if r.URL.Query().Get("pageName") != "Home" {
t.Fatalf("expected pageName=Home, got %s", r.URL.Query().Get("pageName"))
}
writeJSON(t, w, map[string]interface{}{
"data": map[string]interface{}{
"title": "Home",
"content_base64": base64.StdEncoding.EncodeToString([]byte("Welcome to wiki")),
},
})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runWikiShortcut(t, server, "view", map[string]string{"name": "Home"})
if err != nil {
t.Fatalf("view failed: %v", err)
}
}
func TestWikiViewRequiresName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --name")
}))
defer server.Close()
err := runWikiShortcut(t, server, "view", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
// --- create ---
func TestWikiCreate(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
case r.Method == "POST" && r.URL.Path == "/wiki/open/createWiki":
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["pageName"] != "NewPage" {
t.Fatalf("expected pageName=NewPage, got %v", payload["pageName"])
}
if payload["title"] != "NewPage" {
t.Fatalf("expected title=NewPage, got %v", payload["title"])
}
if payload["owner"] != "owner" {
t.Fatalf("expected owner=owner, got %v", payload["owner"])
}
if payload["repo"] != "repo" {
t.Fatalf("expected repo=repo, got %v", payload["repo"])
}
if payload["projectId"].(float64) != 42 {
t.Fatalf("expected projectId=42, got %v", payload["projectId"])
}
expectedContent := base64.StdEncoding.EncodeToString([]byte("Hello Wiki!"))
if payload["content_base64"] != expectedContent {
t.Fatalf("content_base64 mismatch: got %v", payload["content_base64"])
}
writeJSON(t, w, map[string]interface{}{
"code": 201,
"data": map[string]interface{}{"title": "NewPage"},
})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runWikiShortcut(t, server, "create", map[string]string{
"name": "NewPage", "content": "Hello Wiki!", "message": "create page",
})
if err != nil {
t.Fatalf("create failed: %v", err)
}
}
func TestWikiCreateRequiresName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --name")
}))
defer server.Close()
err := runWikiShortcut(t, server, "create", map[string]string{"content": "test"})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
func TestWikiCreateRequiresContent(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --content")
}))
defer server.Close()
err := runWikiShortcut(t, server, "create", map[string]string{"name": "Test"})
if err == nil {
t.Fatal("expected error for missing --content")
}
}
// --- update ---
func TestWikiUpdate(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki":
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["pageName"] != "Home" {
t.Fatalf("expected pageName=Home, got %v", payload["pageName"])
}
if payload["title"] != "Home" {
t.Fatalf("expected title=Home, got %v", payload["title"])
}
if payload["message"] != "update page" {
t.Fatalf("expected message=update page, got %v", payload["message"])
}
writeJSON(t, w, map[string]interface{}{"code": 200})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runWikiShortcut(t, server, "update", map[string]string{
"name": "Home", "content": "Updated content", "message": "update page",
})
if err != nil {
t.Fatalf("update failed: %v", err)
}
}
func TestWikiUpdateRequiresName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --name")
}))
defer server.Close()
err := runWikiShortcut(t, server, "update", map[string]string{"content": "test"})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
// --- delete ---
func TestWikiDelete(t *testing.T) {
callCount := 0
sidebarContent := "[[Home]]\n[[OldPage]]\n[[Guide]]"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
switch {
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
writeJSON(t, w, map[string]interface{}{"id": float64(42)})
case r.Method == "DELETE" && r.URL.Path == "/wiki/open/deleteWiki":
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["pageName"] != "OldPage" {
t.Fatalf("expected pageName=OldPage, got %v", payload["pageName"])
}
writeJSON(t, w, map[string]interface{}{"code": 200})
case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki":
if r.URL.Query().Get("pageName") != "_Sidebar" {
t.Fatalf("expected pageName=_Sidebar, got %s", r.URL.Query().Get("pageName"))
}
// Return sidebar with the page still in it
writeJSON(t, w, map[string]interface{}{
"code": 200,
"data": fmt.Sprintf(`{"content_base64":"%s"}`, base64.StdEncoding.EncodeToString([]byte(sidebarContent))),
})
case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki":
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["pageName"] != "_Sidebar" {
t.Fatalf("expected pageName=_Sidebar, got %v", payload["pageName"])
}
// Verify OldPage is removed from sidebar
updated, _ := base64.StdEncoding.DecodeString(payload["content_base64"].(string))
if strings.Contains(string(updated), "[[OldPage]]") {
t.Fatal("sidebar should not contain [[OldPage]] after delete")
}
if !strings.Contains(string(updated), "[[Home]]") {
t.Fatal("sidebar should still contain [[Home]]")
}
writeJSON(t, w, map[string]interface{}{"code": 200})
default:
t.Fatalf("unexpected request #%d: %s %s", callCount, r.Method, r.URL.Path)
}
}))
defer server.Close()
err := runWikiShortcut(t, server, "delete", map[string]string{"name": "OldPage"})
if err != nil {
t.Fatalf("delete failed: %v", err)
}
}
func TestWikiDeleteRequiresName(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no request should be made without --name")
}))
defer server.Close()
err := runWikiShortcut(t, server, "delete", map[string]string{})
if err == nil {
t.Fatal("expected error for missing --name")
}
}
// --- helpers ---
func runWikiShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findWikiShortcut(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 findWikiShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
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")
json.NewEncoder(w).Encode(payload)
}

30
showcase/Dockerfile Normal file
View File

@ -0,0 +1,30 @@
# Stage 1: Build
FROM golang:1.26-alpine AS builder
ENV GOPROXY=https://goproxy.cn,direct
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build gitlink-cli binary
RUN CGO_ENABLED=0 GOOS=linux go build -o gitlink-cli .
# Build showcase server binary
RUN CGO_ENABLED=0 GOOS=linux go build -o showcase-server ./showcase/
# Stage 2: Runtime
FROM alpine:latest
RUN apk add --no-cache ca-certificates git
WORKDIR /app
COPY --from=builder /build/gitlink-cli .
COPY --from=builder /build/showcase-server .
EXPOSE 9090
CMD ["./showcase-server"]

14
showcase/deploy.sh Normal file
View File

@ -0,0 +1,14 @@
# 展示页部署命令(在本地终端手动执行)
# 1. 用 SCP 上传展示页到 ECS需要手动输入密码pd1@YwC#WRFVHkXc8nvu!4
scp "d:/自用/self/word/大三下/软件演化/gitlink-cli/showcase/index.html" root@121.41.210.165:/opt/showcase/index.html
# 2. SSH 到 ECS密码pd1@YwC#WRFVHkXc8nvu!4
ssh root@121.41.210.165
# 登录后在 ECS 上执行:
mkdir -p /opt/showcase
docker run -d --name showcase -p 8080:80 -v /opt/showcase:/usr/share/nginx/html:ro --restart unless-stopped nginx:alpine
# 3. 访问
# 浏览器打开 http://121.41.210.165:8080

357
showcase/index.html Normal file
View File

@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>gitlink-cli 功能增强展示</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; background: #f0f2f5; color: #333; }
.header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: white; padding: 40px 20px; text-align: center; }
.header h1 { font-size: 2em; margin-bottom: 8px; }
.header p { color: #8892b0; font-size: 1.1em; }
.header .repo-info { margin-top: 12px; font-size: 0.9em; color: #64ffda; }
.header .repo-info code { background: rgba(255,255,255,0.1); padding: 4px 12px; border-radius: 4px; }
.stats-bar { display: flex; justify-content: center; gap: 32px; padding: 20px; background: white; border-bottom: 1px solid #e8e8e8; flex-wrap: wrap; }
.stat-item { text-align: center; }
.stat-item .num { font-size: 2em; font-weight: bold; color: #1890ff; }
.stat-item .num.green { color: #52c41a; }
.stat-item .label { color: #999; font-size: 0.85em; }
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
.module-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(550px, 1fr)); gap: 20px; }
.module-card { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
.module-header { padding: 16px 20px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #f0f0f0; }
.module-header:hover { background: #fafafa; }
.module-header h3 { font-size: 1.1em; display: flex; align-items: center; gap: 8px; }
.module-header .tag { font-size: 0.75em; background: #e6f7ff; color: #1890ff; padding: 2px 8px; border-radius: 10px; }
.module-header .arrow { transition: transform 0.3s; font-size: 0.8em; color: #999; }
.module-header .arrow.open { transform: rotate(180deg); }
.module-body { padding: 0; }
.module-body.collapsed { display: none; }
.cmd-section { padding: 10px 20px 4px; color: #1890ff; font-weight: 600; font-size: 0.88em; background: #f0f5ff; border-bottom: 1px solid #e6f0ff; border-top: 1px solid #e6f0ff; }
.cmd-section:first-child { border-top: none; }
.cmd-row { border-bottom: 1px solid #f5f5f5; }
.cmd-row:last-child { border-bottom: none; }
.cmd-row:hover { background: #fafbfc; }
.cmd-top { display: flex; align-items: center; padding: 10px 20px; gap: 12px; }
.cmd-name { font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.9em; color: #1890ff; font-weight: 600; min-width: 70px; }
.cmd-desc { color: #666; font-size: 0.85em; flex: 1; }
.cmd-toggle { color: #999; cursor: pointer; font-size: 0.85em; border: none; background: none; }
.cmd-toggle:hover { color: #1890ff; }
.cmd-detail { padding: 0 20px 10px 90px; color: #999; font-size: 0.8em; display: none; }
.cmd-detail.show { display: block; }
.cmd-params { padding: 6px 20px 10px 90px; display: none; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
.cmd-params.show { display: flex; }
.param-group { display: flex; flex-direction: column; gap: 2px; }
.param-group label { font-size: 0.75em; color: #999; }
.param-group input { border: 1px solid #d9d9d9; border-radius: 4px; padding: 4px 8px; font-size: 0.85em; width: 140px; }
.param-group input:focus { border-color: #1890ff; outline: none; box-shadow: 0 0 0 2px rgba(24,144,255,0.2); }
.run-btn { background: #1890ff; color: white; border: none; padding: 5px 14px; border-radius: 6px; cursor: pointer; font-size: 0.85em; white-space: nowrap; align-self: flex-end; }
.run-btn:hover { background: #40a9ff; }
.run-btn:disabled { background: #d9d9d9; cursor: not-allowed; }
.result-panel { background: #1e1e1e; margin: 4px 20px 14px; border-radius: 8px; overflow: hidden; display: none; }
.result-panel.show { display: block; }
.result-toolbar { background: #2d2d2d; padding: 8px 12px; display: flex; justify-content: space-between; align-items: center; }
.result-toolbar .cmd-text { color: #4ec9b0; font-family: monospace; font-size: 0.85em; }
.result-toolbar .status { font-size: 0.8em; padding: 2px 8px; border-radius: 4px; }
.result-toolbar .status.ok { background: #2ea04380; color: #4ec9b0; }
.result-toolbar .status.err { background: #da363380; color: #f85149; }
.result-toolbar .close-btn { color: #666; cursor: pointer; font-size: 1.2em; }
.result-toolbar .close-btn:hover { color: #999; }
.result-body { padding: 12px; max-height: 400px; overflow: auto; }
.result-body pre { color: #d4d4d4; font-family: 'SFMono-Regular', Consolas, monospace; font-size: 0.82em; line-height: 1.5; white-space: pre-wrap; word-break: break-all; margin: 0; }
.footer { text-align: center; color: #999; padding: 40px 20px; font-size: 0.85em; }
@media (max-width: 600px) {
.module-grid { grid-template-columns: 1fr; }
.cmd-params { padding-left: 20px; }
}
</style>
</head>
<body>
<div class="header">
<h1>gitlink-cli 功能增强</h1>
<p>软件演化与运维 课程实践 — 进阶任务 子任务一</p>
<div class="repo-info">演示仓库:<code>chroe/gitlink-cli</code> — 展开命令、填入参数、点击运行查看真实结果</div>
</div>
<div class="stats-bar">
<div class="stat-item"><div class="num">9</div><div class="label">新增模块</div></div>
<div class="stat-item"><div class="num green">47</div><div class="label">新增命令</div></div>
<div class="stat-item"><div class="num">7</div><div class="label">批量操作</div></div>
<div class="stat-item"><div class="num">47+</div><div class="label">单元测试</div></div>
</div>
<div class="container"><div class="module-grid" id="modules"></div></div>
<div class="footer">gitlink-cli 功能增强 — 软件演化与运维课程实践</div>
<script>
const MODULES = [
{
name: "milestone", title: "里程碑管理",
desc: "管理项目的版本规划节点,可用于跟踪项目进度和发布计划",
color: "#722ed1",
commands: [
{ name: "list", desc: "列出所有里程碑", detail: "获取项目中所有已创建的里程碑列表,包括开启和关闭的数量统计", params: [] },
{ name: "view", desc: "查看里程碑详情", detail: "查看某个里程碑的详细信息,包括关联的疑修数量和完成进度", params: [{k:"id",l:"里程碑ID",v:"2760"}] },
{ name: "create", desc: "创建里程碑", detail: "新建一个版本规划节点,可设置名称、描述、截止日期", params: [{k:"name",l:"名称",v:"v2.0"},{k:"description",l:"描述",v:"新里程碑"},{k:"due",l:"截止日期",v:"2026-07-01"}] },
{ name: "update", desc: "更新里程碑", detail: "修改里程碑的名称、描述或截止日期", params: [{k:"id",l:"里程碑ID",v:"2760"},{k:"description",l:"新描述",v:"已更新"}] },
{ name: "close", desc: "关闭里程碑", detail: "标记里程碑为已完成状态", params: [{k:"id",l:"里程碑ID",v:"2760"}] },
{ name: "delete", desc: "删除里程碑", detail: "删除指定的里程碑(不可恢复)", params: [{k:"id",l:"里程碑ID",v:"2760"}] },
]
},
{
name: "webhook", title: "Webhook 配置",
desc: "管理项目的事件通知,当仓库发生 push、PR 等事件时自动通知外部服务",
color: "#eb2f96",
commands: [
{ name: "list", desc: "列出所有 Webhook", detail: "获取项目中配置的所有事件通知地址列表", params: [] },
{ name: "view", desc: "查看 Webhook 详情", detail: "查看某个 Webhook 的详细配置信息,包括事件类型和最近触发状态", params: [{k:"id",l:"Webhook ID",v:"51106"}] },
{ name: "create", desc: "创建 Webhook", detail: "新增一个事件通知配置,指定 URL 和监听的事件类型", params: [{k:"url",l:"回调URL",v:"https://httpbin.org/post"}] },
{ name: "update", desc: "更新 Webhook", detail: "修改 Webhook 的 URL、事件类型等配置", params: [{k:"id",l:"Webhook ID",v:"51106"},{k:"url",l:"新URL",v:"https://httpbin.org/post"}] },
{ name: "test", desc: "测试 Webhook", detail: "发送一个测试事件,验证 Webhook 配置是否正确", params: [{k:"id",l:"Webhook ID",v:"51106"}] },
{ name: "delete", desc: "删除 Webhook", detail: "删除指定的事件通知配置", params: [{k:"id",l:"Webhook ID",v:"51106"}] },
]
},
{
name: "label", title: "标签管理",
desc: "管理疑修Issue的标签用于分类和优先级标记",
color: "#fa8c16",
commands: [
{ name: "list", desc: "列出所有标签", detail: "获取项目中所有疑修标签,包括标签颜色和关联的疑修数量", params: [] },
{ name: "create", desc: "创建标签", detail: "新建一个疑修标签,可设置名称、颜色和描述。创建后点 +list 查看新标签的 ID", params: [{k:"name",l:"标签名",v:"test-label"},{k:"color",l:"颜色",v:"#ff6600"},{k:"description",l:"描述",v:"test"}] },
{ name: "update", desc: "更新标签", detail: "修改标签的名称、颜色或描述。先点 +list 查看 ID", params: [{k:"id",l:"标签ID",v:"328745"},{k:"description",l:"新描述",v:"updated"}] },
{ name: "delete", desc: "删除标签", detail: "删除指定的疑修标签。先点 +list 查看 ID", params: [{k:"id",l:"标签ID",v:"328745"}] },
]
},
{
name: "commit", title: "提交记录",
desc: "查看仓库的代码提交历史、变更文件和逐行追溯",
color: "#52c41a",
commands: [
{ name: "list", desc: "列出提交记录", detail: "查看仓库的所有代码提交历史,支持按分支/标签过滤和分页", params: [{k:"limit",l:"数量",v:"5"}] },
{ name: "view", desc: "查看提交详情", detail: "查看某次提交修改了哪些文件以及每个文件的增删行数。SHA 从 +list 获取", params: [{k:"sha",l:"提交SHA",v:"a82717bbf372aee48bbd1452c3d6be9256b0febf"}] },
{ name: "diff", desc: "查看代码差异", detail: "查看某次提交的具体代码变更内容。SHA 从 +list 获取", params: [{k:"sha",l:"提交SHA",v:"a82717bbf372aee48bbd1452c3d6be9256b0febf"}] },
{ name: "blame", desc: "逐行追溯", detail: "查看文件每一行代码是谁在什么时候、通过哪次提交修改的", params: [{k:"path",l:"文件路径",v:"README.md"}] },
]
},
{
name: "wiki", title: "Wiki 页面管理",
desc: "管理项目维基页面,支持创建、查看、编辑和删除 Wiki 文档",
color: "#13c2c2",
commands: [
{ name: "list", desc: "列出 Wiki 页面", detail: "获取项目中所有 Wiki 页面列表", params: [] },
{ name: "view", desc: "查看 Wiki 页面", detail: "查看某个 Wiki 页面的详细内容", params: [{k:"name",l:"页面名称",v:"Home"}] },
{ name: "create", desc: "创建 Wiki 页面", detail: "新建一个 Wiki 页面,内容自动进行 Base64 编码", params: [{k:"name",l:"页面标题",v:"TestPage"},{k:"content",l:"页面内容",v:"Hello Wiki!"},{k:"message",l:"提交信息",v:"create test page"}] },
{ name: "update", desc: "更新 Wiki 页面", detail: "修改已有的 Wiki 页面内容,内容自动进行 Base64 编码", params: [{k:"name",l:"页面标题",v:"Home"},{k:"content",l:"新内容",v:"Updated content"},{k:"message",l:"提交信息",v:"update page"}] },
{ name: "delete", desc: "删除 Wiki 页面", detail: "删除指定的 Wiki 页面(不可恢复)", params: [{k:"name",l:"页面标题",v:"TestPage"}] },
]
},
{
name: "file", title: "文件管理",
desc: "管理仓库中的文件和目录,支持在线创建、编辑、删除和查看文件",
color: "#2f54eb",
commands: [
{ name: "list", desc: "列出仓库文件", detail: "获取仓库指定路径下的文件和目录列表,支持分支选择和关键词搜索", params: [{k:"ref",l:"分支/标签",v:"master"}] },
{ name: "tree", desc: "查看文件树", detail: "以树形结构浏览仓库目录,支持递归展开所有子目录", params: [{k:"sha",l:"分支/提交SHA",v:"master"},{k:"limit",l:"每页数量",v:"20"}] },
{ name: "get", desc: "获取文件内容", detail: "查看指定文件或目录的内容详情", params: [{k:"path",l:"文件路径",v:"README.md"},{k:"ref",l:"分支",v:"master"}] },
{ name: "create", desc: "创建文件", detail: "在仓库中新建文件,内容自动进行 Base64 编码", params: [{k:"path",l:"文件路径",v:"test/hello.txt"},{k:"content",l:"文件内容",v:"Hello World!"},{k:"message",l:"提交信息",v:"add test file"},{k:"branch",l:"目标分支",v:"master"}] },
{ name: "delete", desc: "删除文件", detail: "删除仓库中的文件,需要提供文件的 blob SHA", params: [{k:"path",l:"文件路径",v:"test/hello.txt"},{k:"sha",l:"文件SHA",v:""},{k:"message",l:"提交信息",v:"delete test file"},{k:"branch",l:"目标分支",v:"master"}] },
]
},
{
name: "member", title: "成员管理",
desc: "管理项目成员支持添加、移除、修改角色和查看成员列表。用户ID 可用 search +users 查找",
color: "#f5222d",
commands: [
{ name: "list", desc: "列出项目成员", detail: "获取项目中所有协作者的列表,返回结果中包含每位成员的 id数字和 login用户名可按关键词搜索", params: [{k:"keyword",l:"搜索关键词",v:""}] },
{ name: "users", desc: "🔍 按用户名搜索ID", module: "search", detail: "输入 GitLink 用户名搜索,返回的 user_id 就是添加/移除成员时需要的数字 ID", params: [{k:"keyword",l:"用户名",v:""}] },
{ name: "add", desc: "添加成员", detail: "邀请用户加入项目成为协作者。user-id 是数字 ID先点「按用户名搜索ID」查找", params: [{k:"user-id",l:"用户数字ID",v:""}] },
{ name: "remove", desc: "移除成员", detail: "将指定用户移出项目。先点 +list 查看成员的数字 ID", params: [{k:"user-id",l:"用户数字ID",v:""}] },
{ name: "update", desc: "修改成员角色", detail: "更改成员的项目角色Manager管理员、Developer开发者、Reporter报告者。先点 +list 查看成员 ID", params: [{k:"user-id",l:"用户数字ID",v:""},{k:"role",l:"角色",v:"Developer"}] },
]
},
{
name: "watch", title: "仓库关注",
desc: "关注仓库以接收更新通知,支持关注/取消关注和查看关注者列表",
color: "#faad14",
commands: [
{ name: "watch", desc: "关注仓库", detail: "开始关注当前仓库,接收后续的事件通知", params: [] },
{ name: "unwatch", desc: "取消关注", detail: "停止关注当前仓库,不再接收通知", params: [] },
{ name: "watchers", desc: "查看关注者", detail: "列出关注了当前仓库的所有用户", params: [] },
]
},
{
name: "star", title: "仓库点赞",
desc: "为仓库点赞Star表达对项目的认可和支持",
color: "#eb2f96",
commands: [
{ name: "star", desc: "点赞仓库", detail: "为当前仓库点赞Star", params: [] },
{ name: "unstar", desc: "取消点赞", detail: "取消对当前仓库的点赞", params: [] },
{ name: "stars", desc: "查看点赞者", detail: "列为当前仓库点赞的所有用户", params: [] },
]
},
{
name: "issue", title: "Issue 管理",
desc: "管理疑修Issue关闭/重新打开/批量操作/设置标签和里程碑,支持逗号列表或 CSV 文件导入",
color: "#13c2c2",
commands: [
{ name: "list", desc: "列出 Issue", detail: "列出项目中的 Issue支持按状态过滤", params: [{k:"state",l:"状态(open/closed/all)",v:"open"},{k:"limit",l:"数量",v:"10"}] },
{ name: "close", desc: "关闭 Issue", detail: "关闭指定编号的 Issue", params: [{k:"number",l:"Issue编号",v:"1"}] },
{ name: "reopen", desc: "重新打开 Issue", detail: "重新打开已关闭的 Issue", params: [{k:"number",l:"Issue编号",v:"1"}] },
{ name: "batch-close", desc: "批量关闭 Issue", detail: "按编号批量关闭 Issue支持 --numbers 逗号分隔或 --from CSV 文件。开启预览模式可先查看将执行的操作而不实际执行", params: [{k:"numbers",l:"Issue编号",v:"1,2,3"},{k:"dry-run",l:"预览模式",v:"false",bool:true}] },
{ name: "batch-reopen", desc: "批量重新打开 Issue", detail: "批量重新打开已关闭的 Issue支持 --numbers 逗号分隔或 --from CSV 文件", params: [{k:"numbers",l:"Issue编号",v:"1,2,3"},{k:"dry-run",l:"预览模式",v:"false",bool:true}] },
]
},
{
name: "repo", title: "批量仓库管理",
desc: "批量删除或 Fork 仓库,支持逗号分隔列表或 CSV 文件批量导入,建议先用预览模式确认",
color: "#722ed1",
commands: [
{ name: "batch-delete", desc: "批量删除仓库", detail: "按 owner/repo 格式批量删除仓库,支持 --repos 逗号分隔或 --from CSV 文件。⚠️ 危险操作,建议先开启预览模式确认", params: [{k:"repos",l:"仓库列表",v:"chroe/calc-framework,chroe/tutoring-prep"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
{ name: "batch-fork", desc: "批量 Fork 仓库", detail: "批量 Fork 多个仓库到自己的账号下", params: [{k:"repos",l:"仓库列表",v:"wxhwwla/calc-framework,kaysen/tutoring-prep"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
]
},
{
name: "org", title: "批量组织邀请",
desc: "批量邀请成员加入组织,支持逗号分隔或 CSV 文件批量导入,建议先用预览模式确认",
color: "#fa8c16",
commands: [
{ name: "batch-invite", desc: "批量邀请成员", detail: "批量邀请用户加入组织支持用户名或数字ID支持设置角色member/admin支持 --from CSV 文件", params: [{k:"id",l:"组织ID用 org +list 查看)",v:"151783"},{k:"users",l:"用户名/ID列表",v:"chroe"},{k:"role",l:"角色",v:"member"},{k:"dry-run",l:"预览模式",v:"true",bool:true}] },
]
},
];
const container = document.getElementById('modules');
MODULES.forEach(mod => {
const card = document.createElement('div');
card.className = 'module-card';
const cmdHTML = mod.commands.map(cmd => {
if (cmd.section) {
return `<div class="cmd-section">${cmd.section}</div>`;
}
const uid = `${mod.name}-${cmd.name}`;
const paramsHTML = cmd.params.map(p => {
if (p.bool) {
return `<div class="param-group"><label>${p.l}</label><select id="p-${uid}-${p.k}"><option value="false"${p.v!=='true'?' selected':''}></option><option value="true"${p.v==='true'?' selected':''}></option></select></div>`;
}
return `<div class="param-group"><label>${p.l}</label><input id="p-${uid}-${p.k}" value="${p.v}" placeholder="${p.l}"></div>`;
}).join('');
return `
<div class="cmd-row">
<div class="cmd-top">
<span class="cmd-name">+${cmd.name}</span>
<span class="cmd-desc">${cmd.desc}</span>
<button class="cmd-toggle" onclick="toggleDetail('${uid}')">详情</button>
</div>
<div class="cmd-detail" id="detail-${uid}">${cmd.detail}</div>
<div class="cmd-params" id="params-${uid}">
${paramsHTML}
<button class="run-btn" onclick="runCommand('${mod.name}','${cmd.name}','${uid}')" id="btn-${uid}">▶ 运行</button>
</div>
<div class="result-panel" id="result-${uid}"></div>
</div>`;
}).join('');
card.innerHTML = `
<div class="module-header" onclick="toggleModule(this)">
<h3><span style="color:${mod.color};font-size:1.3em;">&#9632;</span> ${mod.title} <span class="tag">${mod.commands.length} 个命令</span></h3>
<span class="arrow">&#9660;</span>
</div>
<div class="module-body collapsed">
<p style="padding:12px 20px;color:#666;font-size:0.9em;border-bottom:1px solid #f0f0f0;background:#fafbfc;">${mod.desc}</p>
${cmdHTML}
</div>`;
container.appendChild(card);
});
function toggleModule(header) {
const body = header.nextElementSibling;
const arrow = header.querySelector('.arrow');
body.classList.toggle('collapsed');
arrow.classList.toggle('open');
}
function toggleDetail(uid) {
const detail = document.getElementById(`detail-${uid}`);
const params = document.getElementById(`params-${uid}`);
detail.classList.toggle('show');
params.classList.toggle('show');
}
function getArgs(uid, params) {
return params.map(p => {
const val = document.getElementById(`p-${uid}-${p.k}`).value.trim();
// Boolean flag: only pass --flag when value is "true", omit for false
if (p.bool) {
return val === 'true' ? `--${p.k}` : '';
}
if (!val) return '';
// Use = syntax for bool values (pflag requires it)
if (val === 'true' || val === 'false') {
return `--${p.k}=${val}`;
}
if (val.includes(' ')) {
return `--${p.k} "${val}"`;
}
return `--${p.k} ${val}`;
}).filter(Boolean).join(' ');
}
async function runCommand(module, command, uid) {
const btn = document.getElementById(`btn-${uid}`);
const panel = document.getElementById(`result-${uid}`);
const mod = MODULES.find(m => m.name === module);
const cmd = mod.commands.find(c => c.name === command);
const args = getArgs(uid, cmd.params);
const apiModule = cmd.module || module;
btn.disabled = true;
btn.textContent = '运行中...';
let url = `/api/run?module=${apiModule}&command=${command}`;
if (args) url += `&args=${encodeURIComponent(args)}`;
try {
const resp = await fetch(url);
const data = await resp.json();
const statusClass = data.ok ? 'ok' : 'err';
const statusText = data.ok ? '成功' : '失败';
let output;
if (!data.ok && data.error) {
output = data.error;
} else if (data.output === null || data.output === undefined) {
output = '(操作完成,无返回数据)';
} else if (typeof data.output === 'string') {
output = data.output || '(操作完成,无返回数据)';
} else {
const s = JSON.stringify(data.output, null, 2);
output = (s === 'null' || s === '{}' || s === '""') ? '(操作完成,无返回数据)' : s;
}
panel.innerHTML = `
<div class="result-toolbar">
<span class="cmd-text">$ ${data.command || module + ' +' + command}</span>
<span class="status ${statusClass}">${statusText}</span>
<span class="close-btn" onclick="panel.classList.remove('show')">&times;</span>
</div>
<div class="result-body"><pre>${escapeHtml(output)}</pre></div>`;
panel.classList.add('show');
} catch (e) {
panel.innerHTML = `
<div class="result-toolbar">
<span class="cmd-text">$ ${module} +${command}</span>
<span class="status err">请求失败</span>
<span class="close-btn" onclick="panel.classList.remove('show')">&times;</span>
</div>
<div class="result-body"><pre>${escapeHtml(e.message)}</pre></div>`;
panel.classList.add('show');
}
btn.disabled = false;
btn.textContent = '▶ 运行';
}
function escapeHtml(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
</script>
</body>
</html>

145
showcase/main.go Normal file
View File

@ -0,0 +1,145 @@
package main
import (
_ "embed"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
)
//go:embed index.html
var indexHTML []byte
type RunResult struct {
OK bool `json:"ok"`
Command string `json:"command"`
Output interface{} `json:"output"`
Error string `json:"error,omitempty"`
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "9090"
}
cliBin := findCLIBinary()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML)
})
http.HandleFunc("/api/run", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
module := r.URL.Query().Get("module")
command := r.URL.Query().Get("command")
owner := r.URL.Query().Get("owner")
repo := r.URL.Query().Get("repo")
extraArgs := r.URL.Query().Get("args")
if module == "" || command == "" {
json.NewEncoder(w).Encode(RunResult{Error: "missing module or command"})
return
}
if owner == "" {
owner = "chroe"
}
if repo == "" {
if module == "wiki" {
repo = "gitlink_help_center"
} else {
repo = "gitlink-cli"
}
}
args := []string{module, "+" + command, "--owner", owner, "--repo", repo, "--format", "json"}
if extraArgs != "" {
args = append(args, parseShellArgs(extraArgs)...)
}
cmdStr := "gitlink-cli " + strings.Join(args, " ")
log.Printf("Running: %s", cmdStr)
cmd := exec.Command(cliBin, args...)
output, err := cmd.CombinedOutput()
result := RunResult{
Command: cmdStr,
}
if err != nil {
result.Error = strings.TrimSpace(string(output))
result.Output = nil
} else {
result.OK = true
var parsed interface{}
if json.Unmarshal(output, &parsed) == nil {
result.Output = parsed
} else {
result.Output = strings.TrimSpace(string(output))
}
}
json.NewEncoder(w).Encode(result)
})
fmt.Printf("Showcase Dashboard running at http://localhost:%s\n", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
// parseShellArgs splits a shell-style argument string, respecting quoted values.
// e.g. `--content "Hello Wiki!" --message "create page"` -> ["--content", "Hello Wiki!", "--message", "create page"]
func parseShellArgs(s string) []string {
var args []string
var current strings.Builder
inQuote := false
for i := 0; i < len(s); i++ {
ch := s[i]
if ch == '"' {
inQuote = !inQuote
continue
}
if ch == ' ' && !inQuote {
if current.Len() > 0 {
args = append(args, current.String())
current.Reset()
}
continue
}
current.WriteByte(ch)
}
if current.Len() > 0 {
args = append(args, current.String())
}
return args
}
func findCLIBinary() string {
exe, _ := os.Executable()
exeDir := filepath.Dir(exe)
candidates := []string{
filepath.Join(exeDir, "gitlink-cli.exe"),
filepath.Join(exeDir, "gitlink-cli"),
filepath.Join(exeDir, "..", "gitlink-cli.exe"),
filepath.Join(exeDir, "..", "gitlink-cli"),
"./gitlink-cli.exe",
"./gitlink-cli",
"../gitlink-cli.exe",
"../gitlink-cli",
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
abs, _ := filepath.Abs(c)
return abs
}
}
return "gitlink-cli"
}

31
showcase/pipeline.yml Normal file
View File

@ -0,0 +1,31 @@
version: 2
name: 构建部署Showcase
description: "代码提交自动触发在服务器上拉取代码、构建Docker镜像并部署"
global:
concurrent: 1
trigger:
webhook: gitlink@1.0.0
event:
- ref: push
ruleset-operator: AND
workflow:
- ref: start
name: 开始
task: start
- ref: ssh_cmd_0
name: SSH部署到服务器
task: ssh_cmd@1.1.1
input:
ssh_pass: ((deploy_server.server_password))
ssh_ip: '"118.31.4.168"'
ssh_port: '"22"'
ssh_user: '"root"'
ssh_cmd: >-
"mkdir -p /opt/gitlink-cli && cd /opt/gitlink-cli && (git clone https://gitlink.org.cn/chroe/gitlink-cli.git . || git pull origin master) && docker build -f showcase/Dockerfile -t gitlink-cli-showcase . && docker stop gitlink-cli-showcase || true && docker rm gitlink-cli-showcase || true && docker run -d -p 9090:9090 --name gitlink-cli-showcase --restart unless-stopped gitlink-cli-showcase"
needs:
- start
- ref: end
name: 结束
task: end
needs:
- ssh_cmd_0