Compare commits
41 Commits
master
...
fix/batch-
| Author | SHA1 | Date |
|---|---|---|
|
|
d1965eb098 | |
|
|
ce7bb8ad19 | |
|
|
831bd74be6 | |
|
|
f6467c5e84 | |
|
|
452c87dca7 | |
|
|
7f0652fd79 | |
|
|
73b5a557bc | |
|
|
cb8dfd5537 | |
|
|
49c4b2931f | |
|
|
623e065efa | |
|
|
b1bd9759ce | |
|
|
c229513584 | |
|
|
1004bddc8d | |
|
|
ad30d3b233 | |
|
|
3558a1626c | |
|
|
72f1a7d6f5 | |
|
|
9ca2ab9530 | |
|
|
ac78457f9e | |
|
|
cf9f56feb3 | |
|
|
c1155c2262 | |
|
|
4e4e3fb7c9 | |
|
|
f627c3d813 | |
|
|
bfd8cb0ba5 | |
|
|
1a5dbe9e8e | |
|
|
37264ea7ec | |
|
|
fca5e9b821 | |
|
|
7cd995dffa | |
|
|
d072d54995 | |
|
|
ad215e292d | |
|
|
d6c1a92fe3 | |
|
|
999e721bd8 | |
|
|
a82717bbf3 | |
|
|
d63ea089ba | |
|
|
74f75a55ca | |
|
|
e29a02a3d0 | |
|
|
2f943d9a53 | |
|
|
6042a7ea93 | |
|
|
60b6ea6f6c | |
|
|
1276744ee2 | |
|
|
f024db9878 | |
|
|
65f9f49e00 |
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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-keyring(OS 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 不重建 CLI,showcase 用的还是旧版二进制。
|
||||||
|
|
||||||
|
### 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/CD(release + 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. 变更说明文档
|
||||||
|
|
@ -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,与其他命令体验一致。
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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": "删除成功",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;">■</span> ${mod.title} <span class="tag">${mod.commands.length} 个命令</span></h3>
|
||||||
|
<span class="arrow">▼</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')">×</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')">×</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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue