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
|
||||
with:
|
||||
go-version: '1.22'
|
||||
go-version: '1.26'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
|
|
@ -36,7 +36,9 @@ jobs:
|
|||
"linux amd64" \
|
||||
"linux arm64" \
|
||||
"windows amd64" \
|
||||
"windows arm64"; do
|
||||
"windows arm64" \
|
||||
"freebsd amd64" \
|
||||
"freebsd arm64"; do
|
||||
GOOS=$(echo "$pair" | cut -d' ' -f1)
|
||||
GOARCH=$(echo "$pair" | cut -d' ' -f2)
|
||||
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"
|
||||
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
||||
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"
|
||||
)
|
||||
|
||||
|
|
@ -47,7 +48,10 @@ var versionCmd = &cobra.Command{
|
|||
|
||||
func Execute() error {
|
||||
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 nil
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/config"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
|
|
@ -41,8 +42,7 @@ func DeleteToken() error {
|
|||
// File-based fallback
|
||||
|
||||
func credentialPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli", "credentials")
|
||||
return filepath.Join(config.ConfigDir(), "credentials")
|
||||
}
|
||||
|
||||
func storeTokenFile(token string) error {
|
||||
|
|
|
|||
|
|
@ -24,12 +24,17 @@ type APIError struct {
|
|||
StatusCode int
|
||||
Code interface{}
|
||||
Message string
|
||||
Suggestion string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (e *APIError) Suggest() string {
|
||||
return e.Suggestion
|
||||
}
|
||||
|
||||
func New() (*Client, error) {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
|
|
@ -77,6 +82,10 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
if c.Debug {
|
||||
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
|
||||
if resp.StatusCode >= 400 {
|
||||
suggestion := suggestFix(resp.StatusCode)
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Code: resp.StatusCode,
|
||||
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),
|
||||
Code: int(statusCode),
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
switch code {
|
||||
case 400:
|
||||
return "请求参数格式错误,请检查参数值是否正确"
|
||||
case 401:
|
||||
return "请先运行 gitlink-cli auth login 登录"
|
||||
case 403:
|
||||
return "权限不足,请确认账户权限或联系项目管理员"
|
||||
case 404:
|
||||
return "资源不存在,请检查 owner/repo/id 是否正确"
|
||||
case 409:
|
||||
return "资源冲突,可能存在同名资源"
|
||||
case 422:
|
||||
return "参数校验失败,请检查请求参数"
|
||||
case 500, 502, 503:
|
||||
return "服务器内部错误,请稍后重试或联系平台管理员"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package config
|
|||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
|
@ -30,6 +31,11 @@ func ConfigDir() string {
|
|||
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if appData := os.Getenv("AppData"); appData != "" {
|
||||
return filepath.Join(appData, "gitlink-cli")
|
||||
}
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "gitlink-cli")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) {
|
|||
owner, repo, err := fromGitRemote()
|
||||
if err != nil {
|
||||
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) {
|
||||
out, err := exec.Command("git", "remote", "get-url", "origin").Output()
|
||||
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))
|
||||
return parseRemoteURL(remote)
|
||||
|
|
@ -45,7 +45,7 @@ func parseRemoteURL(remote string) (string, string, error) {
|
|||
if strings.HasPrefix(remote, "git@") {
|
||||
parts := strings.SplitN(remote, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmt.Errorf("cannot parse SSH remote: %s", remote)
|
||||
return "", "", fmt.Errorf("无法解析 SSH 远程地址: %s", remote)
|
||||
}
|
||||
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
|
||||
u, err := url.Parse(remote)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("cannot parse remote URL: %s", remote)
|
||||
return "", "", fmt.Errorf("无法解析远程地址 URL: %s", remote)
|
||||
}
|
||||
return parsePathSegments(u.Path)
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ func parsePathSegments(path string) (string, string, error) {
|
|||
path = strings.TrimSuffix(path, ".git")
|
||||
parts := strings.SplitN(path, "/", 3)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
// Try to render as table if data is a slice of maps
|
||||
switch data := envelope.Data.(type) {
|
||||
case []interface{}:
|
||||
return printSliceTable(w, data)
|
||||
case map[string]interface{}:
|
||||
// For maps with nested structures, prefer JSON
|
||||
if slice := findSliceInMap(data); slice != nil {
|
||||
return printSliceTable(w, slice)
|
||||
}
|
||||
if hasComplexValues(data) {
|
||||
return printJSON(w, envelope)
|
||||
}
|
||||
return printMapTable(w, data)
|
||||
default:
|
||||
// Fallback to JSON
|
||||
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 {
|
||||
for _, v := range m {
|
||||
switch v.(type) {
|
||||
|
|
@ -98,7 +120,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Collect headers from first item
|
||||
first, ok := items[0].(map[string]interface{})
|
||||
if !ok {
|
||||
data, _ := json.MarshalIndent(items, "", " ")
|
||||
|
|
@ -109,7 +130,6 @@ func printSliceTable(w io.Writer, items []interface{}) error {
|
|||
headers := collectKeys(first)
|
||||
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||
|
||||
// Print headers
|
||||
fmt.Fprintln(tw, strings.Join(headers, "\t"))
|
||||
dashes := make([]string, len(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"))
|
||||
|
||||
// Print rows
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
|
|
@ -144,7 +163,6 @@ func printMapTable(w io.Writer, m map[string]interface{}) error {
|
|||
|
||||
func collectKeys(m map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
// Prefer common keys first
|
||||
priority := []string{"id", "name", "login", "title", "status", "state", "created_at", "updated_at"}
|
||||
seen := map[string]bool{}
|
||||
for _, k := range priority {
|
||||
|
|
@ -170,10 +188,21 @@ func formatValue(v interface{}) string {
|
|||
case reflect.Map, reflect.Slice:
|
||||
data, _ := json.Marshal(v)
|
||||
s := string(data)
|
||||
if len(s) > 60 {
|
||||
return s[:57] + "..."
|
||||
if len(s) > 50 {
|
||||
return s[:47] + "..."
|
||||
}
|
||||
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:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ function getPlatformInfo(platform = os.platform(), arch = os.arch()) {
|
|||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
freebsd: "freebsd",
|
||||
};
|
||||
|
||||
const archMap = {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ PLATFORMS=(
|
|||
"linux/arm64"
|
||||
"windows/amd64"
|
||||
"windows/arm64"
|
||||
"freebsd/amd64"
|
||||
"freebsd/arm64"
|
||||
)
|
||||
|
||||
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 {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取分支列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -39,9 +39,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
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
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
from := ctx.Arg("from")
|
||||
if from == "" {
|
||||
from = "master"
|
||||
|
|
@ -52,7 +55,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建分支失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -65,15 +68,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
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
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("删除分支失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -86,15 +92,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
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
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"branch_name": name,
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("设置分支保护失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -107,13 +116,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
name, _ := ctx.RequireArg("name")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil)
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取构建列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -40,9 +40,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
build, err := ctx.RequireArg("build")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
stage := ctx.Arg("stage")
|
||||
step := ctx.Arg("step")
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取构建日志失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -66,13 +69,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil)
|
||||
build, err := ctx.RequireArg("build")
|
||||
if err != nil {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
|
@ -84,13 +90,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
build, _ := ctx.RequireArg("build")
|
||||
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil)
|
||||
build, err := ctx.RequireArg("build")
|
||||
if err != nil {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const closedIssueStatusID = 5
|
||||
const openIssueStatusID = 1
|
||||
|
||||
type batchCloseResult struct {
|
||||
type batchResult struct {
|
||||
Number string `json:"number" yaml:"number"`
|
||||
Action string `json:"action" yaml:"action"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
||||
}
|
||||
|
||||
type batchCloseSummary struct {
|
||||
Repository string `json:"repository" yaml:"repository"`
|
||||
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"`
|
||||
Results []batchCloseResult `json:"results" yaml:"results"`
|
||||
type batchSummary struct {
|
||||
Repository string `json:"repository" yaml:"repository"`
|
||||
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 []batchResult `json:"results" yaml:"results"`
|
||||
}
|
||||
|
||||
func newBatchCloseShortcut() *common.Shortcut {
|
||||
|
|
@ -43,27 +46,29 @@ func newBatchCloseShortcut() *common.Shortcut {
|
|||
|
||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||
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"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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"))
|
||||
summary := batchCloseSummary{
|
||||
summary := batchSummary{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
DryRun: dryRun,
|
||||
Total: len(numbers),
|
||||
Results: make([]batchCloseResult, 0, len(numbers)),
|
||||
Results: make([]batchResult, 0, len(numbers)),
|
||||
}
|
||||
|
||||
for _, number := range numbers {
|
||||
result := batchCloseResult{Number: number, Action: "close"}
|
||||
result := batchResult{Number: number, Action: "close"}
|
||||
if dryRun {
|
||||
result.Status = "planned"
|
||||
summary.Succeeded++
|
||||
|
|
@ -82,11 +87,13 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
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 of %d issue(s) failed to close", summary.Failed, summary.Total)
|
||||
return fmt.Errorf("%d / %d 个 Issue 关闭失败", summary.Failed, summary.Total)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -94,7 +101,7 @@ func runBatchClose(ctx *common.RuntimeContext) error {
|
|||
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch issue: %w", err)
|
||||
return fmt.Errorf("获取 Issue 详情: %w", err)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
|
|
@ -103,11 +110,383 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
|
|||
"status_id": closedIssueStatusID,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
numbers, err := parseIssueNumbers(numbersValue)
|
||||
if err != nil {
|
||||
|
|
@ -134,7 +513,7 @@ func parseIssueNumbers(value string) ([]string, error) {
|
|||
func readIssueNumbersFromCSV(path string) ([]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read issue numbers from CSV: %w", err)
|
||||
return nil, fmt.Errorf("读取 CSV 文件失败: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
|
|
@ -142,7 +521,7 @@ func readIssueNumbersFromCSV(path string) ([]string, error) {
|
|||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
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 {
|
||||
return nil, nil
|
||||
|
|
@ -180,7 +559,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) {
|
|||
continue
|
||||
}
|
||||
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] {
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
|
|
@ -17,11 +18,16 @@ func v1RepoPath(ctx *common.RuntimeContext) string {
|
|||
type existingIssue struct {
|
||||
Subject string
|
||||
Description string
|
||||
StatusID interface{}
|
||||
}
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCloseShortcut(),
|
||||
newBatchReopenShortcut(),
|
||||
newBatchAssignShortcut(),
|
||||
newBatchLabelShortcut(),
|
||||
newBatchMilestoneShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List issues",
|
||||
|
|
@ -32,7 +38,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
|
|
@ -42,7 +48,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取 Issue 列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -59,7 +65,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
title, err := ctx.RequireArg("title")
|
||||
if err != nil {
|
||||
|
|
@ -82,7 +88,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建 Issue 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -95,7 +101,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("查看 Issue 详情失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -116,7 +122,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
|
|
@ -124,7 +130,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
current, err := fetchExistingIssue(ctx, number)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("关闭 Issue 失败: %w", err)
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
|
|
@ -133,9 +139,40 @@ func Shortcuts() []*common.Shortcut {
|
|||
"status_id": 5, // 5 = closed
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
|
@ -150,7 +187,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
if err != nil {
|
||||
|
|
@ -160,12 +197,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
description := ctx.Arg("body")
|
||||
state := ctx.Arg("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)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("更新 Issue 失败: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("更新 Issue 失败: %w", err)
|
||||
}
|
||||
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",
|
||||
Description: "Add a comment to an issue",
|
||||
|
|
@ -201,7 +336,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
number, err := ctx.RequireArg("number")
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("添加评论失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -227,20 +362,28 @@ func Shortcuts() []*common.Shortcut {
|
|||
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) {
|
||||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("获取 Issue 详情: %w", err)
|
||||
}
|
||||
issueData, ok := getEnv.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to parse issue data")
|
||||
return nil, fmt.Errorf("解析 Issue 数据失败")
|
||||
}
|
||||
subject, _ := issueData["subject"].(string)
|
||||
if subject == "" {
|
||||
return nil, fmt.Errorf("failed to parse issue subject")
|
||||
return nil, fmt.Errorf("解析 Issue 标题失败")
|
||||
}
|
||||
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{
|
||||
Subject: subject,
|
||||
Description: description,
|
||||
StatusID: statusID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +397,6 @@ func normalizeIssueStatus(state string) (interface{}, error) {
|
|||
if id, err := strconv.Atoi(state); err == 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)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return []*common.Shortcut{
|
||||
newBatchInviteShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List organizations",
|
||||
|
|
@ -22,7 +23,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/organizations", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取组织列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -34,11 +35,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "id", Short: "i", Usage: "Organization ID or login", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
|
@ -51,13 +55,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, _ := ctx.RequireArg("id")
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取组织成员列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -70,7 +77,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "description", Short: "d", Usage: "Description"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
name, _ := ctx.RequireArg("name")
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
|
|
@ -79,7 +89,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", "/organizations", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建组织失败: %w", err)
|
||||
}
|
||||
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 {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
|
|
@ -30,7 +30,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取 PR 列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -46,10 +46,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
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
|
||||
}
|
||||
title, _ := ctx.RequireArg("title")
|
||||
head, _ := ctx.RequireArg("head")
|
||||
base := ctx.Arg("base")
|
||||
if base == "" {
|
||||
base = "master"
|
||||
|
|
@ -64,7 +70,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建 PR 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -77,13 +83,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
|
@ -96,9 +105,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
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
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
method := ctx.Arg("method")
|
||||
if method == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("合并 PR 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -121,13 +133,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
|
@ -139,13 +154,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
|
@ -157,13 +175,53 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
|
@ -176,14 +234,20 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
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
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
body, _ := ctx.RequireArg("body")
|
||||
|
||||
prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch PR: %w", err)
|
||||
return fmt.Errorf("获取 PR 详情: %w", err)
|
||||
}
|
||||
issueID, err := extractIssueID(prEnv)
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("添加 PR 评论失败: %w", err)
|
||||
}
|
||||
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) {
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unexpected PR response format")
|
||||
return 0, fmt.Errorf("PR 响应格式异常")
|
||||
}
|
||||
issue, ok := data["issue"].(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("PR response missing issue field")
|
||||
return 0, fmt.Errorf("PR 响应缺少 issue 字段")
|
||||
}
|
||||
idFloat, ok := issue["id"].(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("PR response missing issue.id field")
|
||||
return 0, fmt.Errorf("PR 响应缺少 issue.id 字段")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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/ci"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/commit"
|
||||
"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/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/pr"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
|
||||
"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/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.
|
||||
func RegisterAll(root *cobra.Command) {
|
||||
groups := map[string][]*common.Shortcut{
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.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{
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search 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 {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@ package release
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -19,14 +22,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取 Release 列表失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -43,10 +46,16 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
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
|
||||
}
|
||||
tag, _ := ctx.RequireArg("tag")
|
||||
name, _ := ctx.RequireArg("name")
|
||||
payload := map[string]interface{}{
|
||||
"tag_name": tag,
|
||||
"name": name,
|
||||
|
|
@ -62,7 +71,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建 Release 失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -75,16 +84,122 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
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)
|
||||
},
|
||||
},
|
||||
{
|
||||
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",
|
||||
Description: "Delete a release",
|
||||
|
|
@ -93,22 +208,21 @@ func Shortcuts() []*common.Shortcut {
|
|||
},
|
||||
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
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), 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)
|
||||
if viewErr != nil {
|
||||
// Release no longer exists — delete actually succeeded
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
}, nil))
|
||||
}
|
||||
// Release still exists — delete truly failed
|
||||
return delErr
|
||||
return fmt.Errorf("删除 Release 失败: %w", delErr)
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
"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 (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchDeleteShortcut(),
|
||||
newBatchForkShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repositories for a user or organization",
|
||||
|
|
@ -33,21 +38,72 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取仓库列表失败: %w", err)
|
||||
}
|
||||
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",
|
||||
Description: "Show repository details",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取仓库详情失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -65,15 +121,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Get current user login for the create path
|
||||
userEnv, err := ctx.CallAPI("GET", "/users/me", 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{})
|
||||
login, _ := userData["login"].(string)
|
||||
if login == "" {
|
||||
return fmt.Errorf("cannot determine current user login")
|
||||
return fmt.Errorf("无法获取当前用户名")
|
||||
}
|
||||
userID, _ := userData["user_id"].(float64)
|
||||
body := map[string]interface{}{
|
||||
|
|
@ -89,7 +144,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("创建仓库失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -99,11 +154,57 @@ func Shortcuts() []*common.Shortcut {
|
|||
Description: "Fork a repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", 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)
|
||||
},
|
||||
|
|
@ -113,11 +214,11 @@ func Shortcuts() []*common.Shortcut {
|
|||
Description: "Delete a repository",
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("解析仓库信息失败: %w", err)
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("删除仓库失败: %w", err)
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"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"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
keyword, err := ctx.RequireArg("keyword")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/projects", q)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("搜索仓库失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -38,15 +42,50 @@ func Shortcuts() []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
keyword, _ := ctx.RequireArg("keyword")
|
||||
keyword, err := ctx.RequireArg("keyword")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("search", keyword)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
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 {
|
||||
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)
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("获取当前用户信息失败: %w", err)
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
|
|
@ -32,7 +32,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("查看用户详情失败: %w", err)
|
||||
}
|
||||
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