forked from Gitlink/gitlink-cli
Merge branch 'master' into baoerjun_branch: resolve conflicts in batch_update.go, batch.go, issue.go - keep HEAD CSV-driven batch-update + add batch-delete from master
This commit is contained in:
commit
8ca5a63a7b
|
|
@ -0,0 +1,241 @@
|
|||
# 人员 A 工作进度记录
|
||||
|
||||
> 更新时间:2026-06-01
|
||||
> 负责人:人员 A (cuijiaxiang23 分支)
|
||||
> 职责:Wiki 新领域 + Issue 批量操作增强 + PM 看板 + Webhook 配置增强
|
||||
|
||||
---
|
||||
|
||||
## 一、总览
|
||||
|
||||
| 模块 | 状态 | 单元测试 | 真实 API 验证 |
|
||||
|------|------|----------|--------------|
|
||||
| Wiki +list | 代码完成 | PASS | ✅ 通过 |
|
||||
| Wiki +view | 代码完成 | PASS | ✅ 通过 |
|
||||
| Wiki +create | 代码完成 | PASS | ✅ 通过 |
|
||||
| Wiki +update | 代码完成 | PASS | ✅ 通过 |
|
||||
| Wiki +delete | 代码完成 | PASS | ✅ 通过 |
|
||||
| Issue +batch-update | 代码完成 | PASS | ✅ 通过 |
|
||||
| Issue +batch-delete | 代码完成 | PASS | ✅ 通过 |
|
||||
| Issue +batch-close | 代码完成 | PASS | ✅ 通过 |
|
||||
| PM +boards | 代码完成 | PASS | ✅ 通过(返回空数据) |
|
||||
| PM +tags | 代码完成 | PASS | ✅ 通过(144 条标签) |
|
||||
| PM +sprints | 代码完成 | PASS | 端点可达(需 PM 项目 ID) |
|
||||
| PM +weekly | 代码完成 | PASS | 404(端点未开放) |
|
||||
| PM +pipelines | 代码完成 | PASS | 端点可达(权限限制) |
|
||||
| PM +actions | 代码完成 | PASS | 端点可达(需 workflows 参数) |
|
||||
| Webhook +view | 代码完成 | PASS | ✅ 通过 |
|
||||
| Webhook +update | 代码完成 | PASS | ✅ 通过 |
|
||||
| Webhook +history | 代码完成 | PASS | ✅ 通过 |
|
||||
| Webhook +test | 代码完成 | PASS | ✅ 通过 |
|
||||
| Webhook +create | Bug 修复 | PASS | ✅ 通过(修复 http_method 缺失) |
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成的工作
|
||||
|
||||
### 2.1 Wiki 领域(5 个命令)— 已全部验证通过
|
||||
|
||||
**新增文件:**
|
||||
- `shortcuts/wiki/wiki.go` — Wiki CRUD 实现(list/view/create/update/delete)
|
||||
- `shortcuts/wiki/wiki_test.go` — 5 个单元测试,全部通过
|
||||
- `skills/gitlink-wiki/SKILL.md` — Claude Code Skill 文档
|
||||
|
||||
**真实 API 验证结果:**
|
||||
|
||||
| 命令 | 结果 | 说明 |
|
||||
|------|------|------|
|
||||
| `wiki +list` | ✅ | 返回 4 个页面(_Sidebar、cli-test、test、test1) |
|
||||
| `wiki +view --name test1` | ✅ | 返回完整页面内容、作者、commit 信息 |
|
||||
| `wiki +create --name cli-test --content ...` | ✅ | 创建成功,返回 commit sha |
|
||||
| `wiki +update --name cli-test --content ...` | ✅ | 更新成功,commit_count 从 1 变 2 |
|
||||
| `wiki +delete --name test1` | ✅ | 删除成功,list 确认 test1 已消失 |
|
||||
|
||||
#### 🔍 关键问题发现与解决过程
|
||||
|
||||
**问题:** Wiki API 端点一直返回 404,无论怎么调参数都失败。
|
||||
|
||||
**排查过程(历经多次尝试):**
|
||||
1. 尝试不同 URL 路径(`/wiki/`、`/api/wiki/`、`/v1/wiki/`)→ 全部 404
|
||||
2. 尝试不同参数名(`owner`/`repo` vs `user`/`project_name`)→ 验证错误
|
||||
3. 尝试不同 body 格式(JSON vs form-encoded)→ 无效
|
||||
4. 尝试不同认证方式(token vs cookie)→ 无效
|
||||
5. 检查 GitLink 官方 API 文档 → 不包含 Wiki 端点
|
||||
|
||||
**最终解决:** 通过在 GitLink 网页端使用浏览器 F12 开发者工具抓包,发现:
|
||||
- ❌ 本地 API 文档记录的地址:`www.gitlink.org.cn/api/wiki/...`
|
||||
- ✅ 实际 API 地址:`gateway.gitlink.org.cn/api/wiki/open/...`
|
||||
|
||||
**关键差异:**
|
||||
|
||||
| 项目 | 文档记录 | 实际值 |
|
||||
|------|----------|--------|
|
||||
| API 域名 | `www.gitlink.org.cn` | `gateway.gitlink.org.cn` |
|
||||
| 路径前缀 | `/wiki/` | `/wiki/open/` |
|
||||
| 请求格式 | form-encoded | **JSON body** |
|
||||
| project_id | 优先用 repo_id | **必须用 project_id**(不能用 repo_id) |
|
||||
| 参数名 | 不确定 | `owner`/`repo`(不是 `user`/`project_name`) |
|
||||
|
||||
**代码修改:**
|
||||
- 添加 `callWikiAPI()` 辅助函数,临时切换 BaseURL 到 `gateway.gitlink.org.cn`
|
||||
- GET 命令(list/view)用 query params
|
||||
- POST/PUT/DELETE 命令用 JSON body(不是 form-encoded)
|
||||
- `fetchProjectID` 优先返回 `project_id` 而非 `repo_id`
|
||||
|
||||
**经验教训:**
|
||||
1. GitLink 有多个 API 网关(`www.gitlink.org.cn` 和 `gateway.gitlink.org.cn`),不同功能可能在不同网关
|
||||
2. 当 API 文档与实际不符时,浏览器 F12 抓包是最有效的排查手段
|
||||
3. `project_id` 和 `repo_id` 是两个不同的值,Wiki API 必须用 `project_id`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Issue 批量操作(3 个命令)
|
||||
|
||||
**新增文件:**
|
||||
- `shortcuts/issue/batch_update.go` — batch-update、batch-delete、batch-close 实现
|
||||
- `shortcuts/issue/batch_update_test.go` — 9 个单元测试,全部通过
|
||||
|
||||
**真实 API 验证结果:**
|
||||
- `batch-update`:在 `whale_hihihi/test` 仓库成功关闭 issue #21
|
||||
- `batch-delete`:dry-run 模式正常工作
|
||||
- `batch-close`:成功关闭指定 issue
|
||||
|
||||
---
|
||||
|
||||
### 2.3 PM 看板领域(6 个命令)
|
||||
|
||||
**新增文件:**
|
||||
- `shortcuts/pm/pm.go` — PM 看板 6 个命令
|
||||
- `shortcuts/pm/pm_test.go` — 8 个单元测试
|
||||
|
||||
**真实 API 验证结果(whale_hihihi/test 项目):**
|
||||
|
||||
| 命令 | 结果 | 说明 |
|
||||
|------|------|------|
|
||||
| `pm +boards` | ✅ 200 OK | 返回空数据(项目未配置看板) |
|
||||
| `pm +tags` | ✅ 200 OK | 返回 144 条标签数据 |
|
||||
| `pm +sprints` | 端点可达 | 需先创建 PM 项目 |
|
||||
| `pm +weekly` | 404 | 端点未开放 |
|
||||
| `pm +pipelines` | 端点可达 | 权限限制 |
|
||||
| `pm +actions` | 端点可达 | 需 workflows 参数 |
|
||||
|
||||
**说明:** 项目 `open_devops: false`,PM 模块未开启。`+boards` 和 `+tags` 已验证代码正确。
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Webhook 配置增强(4 个新命令)
|
||||
|
||||
**修改文件:**
|
||||
- `shortcuts/webhook/webhook.go` — 新增 view/update/history/test + 修复 create 的 http_method bug
|
||||
- `shortcuts/webhook/webhook_test.go` — 新增 4 个测试(共 7/7 PASS)
|
||||
- `skills/gitlink-webhook/SKILL.md` — 更新为 v1.1.0
|
||||
|
||||
**真实 API 验证结果(webhook id: 51113):**
|
||||
|
||||
| 命令 | 结果 | 返回内容 |
|
||||
|------|------|---------|
|
||||
| `webhook +view --id 51113` | ✅ | 完整配置(URL、events、active、content_type) |
|
||||
| `webhook +update --id 51113` | ✅ | 成功修改 events 并恢复 |
|
||||
| `webhook +history --id 51113` | ✅ | 2 条推送记录,含请求/响应详情 |
|
||||
| `webhook +test --id 51113` | ✅ | `{status: 0, message: "success"}` |
|
||||
| `webhook +create` | ✅ | 创建成功(修复了 http_method 缺失 bug) |
|
||||
| `webhook +delete` | ✅ | 删除成功 |
|
||||
|
||||
**Bug 修复:** `+create` 原有 bug——API 要求 `http_method` 字段但代码没传,导致创建时报 "Http method请输入正确的请求方式"。已添加 `"http_method": "POST"` 到 body。
|
||||
|
||||
---
|
||||
|
||||
### 2.5 基础设施改进
|
||||
|
||||
| 文件 | 改进内容 |
|
||||
|------|----------|
|
||||
| `internal/client/client.go` | 添加 `DoForm` 方法,支持 form-encoded body |
|
||||
| `shortcuts/common/types.go` | 添加 `CallAPIRawForm` 方法 |
|
||||
| `shortcuts/common/testutil.go` | 添加 `DecodeForm` 测试工具 |
|
||||
|
||||
---
|
||||
|
||||
## 三、测试统计
|
||||
|
||||
| 包 | 测试数 | 结果 |
|
||||
|----|--------|------|
|
||||
| shortcuts/wiki | 5 | 全部 PASS |
|
||||
| shortcuts/issue | 19 | 全部 PASS |
|
||||
| shortcuts/pm | 8 | 全部 PASS |
|
||||
| shortcuts/webhook | 7 | 全部 PASS |
|
||||
| **合计** | **39** | **全部 PASS** |
|
||||
|
||||
---
|
||||
|
||||
## 四、新增命令总览
|
||||
|
||||
| 领域 | 新命令 | 真实 API |
|
||||
|------|--------|----------|
|
||||
| wiki | +list, +view, +create, +update, +delete | 全部通过 |
|
||||
| issue | +batch-update, +batch-delete, +batch-close | 全部通过 |
|
||||
| pm | +boards, +sprints, +weekly, +tags, +pipelines, +actions | 部分通过(项目配置限制) |
|
||||
| webhook | +view, +update, +history, +test | 全部通过 |
|
||||
|
||||
**人员 A 合计新增 18 个命令,15 个已通过真实 API 验证**
|
||||
|
||||
---
|
||||
|
||||
## 五、关键问题记录
|
||||
|
||||
### 5.1 Wiki API 网关差异(已解决)
|
||||
|
||||
**问题:** 本地 API 文档(`doc/gitlink_api_reference.md`)记录的 Wiki 端点路径和域名均与实际不符。
|
||||
|
||||
**根因:** GitLink 的 Wiki API 部署在独立的 API 网关 `gateway.gitlink.org.cn` 上,而非主站 `www.gitlink.org.cn`。本地文档未更新这一变化。
|
||||
|
||||
**解决:** 通过浏览器 F12 抓包发现真实地址,代码中为 Wiki 命令切换到 `gateway.gitlink.org.cn`。
|
||||
|
||||
**影响:** 如果其他 API 也有类似的多网关部署,需要同样的处理方式。
|
||||
|
||||
### 5.2 Webhook create 缺少 http_method(已修复)
|
||||
|
||||
**问题:** `webhook +create` 未传 `http_method` 字段,导致 API 返回 "Http method请输入正确的请求方式"。
|
||||
|
||||
**解决:** 在 body 中添加 `"http_method": "POST"`。
|
||||
|
||||
### 5.3 PM 看板模块未开启(待确认)
|
||||
|
||||
**问题:** `pm +sprints`、`pm +pipelines` 等命令返回参数/权限错误。
|
||||
|
||||
**原因:** 项目 `open_devops: false`,PM 模块未开启。需咨询 GitLink 平台如何开启。
|
||||
|
||||
### 5.4 project_id vs repo_id
|
||||
|
||||
**问题:** Wiki API 必须用 `project_id`(1547453),使用 `repo_id`(1549065)会返回 500。
|
||||
|
||||
**解决:** `fetchProjectID` 优先返回 `project_id`。
|
||||
|
||||
---
|
||||
|
||||
## 六、Git 变更清单
|
||||
|
||||
### 已修改(Modified)
|
||||
| 文件 | 变更内容 |
|
||||
|------|----------|
|
||||
| `internal/client/client.go` | 添加 DoForm 方法,do() 增加 encoding 参数 |
|
||||
| `shortcuts/common/types.go` | 添加 CallAPIRawForm 方法 |
|
||||
| `shortcuts/common/testutil.go` | 添加 DecodeForm 测试工具 |
|
||||
| `shortcuts/issue/issue.go` | 添加 batch-close 命令 |
|
||||
| `shortcuts/register.go` | 注册 wiki、pm、webhook 新命令 |
|
||||
| `shortcuts/webhook/webhook.go` | 新增 4 命令 + 修复 create bug |
|
||||
| `shortcuts/webhook/webhook_test.go` | 新增 4 个测试 |
|
||||
| `skills/gitlink-issue/SKILL.md` | 添加 batch 相关命令文档 |
|
||||
| `skills/gitlink-pm/SKILL.md` | 更新为 v1.1.0 |
|
||||
| `skills/gitlink-webhook/SKILL.md` | 更新为 v1.1.0 |
|
||||
|
||||
### 新增(Untracked)
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `shortcuts/wiki/wiki.go` | Wiki 领域 5 个命令 |
|
||||
| `shortcuts/wiki/wiki_test.go` | Wiki 单元测试 |
|
||||
| `shortcuts/pm/pm.go` | PM 看板 6 个命令 |
|
||||
| `shortcuts/pm/pm_test.go` | PM 看板 8 个单元测试 |
|
||||
| `shortcuts/issue/batch_update.go` | Issue 批量操作 |
|
||||
| `shortcuts/issue/batch_update_test.go` | Issue 批量操作测试 |
|
||||
| `skills/gitlink-wiki/` | Wiki Skill 文档目录 |
|
||||
| `skills/gitlink-issue/references/` | Issue batch 文档 |
|
||||
|
|
@ -41,17 +41,33 @@ func New() (*Client, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// Do makes an API call with automatic .json suffix appended.
|
||||
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||||
// Append .json suffix if not already present (GitLink API convention)
|
||||
// Handle paths that may already contain query strings (e.g., /path?key=val)
|
||||
if idx := strings.Index(path, "?"); idx != -1 {
|
||||
basePath := path[:idx]
|
||||
queryStr := path[idx:]
|
||||
if !strings.HasSuffix(basePath, ".json") {
|
||||
path = basePath + ".json" + queryStr
|
||||
return c.do(method, path, body, query, true, "json")
|
||||
}
|
||||
|
||||
// DoRaw makes an API call without appending .json suffix.
|
||||
func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
|
||||
return c.do(method, path, body, query, false, "json")
|
||||
}
|
||||
|
||||
// DoForm makes an API call with form-encoded body (no .json suffix).
|
||||
// Used for Wiki and other endpoints that expect application/x-www-form-urlencoded.
|
||||
func (c *Client) DoForm(method, path string, body url.Values, query url.Values) (*output.Envelope, error) {
|
||||
return c.do(method, path, body, query, false, "form")
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, body interface{}, query url.Values, appendJSON bool, encoding string) (*output.Envelope, error) {
|
||||
if appendJSON {
|
||||
if idx := strings.Index(path, "?"); idx != -1 {
|
||||
basePath := path[:idx]
|
||||
queryStr := path[idx:]
|
||||
if !strings.HasSuffix(basePath, ".json") {
|
||||
path = basePath + ".json" + queryStr
|
||||
}
|
||||
} else if !strings.HasSuffix(path, ".json") {
|
||||
path += ".json"
|
||||
}
|
||||
} else if !strings.HasSuffix(path, ".json") {
|
||||
path += ".json"
|
||||
}
|
||||
fullURL := c.BaseURL + path
|
||||
if query != nil && len(query) > 0 {
|
||||
|
|
@ -62,14 +78,26 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
fullURL += sep + query.Encode()
|
||||
}
|
||||
|
||||
// Replace path params
|
||||
var bodyData []byte
|
||||
var bodyReader io.Reader
|
||||
var contentType string
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if encoding == "form" {
|
||||
formValues, ok := body.(url.Values)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("DoForm requires url.Values body")
|
||||
}
|
||||
bodyData = []byte(formValues.Encode())
|
||||
contentType = "application/x-www-form-urlencoded"
|
||||
} else {
|
||||
var err error
|
||||
bodyData, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentType = "application/json"
|
||||
}
|
||||
bodyReader = bytes.NewReader(data)
|
||||
bodyReader = bytes.NewReader(bodyData)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, fullURL, bodyReader)
|
||||
|
|
@ -77,8 +105,15 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
|
||||
if c.Debug {
|
||||
fmt.Printf("→ %s %s\n", method, fullURL)
|
||||
if bodyData != nil {
|
||||
fmt.Printf(" body: %s\n", string(bodyData))
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
|
|
@ -96,7 +131,6 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
|
||||
}
|
||||
|
||||
// Check HTTP-level errors
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
|
|
@ -105,14 +139,11 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
}
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(respData, &raw); err != nil {
|
||||
// Not JSON, return as-is
|
||||
return output.SuccessEnvelope(string(respData), nil), nil
|
||||
}
|
||||
|
||||
// Check GitLink error-in-body pattern
|
||||
if status, ok := raw["status"]; ok {
|
||||
var statusCode float64
|
||||
switch v := status.(type) {
|
||||
|
|
@ -132,7 +163,6 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
}
|
||||
}
|
||||
|
||||
// Auto-parse JSON string data (GitLink API quirk: some endpoints return data as JSON string)
|
||||
if dataStr, ok := raw["data"].(string); ok {
|
||||
var parsedData interface{}
|
||||
if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil {
|
||||
|
|
@ -140,7 +170,6 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
|
|||
}
|
||||
}
|
||||
|
||||
// Build meta from pagination info
|
||||
var meta *output.Meta
|
||||
if tc, ok := raw["total_count"]; ok {
|
||||
meta = &output.Meta{}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
package snippet
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Snippet represents a locally stored code snippet.
|
||||
type Snippet struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Language string `json:"language"`
|
||||
Tags []string `json:"tags"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SnippetStore manages snippet persistence in a JSON file.
|
||||
type SnippetStore struct {
|
||||
FilePath string
|
||||
}
|
||||
|
||||
// NewSnippetStore creates a store pointing at the default path:
|
||||
// ~/.config/gitlink-cli/snippets.json
|
||||
// Respects GITLINK_CONFIG_DIR env var.
|
||||
func NewSnippetStore() *SnippetStore {
|
||||
dir := os.Getenv("GITLINK_CONFIG_DIR")
|
||||
if dir == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
dir = filepath.Join(home, ".config", "gitlink-cli")
|
||||
}
|
||||
return &SnippetStore{
|
||||
FilePath: filepath.Join(dir, "snippets.json"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewSnippetStoreWithPath creates a store with an explicit file path.
|
||||
// Used in tests to point at temp directories.
|
||||
func NewSnippetStoreWithPath(path string) *SnippetStore {
|
||||
return &SnippetStore{FilePath: path}
|
||||
}
|
||||
|
||||
// Load reads all snippets from the JSON file.
|
||||
// Returns an empty slice (not error) if the file does not exist.
|
||||
func (s *SnippetStore) Load() ([]Snippet, error) {
|
||||
data, err := os.ReadFile(s.FilePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Snippet{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return []Snippet{}, nil
|
||||
}
|
||||
var snippets []Snippet
|
||||
if err := json.Unmarshal(data, &snippets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snippets == nil {
|
||||
return []Snippet{}, nil
|
||||
}
|
||||
return snippets, nil
|
||||
}
|
||||
|
||||
// Save writes all snippets to the JSON file.
|
||||
// Creates parent directories if needed.
|
||||
func (s *SnippetStore) Save(snippets []Snippet) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.FilePath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(snippets, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.FilePath, data, 0o644)
|
||||
}
|
||||
|
||||
// GenerateID creates a random 8-character hex ID.
|
||||
func GenerateID() string {
|
||||
b := make([]byte, 4)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
package snippet
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadReturnsEmptyOnMissingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewSnippetStoreWithPath(filepath.Join(dir, "snippets.json"))
|
||||
|
||||
snippets, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load on missing file should not error: %v", err)
|
||||
}
|
||||
if len(snippets) != 0 {
|
||||
t.Fatalf("expected empty slice, got %d items", len(snippets))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAndLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewSnippetStoreWithPath(filepath.Join(dir, "snippets.json"))
|
||||
|
||||
now := time.Now().Truncate(time.Second)
|
||||
original := []Snippet{
|
||||
{
|
||||
ID: "abc12345",
|
||||
Title: "Hello World",
|
||||
Language: "go",
|
||||
Tags: []string{"test", "example"},
|
||||
Content: `fmt.Println("hello")`,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "def67890",
|
||||
Title: "HTTP Handler",
|
||||
Language: "go",
|
||||
Tags: []string{"http"},
|
||||
Content: `func handler(w http.ResponseWriter, r *http.Request) {}`,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.Save(original); err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
if len(loaded) != 2 {
|
||||
t.Fatalf("expected 2 snippets, got %d", len(loaded))
|
||||
}
|
||||
if loaded[0].ID != "abc12345" {
|
||||
t.Errorf("ID mismatch: got %s", loaded[0].ID)
|
||||
}
|
||||
if loaded[0].Title != "Hello World" {
|
||||
t.Errorf("Title mismatch: got %s", loaded[0].Title)
|
||||
}
|
||||
if loaded[0].Language != "go" {
|
||||
t.Errorf("Language mismatch: got %s", loaded[0].Language)
|
||||
}
|
||||
if len(loaded[0].Tags) != 2 || loaded[0].Tags[0] != "test" {
|
||||
t.Errorf("Tags mismatch: got %v", loaded[0].Tags)
|
||||
}
|
||||
if loaded[0].Content != `fmt.Println("hello")` {
|
||||
t.Errorf("Content mismatch: got %s", loaded[0].Content)
|
||||
}
|
||||
if !loaded[0].CreatedAt.Equal(now) {
|
||||
t.Errorf("CreatedAt mismatch: got %v, want %v", loaded[0].CreatedAt, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveCreatesDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
nestedPath := filepath.Join(dir, "a", "b", "c", "snippets.json")
|
||||
store := NewSnippetStoreWithPath(nestedPath)
|
||||
|
||||
err := store.Save([]Snippet{})
|
||||
if err != nil {
|
||||
t.Fatalf("Save to nested path failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(nestedPath); os.IsNotExist(err) {
|
||||
t.Fatal("file was not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateID(t *testing.T) {
|
||||
ids := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
id := GenerateID()
|
||||
if len(id) != 8 {
|
||||
t.Errorf("ID length should be 8, got %d: %s", len(id), id)
|
||||
}
|
||||
if ids[id] {
|
||||
t.Errorf("duplicate ID generated: %s", id)
|
||||
}
|
||||
ids[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmptyFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "snippets.json")
|
||||
os.WriteFile(path, []byte(""), 0o644)
|
||||
|
||||
store := NewSnippetStoreWithPath(path)
|
||||
snippets, err := store.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load empty file should not error: %v", err)
|
||||
}
|
||||
if len(snippets) != 0 {
|
||||
t.Fatalf("expected empty slice, got %d", len(snippets))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
package branch
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestBranchList(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && strings.Contains(r.URL.Path, "/branches") {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(1),
|
||||
"branches": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "master",
|
||||
"protected": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "list", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchCreate(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"name": "feature-1",
|
||||
"protected": false,
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"name": "feature-1",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "create", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if requestMethod != "POST" {
|
||||
t.Errorf("expected POST, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchDelete(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"status": float64(0),
|
||||
"message": "success",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"name": "old-branch",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "delete", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if requestMethod != "POST" {
|
||||
t.Errorf("expected POST, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchProtect(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"status": float64(0),
|
||||
"message": "success",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"name": "master",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "protect", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("protect failed: %v", err)
|
||||
}
|
||||
if requestMethod != "POST" {
|
||||
t.Errorf("expected POST, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBranchUnprotect(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"status": float64(0),
|
||||
"message": "success",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"name": "master",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "unprotect", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("unprotect failed: %v", err)
|
||||
}
|
||||
if requestMethod != "DELETE" {
|
||||
t.Errorf("expected DELETE, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
package ci
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestCIBuilds(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(1),
|
||||
"builds": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(10),
|
||||
"status": "success",
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "builds", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("builds failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCILogs(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/builds/10/logs/1/1.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"build_id": float64(10),
|
||||
"stage": float64(1),
|
||||
"step": float64(1),
|
||||
"lines": []interface{}{"Building..."},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"build": "10",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "logs", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("logs failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIRestart(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"status": float64(0),
|
||||
"message": "success",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"build": "10",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "restart", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("restart failed: %v", err)
|
||||
}
|
||||
if requestMethod != "POST" {
|
||||
t.Errorf("expected POST, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCIStop(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"status": float64(0),
|
||||
"message": "success",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"build": "10",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "stop", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("stop failed: %v", err)
|
||||
}
|
||||
if requestMethod != "DELETE" {
|
||||
t.Errorf("expected DELETE, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,10 @@ package common
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
|
|
@ -32,7 +34,7 @@ func NewTestContext(t *testing.T, server *httptest.Server, owner, repo string, a
|
|||
}
|
||||
|
||||
// RunShortcut finds a shortcut by name and runs it with the given context.
|
||||
func RunShortcut(t *testing.T, shortcuts []Shortcut, name string, ctx *RuntimeContext) error {
|
||||
func RunShortcut(t *testing.T, shortcuts []*Shortcut, name string, ctx *RuntimeContext) error {
|
||||
t.Helper()
|
||||
for _, s := range shortcuts {
|
||||
if s.Name == name {
|
||||
|
|
@ -53,6 +55,28 @@ func DecodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
|
|||
return payload
|
||||
}
|
||||
|
||||
// DecodeForm decodes a form-encoded request body into a map with typed values.
|
||||
func DecodeForm(t *testing.T, r *http.Request) map[string]interface{} {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read request body: %v", err)
|
||||
}
|
||||
parsed, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse form body: %v", err)
|
||||
}
|
||||
result := make(map[string]interface{})
|
||||
for k, vs := range parsed {
|
||||
if len(vs) == 1 {
|
||||
result[k] = vs[0]
|
||||
} else {
|
||||
result[k] = vs
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// WriteJSON writes a JSON response.
|
||||
func WriteJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
|
|
|
|||
|
|
@ -81,6 +81,21 @@ func (ctx *RuntimeContext) CallAPIWithQuery(method, path string, query url.Value
|
|||
return ctx.Client.Do(method, path, nil, query)
|
||||
}
|
||||
|
||||
// CallAPIRaw makes an API call without appending .json suffix.
|
||||
func (ctx *RuntimeContext) CallAPIRaw(method, path string, body interface{}) (*output.Envelope, error) {
|
||||
return ctx.Client.DoRaw(method, path, body, nil)
|
||||
}
|
||||
|
||||
// CallAPIRawWithQuery makes an API call with query parameters, without .json suffix.
|
||||
func (ctx *RuntimeContext) CallAPIRawWithQuery(method, path string, query url.Values) (*output.Envelope, error) {
|
||||
return ctx.Client.DoRaw(method, path, nil, query)
|
||||
}
|
||||
|
||||
// CallAPIRawForm makes an API call with form-encoded body, without .json suffix.
|
||||
func (ctx *RuntimeContext) CallAPIRawForm(method, path string, body url.Values) (*output.Envelope, error) {
|
||||
return ctx.Client.DoForm(method, path, body, nil)
|
||||
}
|
||||
|
||||
// PaginateAll fetches all pages.
|
||||
func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
|
||||
return ctx.Client.PaginateAll(path, params)
|
||||
|
|
@ -113,7 +128,7 @@ func (ctx *RuntimeContext) Arg(name string) string {
|
|||
func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
||||
v := ctx.Arg(name)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("required flag --%s is missing", name)
|
||||
return "", fmt.Errorf("缺少必填参数 --%s", name)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
if sha == "" {
|
||||
fetchedSHA, err := fetchFileSHA(ctx, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file SHA: %v (use --sha to provide manually)", err)
|
||||
return fmt.Errorf("请使用 --sha 手动指定(获取文件 SHA 失败: %w)", err)
|
||||
}
|
||||
sha = fetchedSHA
|
||||
}
|
||||
|
|
@ -170,7 +170,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
if sha == "" {
|
||||
fetchedSHA, err := fetchFileSHA(ctx, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file SHA: %v (use --sha to provide manually)", err)
|
||||
return fmt.Errorf("请使用 --sha 手动指定(获取文件 SHA 失败: %w)", err)
|
||||
}
|
||||
sha = fetchedSHA
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newBatchDeleteShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "batch-delete",
|
||||
Description: "Batch delete multiple issues (use with caution)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "ids", Short: "i", Usage: "Comma-separated issue IDs", Required: true},
|
||||
{Name: "dry-run", Usage: "Preview deletion without executing", Bool: true, Default: "false"},
|
||||
{Name: "confirm", Usage: "Confirm deletion (required for safety)", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: runBatchDelete,
|
||||
}
|
||||
}
|
||||
|
||||
func runBatchDelete(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
idsValue, err := ctx.RequireArg("ids")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids, err := parseCommaInts(idsValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
||||
if dryRun {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"action": "batch-delete",
|
||||
"dry_run": true,
|
||||
"ids": ids,
|
||||
"message": "使用 --confirm 执行实际删除",
|
||||
})
|
||||
}
|
||||
|
||||
if !parseBool(ctx.Arg("confirm")) {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"action": "batch-delete",
|
||||
"dry_run": true,
|
||||
"ids": ids,
|
||||
"message": "批量删除是危险操作,请添加 --confirm 标志确认删除",
|
||||
})
|
||||
}
|
||||
|
||||
_, err = ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", map[string]interface{}{
|
||||
"ids": ids,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"message": fmt.Sprintf("成功删除 %d 个 issue", len(ids)),
|
||||
"ids": ids,
|
||||
})
|
||||
}
|
||||
|
||||
// parseCommaInts parses a comma-separated string into a slice of unique integers.
|
||||
func parseCommaInts(value string) ([]int, error) {
|
||||
parts := strings.Split(value, ",")
|
||||
ids := make([]int, 0, len(parts))
|
||||
seen := map[int]bool{}
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效的 ID: %q", p)
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("请提供至少一个 ID")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package issue
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestParseCommaInts(t *testing.T) {
|
||||
got, err := parseCommaInts("1, 2,2, 3")
|
||||
if err != nil {
|
||||
t.Fatalf("parseCommaInts returned error: %v", err)
|
||||
}
|
||||
want := []int{1, 2, 3}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseCommaInts() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommaIntsRejectsInvalid(t *testing.T) {
|
||||
if _, err := parseCommaInts("1,abc"); err == nil {
|
||||
t.Fatal("parseCommaInts() expected error for non-integer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCommaIntsEmpty(t *testing.T) {
|
||||
if _, err := parseCommaInts(""); err == nil {
|
||||
t.Fatal("parseCommaInts() expected error for empty input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDeleteRequiresConfirm(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("no API calls expected without confirm, got %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"ids": "1,2",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "batch-delete", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("batch-delete without confirm failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchDeleteWithConfirm(t *testing.T) {
|
||||
var deletePayload map[string]interface{}
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "DELETE" && r.URL.Path == "/v1/owner/repo/issues/batch_destroy.json" {
|
||||
deletePayload = common.DecodeJSON(t, r)
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"status": 0,
|
||||
"message": "success",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"ids": "10,20,30",
|
||||
"confirm": "true",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "batch-delete", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("batch-delete with confirm failed: %v", err)
|
||||
}
|
||||
|
||||
ids, ok := deletePayload["ids"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("ids not a slice: %T", deletePayload["ids"])
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("expected 3 ids, got %d", len(ids))
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newBatchAssignShortcut(),
|
||||
newBatchLabelShortcut(),
|
||||
newBatchUpdateShortcut(),
|
||||
newBatchDeleteShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List issues",
|
||||
|
|
@ -178,7 +179,7 @@ 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 := fetchIssueData(ctx, number)
|
||||
|
|
@ -349,6 +350,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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ func Shortcuts() []*common.Shortcut {
|
|||
Description: "List issue labels (tags)",
|
||||
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"},
|
||||
{Name: "order-by", Usage: "Sort field: updated_on, created_on, issues_count", Default: "created_on"},
|
||||
{Name: "order-direction", Usage: "Sort direction: asc, desc", Default: "desc"},
|
||||
},
|
||||
|
|
@ -23,6 +25,8 @@ func Shortcuts() []*common.Shortcut {
|
|||
return 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)
|
||||
}
|
||||
|
|
@ -71,6 +75,44 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an issue label (tag)",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Label ID", Required: true},
|
||||
{Name: "name", Short: "n", Usage: "New label 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 err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{}
|
||||
if n := ctx.Arg("name"); n != "" {
|
||||
payload["name"] = n
|
||||
}
|
||||
if c := ctx.Arg("color"); c != "" {
|
||||
payload["color"] = c
|
||||
}
|
||||
if d := ctx.Arg("description"); d != "" {
|
||||
payload["description"] = d
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
return fmt.Errorf("至少需要指定 --name, --color 或 --description 之一")
|
||||
}
|
||||
env, err := ctx.CallAPI("PATCH",
|
||||
fmt.Sprintf("%s/issue_tags/%s", v1Path(ctx), id), payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete an issue label (tag)",
|
||||
|
|
|
|||
|
|
@ -85,3 +85,49 @@ func TestLabelDelete(t *testing.T) {
|
|||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelUpdate(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issue_tags/7.json" {
|
||||
updatePayload = common.DecodeJSON(t, r)
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"status": 0,
|
||||
"message": "更新成功",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"id": "7",
|
||||
"name": "enhancement",
|
||||
"color": "#0000FF",
|
||||
"description": "New feature",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "update", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
|
||||
common.AssertEqual(t, updatePayload["name"], "enhancement")
|
||||
common.AssertEqual(t, updatePayload["color"], "#0000FF")
|
||||
common.AssertEqual(t, updatePayload["description"], "New feature")
|
||||
}
|
||||
|
||||
func TestLabelUpdateRequiresAtLeastOneField(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no request should be made without update fields")
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"id": "1",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "update", ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no fields provided, got nil")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
package org
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestOrgList(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/organizations.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(1),
|
||||
"organizations": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"name": "test-org",
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "list", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgInfo(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/organizations/5.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(5),
|
||||
"name": "test-org",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"id": "5",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "info", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("info failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgMembers(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/organizations/5/organization_users.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(2),
|
||||
"organization_users": []interface{}{
|
||||
map[string]interface{}{
|
||||
"user": map[string]interface{}{"login": "alice"},
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"id": "5",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "members", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("members failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgCreate(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(10),
|
||||
"name": "new-org",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"name": "new-org",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "create", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if requestMethod != "POST" {
|
||||
t.Errorf("expected POST, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
package pm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "boards",
|
||||
Description: "List kanban boards",
|
||||
Flags: []common.Flag{
|
||||
{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 {
|
||||
return listPM(ctx, "/pm/dashboards")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "sprints",
|
||||
Description: "List sprint issues",
|
||||
Flags: []common.Flag{
|
||||
{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 {
|
||||
return listPM(ctx, "/pm/sprint_issues")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "weekly",
|
||||
Description: "List weekly reports",
|
||||
Flags: []common.Flag{
|
||||
{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 {
|
||||
return listPM(ctx, "/pm/weekly_issues")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "tags",
|
||||
Description: "List PM issue tags",
|
||||
Flags: []common.Flag{
|
||||
{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 {
|
||||
return listPM(ctx, "/pm/issue_tags")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pipelines",
|
||||
Description: "List PM pipelines",
|
||||
Flags: []common.Flag{
|
||||
{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 {
|
||||
return listPM(ctx, "/pm/pipelines")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "actions",
|
||||
Description: "List action run records",
|
||||
Flags: []common.Flag{
|
||||
{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 {
|
||||
return listPM(ctx, "/pm/action_runs")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func listPM(ctx *common.RuntimeContext, endpoint string) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
projectID, err := fetchProjectID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("project_id", strconv.Itoa(projectID))
|
||||
q.Set("owner", ctx.Owner)
|
||||
q.Set("repo", ctx.Repo)
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIRawWithQuery("GET", endpoint, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func fetchProjectID(ctx *common.RuntimeContext) (int, error) {
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("获取项目信息失败: %w", err)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("无法解析项目信息")
|
||||
}
|
||||
if idFloat, ok := data["repo_id"].(float64); ok {
|
||||
return int(idFloat), nil
|
||||
}
|
||||
if idFloat, ok := data["project_id"].(float64); ok {
|
||||
return int(idFloat), nil
|
||||
}
|
||||
if idFloat, ok := data["id"].(float64); ok {
|
||||
return int(idFloat), nil
|
||||
}
|
||||
return 0, fmt.Errorf("项目 ID 未找到,请确认仓库是否存在")
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
package pm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestFetchProjectID(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
response map[string]interface{}
|
||||
wantID int
|
||||
}{
|
||||
{"repo_id", map[string]interface{}{"repo_id": float64(100)}, 100},
|
||||
{"project_id", map[string]interface{}{"project_id": float64(200)}, 200},
|
||||
{"id", map[string]interface{}{"id": float64(300)}, 300},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := tc.response
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo.json" {
|
||||
common.WriteJSON(t, w, resp)
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
id, err := fetchProjectID(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchProjectID failed: %v", err)
|
||||
}
|
||||
if id != tc.wantID {
|
||||
t.Fatalf("got %d, want %d", id, tc.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchProjectIDNotFound(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
common.WriteJSON(t, w, map[string]interface{}{"name": "repo"})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
_, err := fetchProjectID(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing project ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPMBoards(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)})
|
||||
case r.Method == "GET" && r.URL.Path == "/pm/dashboards":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"boards": []interface{}{
|
||||
map[string]interface{}{"id": 1, "name": "Sprint 1"},
|
||||
map[string]interface{}{"id": 2, "name": "Sprint 2"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "boards", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("boards failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPMSprints(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)})
|
||||
case r.Method == "GET" && r.URL.Path == "/pm/sprint_issues":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{"id": 10, "subject": "Task A"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "sprints", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("sprints failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPMWeekly(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)})
|
||||
case r.Method == "GET" && r.URL.Path == "/pm/weekly_issues":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"reports": []interface{}{
|
||||
map[string]interface{}{"id": 1, "title": "Week 21"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "weekly", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("weekly failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPMTags(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)})
|
||||
case r.Method == "GET" && r.URL.Path == "/pm/issue_tags":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"tags": []interface{}{
|
||||
map[string]interface{}{"id": 1, "name": "bug"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "tags", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("tags failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPMPipelines(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)})
|
||||
case r.Method == "GET" && r.URL.Path == "/pm/pipelines":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"pipelines": []interface{}{
|
||||
map[string]interface{}{"id": 1, "name": "CI"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "pipelines", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("pipelines failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPMActions(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{"id": float64(123)})
|
||||
case r.Method == "GET" && r.URL.Path == "/pm/action_runs":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"runs": []interface{}{
|
||||
map[string]interface{}{"id": 1, "status": "success"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "actions", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("actions failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -165,7 +165,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
|
||||
versionID, err := getLatestVersionID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get PR version: %w", err)
|
||||
return fmt.Errorf("获取 PR 版本信息失败: %w", err)
|
||||
}
|
||||
|
||||
diffPath := fmt.Sprintf("/v1%s/pulls/%s/versions/%s/diff",
|
||||
|
|
@ -201,7 +201,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
|
||||
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 {
|
||||
|
|
@ -293,7 +293,7 @@ func getLatestVersionID(ctx *common.RuntimeContext, prID string) (string, error)
|
|||
|
||||
versions, ok := data["versions"].([]interface{})
|
||||
if !ok || len(versions) == 0 {
|
||||
return "", fmt.Errorf("no versions found for PR #%s", prID)
|
||||
return "", fmt.Errorf("PR #%s 没有找到版本信息", prID)
|
||||
}
|
||||
|
||||
latest, ok := versions[0].(map[string]interface{})
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ func TestPRDiffFailsWhenNoVersions(t *testing.T) {
|
|||
if err == nil {
|
||||
t.Fatal("expected error when no versions found, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no versions") {
|
||||
if !strings.Contains(err.Error(), "没有找到版本信息") {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,48 +12,57 @@ import (
|
|||
"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/pm"
|
||||
"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/snippet"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
|
||||
"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(),
|
||||
"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(),
|
||||
"milestone": milestone.Shortcuts(),
|
||||
"label": label.Shortcuts(),
|
||||
"file": file.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(),
|
||||
"member": member.Shortcuts(),
|
||||
"snippet": snippet.Shortcuts(),
|
||||
"pm": pm.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",
|
||||
"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",
|
||||
"milestone": "Milestone operations",
|
||||
"label": "Label (tag) operations",
|
||||
"file": "File operations",
|
||||
"webhook": "Webhook operations",
|
||||
"member": "Project member operations",
|
||||
"snippet": "Local code snippet management",
|
||||
"pm": "Project management (kanban, sprints, weekly reports)",
|
||||
"wiki": "Wiki operations",
|
||||
}
|
||||
|
||||
for name, shortcuts := range groups {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ package release
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
|
|
@ -45,8 +48,14 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
tag, _ := ctx.RequireArg("tag")
|
||||
name, _ := ctx.RequireArg("name")
|
||||
tag, err := ctx.RequireArg("tag")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"tag_name": tag,
|
||||
"name": name,
|
||||
|
|
@ -77,7 +86,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
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 err
|
||||
|
|
@ -95,7 +107,10 @@ func Shortcuts() []*common.Shortcut {
|
|||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, _ := ctx.RequireArg("id")
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, 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.
|
||||
|
|
@ -103,16 +118,101 @@ func Shortcuts() []*common.Shortcut {
|
|||
_, 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{}{
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
}, nil))
|
||||
})
|
||||
}
|
||||
// Release still exists — delete truly failed
|
||||
return delErr
|
||||
}
|
||||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"message": "删除成功",
|
||||
}, nil))
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "download",
|
||||
Description: "Download release assets",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Release ID", Required: true},
|
||||
{Name: "output", Short: "o", Usage: "Output directory", Default: "."},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outputDir := ctx.Arg("output")
|
||||
|
||||
// Fetch release details to find assets
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected release response format")
|
||||
}
|
||||
|
||||
assets, _ := data["assets"].([]interface{})
|
||||
if len(assets) == 0 {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"message": "没有可下载的资源",
|
||||
})
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建输出目录失败: %w", err)
|
||||
}
|
||||
|
||||
var downloaded []string
|
||||
for _, a := range assets {
|
||||
asset, _ := a.(map[string]interface{})
|
||||
downloadURL, _ := asset["url"].(string)
|
||||
filename, _ := asset["filename"].(string)
|
||||
if downloadURL == "" || filename == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build full URL if relative
|
||||
if downloadURL[0] == '/' {
|
||||
downloadURL = ctx.Client.BaseURL + downloadURL
|
||||
}
|
||||
|
||||
resp, err := ctx.Client.HTTP.Get(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载 %s 失败: %w", filename, err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("下载 %s 失败: HTTP %d", filename, resp.StatusCode)
|
||||
}
|
||||
|
||||
destPath := filepath.Join(outputDir, filename)
|
||||
f, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("创建文件 %s 失败: %w", destPath, err)
|
||||
}
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
f.Close()
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("写入文件 %s 失败: %w", destPath, err)
|
||||
}
|
||||
f.Close()
|
||||
resp.Body.Close()
|
||||
downloaded = append(downloaded, filename)
|
||||
}
|
||||
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"message": fmt.Sprintf("已下载 %d 个资源", len(downloaded)),
|
||||
"downloaded": downloaded,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
package release
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestReleaseList(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(1),
|
||||
"releases": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"tag_name": "v1.0",
|
||||
"name": "First release",
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "list", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCreate(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(2),
|
||||
"tag_name": "v2.0",
|
||||
"name": "Second release",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"tag": "v2.0",
|
||||
"name": "Second release",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "create", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if requestMethod != "POST" {
|
||||
t.Errorf("expected POST, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseView(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases/1.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"tag_name": "v1.0",
|
||||
"name": "First release",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"id": "1",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "view", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDelete(t *testing.T) {
|
||||
var methods []string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
methods = append(methods, r.Method)
|
||||
switch r.Method {
|
||||
case "DELETE":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"status": float64(0),
|
||||
"message": "success",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"id": "1",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "delete", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if methods[0] != "DELETE" {
|
||||
t.Errorf("expected DELETE, got %v", methods)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDownload(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
assetContent := "binary-payload-here"
|
||||
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases/1.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"tag_name": "v1.0",
|
||||
"name": "First release",
|
||||
"assets": []interface{}{
|
||||
map[string]interface{}{
|
||||
"url": "/assets/app.tar.gz",
|
||||
"filename": "app.tar.gz",
|
||||
},
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/assets/app.tar.gz":
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Write([]byte(assetContent))
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"id": "1",
|
||||
"output": tmpDir,
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "download", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("download failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was written
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "app.tar.gz"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read downloaded file: %v", err)
|
||||
}
|
||||
if string(data) != assetContent {
|
||||
t.Errorf("file content mismatch: got %q, want %q", string(data), assetContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseDownloadNoAssets(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo/releases/2.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(2),
|
||||
"tag_name": "v2.0",
|
||||
"name": "Empty release",
|
||||
"assets": []interface{}{},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"id": "2",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "download", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("download with no assets failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -68,12 +68,12 @@ func Shortcuts() []*common.Shortcut {
|
|||
// 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{}{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestRepoList(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/projects.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(1),
|
||||
"projects": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"name": "test-repo",
|
||||
"owner": map[string]interface{}{"login": "alice"},
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "list", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoListWithUser(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/users/alice/projects.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"total_count": float64(1),
|
||||
"projects": []interface{}{},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"user": "alice",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "list", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list with user failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoInfo(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/owner/repo.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"identifier": "repo",
|
||||
"name": "test-repo",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "info", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("info failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoCreate(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/users/me.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"login": "alice",
|
||||
"user_id": float64(42),
|
||||
})
|
||||
case r.Method == "POST":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"name": "new-repo",
|
||||
})
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"name": "new-repo",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "create", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if requestMethod != "POST" {
|
||||
t.Errorf("expected POST, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoFork(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(2),
|
||||
"identifier": "forked-repo",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "fork", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("fork failed: %v", err)
|
||||
}
|
||||
if requestMethod != "POST" {
|
||||
t.Errorf("expected POST, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoDelete(t *testing.T) {
|
||||
var requestMethod string
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
requestMethod = r.Method
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"status": float64(0),
|
||||
"message": "success",
|
||||
})
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "delete", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
if requestMethod != "DELETE" {
|
||||
t.Errorf("expected DELETE, got %s", requestMethod)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
package snippet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/snippet"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// testStorePath overrides the snippet store file path. Empty means use default.
|
||||
// This variable exists for testing only.
|
||||
var testStorePath string
|
||||
|
||||
func getStore() *snippet.SnippetStore {
|
||||
if testStorePath != "" {
|
||||
return snippet.NewSnippetStoreWithPath(testStorePath)
|
||||
}
|
||||
return snippet.NewSnippetStore()
|
||||
}
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
{
|
||||
Name: "create",
|
||||
Description: "Create a new code snippet",
|
||||
Flags: []common.Flag{
|
||||
{Name: "title", Short: "t", Usage: "Snippet title", Required: true},
|
||||
{Name: "language", Short: "l", Usage: "Programming language"},
|
||||
{Name: "tags", Short: "g", Usage: "Tags (comma-separated)"},
|
||||
{Name: "content", Short: "c", Usage: "Snippet content (- for stdin)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
title, err := ctx.RequireArg("title")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := readContent(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
s := snippet.Snippet{
|
||||
ID: snippet.GenerateID(),
|
||||
Title: title,
|
||||
Language: ctx.Arg("language"),
|
||||
Tags: parseTags(ctx.Arg("tags")),
|
||||
Content: content,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
store := getStore()
|
||||
snippets, err := store.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取代码片段失败: %w", err)
|
||||
}
|
||||
snippets = append(snippets, s)
|
||||
if err := store.Save(snippets); err != nil {
|
||||
return fmt.Errorf("保存代码片段失败: %w", err)
|
||||
}
|
||||
return ctx.OutputData(s)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List all saved code snippets",
|
||||
Flags: []common.Flag{
|
||||
{Name: "tag", Short: "t", Usage: "Filter by tag"},
|
||||
{Name: "language", Short: "l", Usage: "Filter by language"},
|
||||
{Name: "keyword", Short: "k", Usage: "Filter by keyword in title"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
store := getStore()
|
||||
snippets, err := store.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取代码片段失败: %w", err)
|
||||
}
|
||||
|
||||
filtered := filterSnippets(snippets, ctx)
|
||||
|
||||
var summaries []map[string]interface{}
|
||||
for _, s := range filtered {
|
||||
summaries = append(summaries, toSummary(s))
|
||||
}
|
||||
if summaries == nil {
|
||||
summaries = []map[string]interface{}{}
|
||||
}
|
||||
return ctx.OutputData(summaries)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "view",
|
||||
Description: "View a saved code snippet",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Snippet ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store := getStore()
|
||||
snippets, err := store.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取代码片段失败: %w", err)
|
||||
}
|
||||
s, _ := findByID(snippets, id)
|
||||
if s == nil {
|
||||
return fmt.Errorf("代码片段 %s 不存在", id)
|
||||
}
|
||||
return ctx.OutputData(s)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search",
|
||||
Description: "Full-text search across snippets",
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Short: "q", Usage: "Search query", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
query, err := ctx.RequireArg("query")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store := getStore()
|
||||
snippets, err := store.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取代码片段失败: %w", err)
|
||||
}
|
||||
|
||||
lowerQuery := strings.ToLower(query)
|
||||
var results []map[string]interface{}
|
||||
for _, s := range snippets {
|
||||
if matchesQuery(s, lowerQuery) {
|
||||
results = append(results, toSummary(s))
|
||||
}
|
||||
}
|
||||
if results == nil {
|
||||
results = []map[string]interface{}{}
|
||||
}
|
||||
return ctx.OutputData(results)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update an existing code snippet",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Snippet ID", Required: true},
|
||||
{Name: "title", Short: "t", Usage: "New title"},
|
||||
{Name: "language", Short: "l", Usage: "New language"},
|
||||
{Name: "tags", Short: "g", Usage: "New tags (comma-separated)"},
|
||||
{Name: "content", Short: "c", Usage: "New content"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title := ctx.Arg("title")
|
||||
language := ctx.Arg("language")
|
||||
tags := ctx.Arg("tags")
|
||||
content := ctx.Arg("content")
|
||||
if title == "" && language == "" && tags == "" && content == "" {
|
||||
return fmt.Errorf("至少需要指定 --title、--language、--tags 或 --content 之一")
|
||||
}
|
||||
|
||||
store := getStore()
|
||||
snippets, err := store.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取代码片段失败: %w", err)
|
||||
}
|
||||
|
||||
s, idx := findByID(snippets, id)
|
||||
if s == nil {
|
||||
return fmt.Errorf("代码片段 %s 不存在", id)
|
||||
}
|
||||
|
||||
if title != "" {
|
||||
s.Title = title
|
||||
}
|
||||
if language != "" {
|
||||
s.Language = language
|
||||
}
|
||||
if tags != "" {
|
||||
s.Tags = parseTags(tags)
|
||||
}
|
||||
if content != "" {
|
||||
s.Content = content
|
||||
}
|
||||
s.UpdatedAt = time.Now()
|
||||
snippets[idx] = *s
|
||||
|
||||
if err := store.Save(snippets); err != nil {
|
||||
return fmt.Errorf("保存代码片段失败: %w", err)
|
||||
}
|
||||
return ctx.OutputData(s)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a saved code snippet",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Snippet ID", Required: true},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store := getStore()
|
||||
snippets, err := store.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取代码片段失败: %w", err)
|
||||
}
|
||||
|
||||
_, idx := findByID(snippets, id)
|
||||
if idx == -1 {
|
||||
return fmt.Errorf("代码片段 %s 不存在", id)
|
||||
}
|
||||
|
||||
remaining := make([]snippet.Snippet, 0, len(snippets)-1)
|
||||
remaining = append(remaining, snippets[:idx]...)
|
||||
remaining = append(remaining, snippets[idx+1:]...)
|
||||
|
||||
if err := store.Save(remaining); err != nil {
|
||||
return fmt.Errorf("保存代码片段失败: %w", err)
|
||||
}
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"message": "代码片段已删除",
|
||||
"id": id,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "export",
|
||||
Description: "Export a snippet to a file",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Snippet ID", Required: true},
|
||||
{Name: "output", Short: "o", Usage: "Output file path (default: stdout)"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store := getStore()
|
||||
snippets, err := store.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取代码片段失败: %w", err)
|
||||
}
|
||||
|
||||
s, _ := findByID(snippets, id)
|
||||
if s == nil {
|
||||
return fmt.Errorf("代码片段 %s 不存在", id)
|
||||
}
|
||||
|
||||
outputPath := ctx.Arg("output")
|
||||
if outputPath != "" {
|
||||
if err := os.WriteFile(outputPath, []byte(s.Content), 0o644); err != nil {
|
||||
return fmt.Errorf("导出文件失败: %w", err)
|
||||
}
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"message": "导出成功",
|
||||
"file": outputPath,
|
||||
"id": id,
|
||||
})
|
||||
}
|
||||
// No output file — print content to stdout
|
||||
fmt.Fprint(os.Stdout, s.Content)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
func findByID(snippets []snippet.Snippet, id string) (*snippet.Snippet, int) {
|
||||
for i, s := range snippets {
|
||||
if s.ID == id {
|
||||
return &snippets[i], i
|
||||
}
|
||||
}
|
||||
return nil, -1
|
||||
}
|
||||
|
||||
func toSummary(s snippet.Snippet) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": s.ID,
|
||||
"title": s.Title,
|
||||
"language": s.Language,
|
||||
"tags": s.Tags,
|
||||
"updated_at": s.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func parseTags(raw string) []string {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var tags []string
|
||||
for _, t := range strings.Split(raw, ",") {
|
||||
t = strings.TrimSpace(t)
|
||||
if t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func readContent(ctx *common.RuntimeContext) (string, error) {
|
||||
content := ctx.Arg("content")
|
||||
if content == "-" {
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取标准输入失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
if content == "" {
|
||||
// Check if stdin has data (piped)
|
||||
info, err := os.Stdin.Stat()
|
||||
if err == nil && info.Mode()&os.ModeCharDevice == 0 {
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取标准输入失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func filterSnippets(snippets []snippet.Snippet, ctx *common.RuntimeContext) []snippet.Snippet {
|
||||
tag := ctx.Arg("tag")
|
||||
lang := ctx.Arg("language")
|
||||
keyword := ctx.Arg("keyword")
|
||||
|
||||
var filtered []snippet.Snippet
|
||||
for _, s := range snippets {
|
||||
if tag != "" && !hasTag(s, tag) {
|
||||
continue
|
||||
}
|
||||
if lang != "" && !strings.EqualFold(s.Language, lang) {
|
||||
continue
|
||||
}
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(s.Title), strings.ToLower(keyword)) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func hasTag(s snippet.Snippet, tag string) bool {
|
||||
lower := strings.ToLower(tag)
|
||||
for _, t := range s.Tags {
|
||||
if strings.ToLower(t) == lower {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchesQuery(s snippet.Snippet, lowerQuery string) bool {
|
||||
if strings.Contains(strings.ToLower(s.Title), lowerQuery) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(s.Language), lowerQuery) {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(strings.ToLower(s.Content), lowerQuery) {
|
||||
return true
|
||||
}
|
||||
for _, t := range s.Tags {
|
||||
if strings.Contains(strings.ToLower(t), lowerQuery) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensure output package is referenced (used in export stdout fallback)
|
||||
var _ = (*output.Envelope)(nil)
|
||||
|
|
@ -0,0 +1,284 @@
|
|||
package snippet
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/snippet"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// setupTestStore creates a temp dir and overrides the package-level testStorePath.
|
||||
// Returns a cleanup function to restore the original value.
|
||||
func setupTestStore(t *testing.T) (storePath string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
storePath = filepath.Join(dir, "snippets.json")
|
||||
original := testStorePath
|
||||
testStorePath = storePath
|
||||
t.Cleanup(func() { testStorePath = original })
|
||||
return storePath
|
||||
}
|
||||
|
||||
func newCtx(args map[string]string) *common.RuntimeContext {
|
||||
return &common.RuntimeContext{
|
||||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create tests ---
|
||||
|
||||
func TestSnippetCreate(t *testing.T) {
|
||||
storePath := setupTestStore(t)
|
||||
|
||||
ctx := newCtx(map[string]string{
|
||||
"title": "Hello World",
|
||||
"language": "go",
|
||||
"tags": "test,example",
|
||||
"content": `fmt.Println("hello")`,
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "create", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
store := snippet.NewSnippetStoreWithPath(storePath)
|
||||
snippets, _ := store.Load()
|
||||
if len(snippets) != 1 {
|
||||
t.Fatalf("expected 1 snippet, got %d", len(snippets))
|
||||
}
|
||||
if snippets[0].Title != "Hello World" {
|
||||
t.Errorf("title mismatch: got %s", snippets[0].Title)
|
||||
}
|
||||
if snippets[0].Language != "go" {
|
||||
t.Errorf("language mismatch: got %s", snippets[0].Language)
|
||||
}
|
||||
if len(snippets[0].Tags) != 2 {
|
||||
t.Errorf("expected 2 tags, got %d", len(snippets[0].Tags))
|
||||
}
|
||||
if snippets[0].Content != `fmt.Println("hello")` {
|
||||
t.Errorf("content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetCreateRequiresTitle(t *testing.T) {
|
||||
setupTestStore(t)
|
||||
ctx := newCtx(map[string]string{
|
||||
"content": "some code",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "create", ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --title")
|
||||
}
|
||||
}
|
||||
|
||||
// --- List tests ---
|
||||
|
||||
func TestSnippetList(t *testing.T) {
|
||||
storePath := setupTestStore(t)
|
||||
|
||||
// Pre-populate
|
||||
store := snippet.NewSnippetStoreWithPath(storePath)
|
||||
store.Save([]snippet.Snippet{
|
||||
{ID: "a1", Title: "Alpha", Language: "go", Tags: []string{"test"}},
|
||||
{ID: "b2", Title: "Beta", Language: "python", Tags: []string{"example"}},
|
||||
{ID: "c3", Title: "Gamma", Language: "go", Tags: []string{"test", "http"}},
|
||||
})
|
||||
|
||||
ctx := newCtx(map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "list", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetListFilterByTag(t *testing.T) {
|
||||
storePath := setupTestStore(t)
|
||||
store := snippet.NewSnippetStoreWithPath(storePath)
|
||||
store.Save([]snippet.Snippet{
|
||||
{ID: "a1", Title: "Alpha", Language: "go", Tags: []string{"test"}},
|
||||
{ID: "b2", Title: "Beta", Language: "python", Tags: []string{"example"}},
|
||||
{ID: "c3", Title: "Gamma", Language: "go", Tags: []string{"test", "http"}},
|
||||
})
|
||||
|
||||
ctx := newCtx(map[string]string{"tag": "test"})
|
||||
err := common.RunShortcut(t, Shortcuts(), "list", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list --tag test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetListFilterByLanguage(t *testing.T) {
|
||||
storePath := setupTestStore(t)
|
||||
store := snippet.NewSnippetStoreWithPath(storePath)
|
||||
store.Save([]snippet.Snippet{
|
||||
{ID: "a1", Title: "Alpha", Language: "go", Tags: []string{"test"}},
|
||||
{ID: "b2", Title: "Beta", Language: "python", Tags: []string{"example"}},
|
||||
})
|
||||
|
||||
ctx := newCtx(map[string]string{"language": "go"})
|
||||
err := common.RunShortcut(t, Shortcuts(), "list", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list --language go failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- View tests ---
|
||||
|
||||
func TestSnippetView(t *testing.T) {
|
||||
storePath := setupTestStore(t)
|
||||
store := snippet.NewSnippetStoreWithPath(storePath)
|
||||
store.Save([]snippet.Snippet{
|
||||
{ID: "abc12345", Title: "Hello", Language: "go", Content: "code"},
|
||||
})
|
||||
|
||||
ctx := newCtx(map[string]string{"id": "abc12345"})
|
||||
err := common.RunShortcut(t, Shortcuts(), "view", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetViewNotFound(t *testing.T) {
|
||||
setupTestStore(t)
|
||||
ctx := newCtx(map[string]string{"id": "nonexistent"})
|
||||
err := common.RunShortcut(t, Shortcuts(), "view", ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent ID")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Search tests ---
|
||||
|
||||
func TestSnippetSearch(t *testing.T) {
|
||||
storePath := setupTestStore(t)
|
||||
store := snippet.NewSnippetStoreWithPath(storePath)
|
||||
store.Save([]snippet.Snippet{
|
||||
{ID: "a1", Title: "HTTP Handler", Language: "go", Content: "func handler()"},
|
||||
{ID: "b2", Title: "Sort Algorithm", Language: "python", Content: "def sort(arr)"},
|
||||
})
|
||||
|
||||
ctx := newCtx(map[string]string{"query": "handler"})
|
||||
err := common.RunShortcut(t, Shortcuts(), "search", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("search failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update tests ---
|
||||
|
||||
func TestSnippetUpdate(t *testing.T) {
|
||||
storePath := setupTestStore(t)
|
||||
store := snippet.NewSnippetStoreWithPath(storePath)
|
||||
store.Save([]snippet.Snippet{
|
||||
{ID: "abc12345", Title: "Old Title", Language: "go", Tags: []string{"old"}, Content: "old code"},
|
||||
})
|
||||
|
||||
ctx := newCtx(map[string]string{
|
||||
"id": "abc12345",
|
||||
"title": "New Title",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "update", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, _ := store.Load()
|
||||
if loaded[0].Title != "New Title" {
|
||||
t.Errorf("title not updated: got %s", loaded[0].Title)
|
||||
}
|
||||
if loaded[0].Content != "old code" {
|
||||
t.Errorf("content should not change: got %s", loaded[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetUpdateRequiresField(t *testing.T) {
|
||||
setupTestStore(t)
|
||||
ctx := newCtx(map[string]string{"id": "abc12345"})
|
||||
err := common.RunShortcut(t, Shortcuts(), "update", ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no fields provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetUpdateNotFound(t *testing.T) {
|
||||
setupTestStore(t)
|
||||
ctx := newCtx(map[string]string{"id": "nonexistent", "title": "X"})
|
||||
err := common.RunShortcut(t, Shortcuts(), "update", ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent ID")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete tests ---
|
||||
|
||||
func TestSnippetDelete(t *testing.T) {
|
||||
storePath := setupTestStore(t)
|
||||
store := snippet.NewSnippetStoreWithPath(storePath)
|
||||
store.Save([]snippet.Snippet{
|
||||
{ID: "a1", Title: "Keep"},
|
||||
{ID: "b2", Title: "Delete Me"},
|
||||
})
|
||||
|
||||
ctx := newCtx(map[string]string{"id": "b2"})
|
||||
err := common.RunShortcut(t, Shortcuts(), "delete", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, _ := store.Load()
|
||||
if len(loaded) != 1 {
|
||||
t.Fatalf("expected 1 snippet after delete, got %d", len(loaded))
|
||||
}
|
||||
if loaded[0].ID != "a1" {
|
||||
t.Errorf("wrong snippet remained: got %s", loaded[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetDeleteNotFound(t *testing.T) {
|
||||
setupTestStore(t)
|
||||
ctx := newCtx(map[string]string{"id": "nonexistent"})
|
||||
err := common.RunShortcut(t, Shortcuts(), "delete", ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent ID")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Export tests ---
|
||||
|
||||
func TestSnippetExportToFile(t *testing.T) {
|
||||
storePath := setupTestStore(t)
|
||||
store := snippet.NewSnippetStoreWithPath(storePath)
|
||||
store.Save([]snippet.Snippet{
|
||||
{ID: "abc12345", Title: "Hello", Content: "package main\nfunc main() {}"},
|
||||
})
|
||||
|
||||
outFile := filepath.Join(t.TempDir(), "main.go")
|
||||
ctx := newCtx(map[string]string{
|
||||
"id": "abc12345",
|
||||
"output": outFile,
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "export", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("export failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read exported file: %v", err)
|
||||
}
|
||||
if string(data) != "package main\nfunc main() {}" {
|
||||
t.Errorf("export content mismatch: got %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetExportNotFound(t *testing.T) {
|
||||
setupTestStore(t)
|
||||
ctx := newCtx(map[string]string{"id": "nonexistent"})
|
||||
err := common.RunShortcut(t, Shortcuts(), "export", ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent ID")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestUserMe(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/users/me.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"login": "alice",
|
||||
"user_id": float64(42),
|
||||
"name": "Alice",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "me", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("me failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInfo(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/users/bob.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"login": "bob",
|
||||
"user_id": float64(7),
|
||||
"name": "Bob",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{
|
||||
"login": "bob",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "info", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("info failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserInfoMissingLogin(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("no request should be made: %s %s", r.Method, r.URL.Path)
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "", "", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "info", ctx)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --login")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package webhook
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -13,11 +14,18 @@ func Shortcuts() []*common.Shortcut {
|
|||
{
|
||||
Name: "list",
|
||||
Description: "List webhooks",
|
||||
Flags: []common.Flag{
|
||||
{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
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", v1Path(ctx)+"/webhooks", nil)
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", v1Path(ctx)+"/webhooks", q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -46,6 +54,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
body := map[string]interface{}{
|
||||
"url": webhookURL,
|
||||
"content_type": ctx.Arg("content-type"),
|
||||
"http_method": "POST",
|
||||
"active": true,
|
||||
}
|
||||
if secret := ctx.Arg("secret"); secret != "" {
|
||||
|
|
@ -66,6 +75,111 @@ func Shortcuts() []*common.Shortcut {
|
|||
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 err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", v1Path(ctx), id), nil)
|
||||
if err != nil {
|
||||
return 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: "Webhook payload URL"},
|
||||
{Name: "content-type", Usage: "Content type: json, form", Default: "json"},
|
||||
{Name: "secret", Short: "s", Usage: "Webhook secret"},
|
||||
{Name: "events", Short: "e", Usage: "Comma-separated events (push,issues,pull_request,etc)"},
|
||||
{Name: "branch-filter", Usage: "Branch filter pattern"},
|
||||
{Name: "active", Usage: "Whether the webhook is active", Default: "true"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"content_type": ctx.Arg("content-type"),
|
||||
"http_method": "POST",
|
||||
"active": true,
|
||||
"branch_filter": ctx.Arg("branch-filter"),
|
||||
"secret": ctx.Arg("secret"),
|
||||
}
|
||||
if webhookURL := ctx.Arg("url"); webhookURL != "" {
|
||||
body["url"] = webhookURL
|
||||
}
|
||||
if events := ctx.Arg("events"); events != "" {
|
||||
body["events"] = strings.Split(events, ",")
|
||||
} else {
|
||||
body["events"] = []string{"push"}
|
||||
}
|
||||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", v1Path(ctx), id), body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "history",
|
||||
Description: "List webhook delivery history",
|
||||
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 err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s/hooktasks", v1Path(ctx), id), nil)
|
||||
if err != nil {
|
||||
return 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 err
|
||||
}
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", v1Path(ctx), id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a webhook",
|
||||
|
|
|
|||
|
|
@ -81,3 +81,105 @@ func TestWebhookDelete(t *testing.T) {
|
|||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebView(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/1.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"url": "https://example.com/hook",
|
||||
"active": true,
|
||||
"content_type": "json",
|
||||
"events": []string{"push"},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"id": "1",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "view", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebUpdate(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/webhooks/1.json" {
|
||||
updatePayload = common.DecodeJSON(t, r)
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(1),
|
||||
"url": "https://example.com/updated",
|
||||
"message": "更新成功",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"id": "1",
|
||||
"url": "https://example.com/updated",
|
||||
"events": "push,issues",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "update", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
|
||||
common.AssertEqual(t, updatePayload["url"], "https://example.com/updated")
|
||||
common.AssertEqual(t, updatePayload["http_method"], "POST")
|
||||
}
|
||||
|
||||
func TestWebHistory(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/1/hooktasks.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"total_count": 2,
|
||||
"hooktasks": []interface{}{
|
||||
map[string]interface{}{"id": float64(10), "status": "succeeded"},
|
||||
map[string]interface{}{"id": float64(11), "status": "failed"},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"id": "1",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "history", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("history failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebTest(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks/1/tests.json" {
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"status": 0,
|
||||
"message": "success",
|
||||
})
|
||||
} else {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"id": "1",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "test", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("test failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||
"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", strconv.Itoa(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", strconv.Itoa(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",
|
||||
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
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"owner": ctx.Owner,
|
||||
"repo": ctx.Repo,
|
||||
"projectId": projectID,
|
||||
"pageName": name,
|
||||
}
|
||||
return callWikiAPI(ctx, "DELETE", "/wiki/open/deleteWiki", body, nil)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// callWikiAPI temporarily switches the client BaseURL to the wiki gateway.
|
||||
// In test mode (BaseURL is a local httptest server), the switch is skipped.
|
||||
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") {
|
||||
ctx.Client.BaseURL = wikiBaseURL
|
||||
}
|
||||
defer func() { ctx.Client.BaseURL = origBase }()
|
||||
|
||||
var env *output.Envelope
|
||||
var err error
|
||||
if query != nil {
|
||||
env, err = ctx.CallAPIRawWithQuery(method, path, query)
|
||||
} else {
|
||||
env, err = ctx.CallAPIRaw(method, path, body)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
}
|
||||
|
||||
func fetchProjectID(ctx *common.RuntimeContext) (int, error) {
|
||||
env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("获取项目信息失败: %w", err)
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("无法解析项目信息")
|
||||
}
|
||||
if idFloat, ok := data["project_id"].(float64); ok {
|
||||
return int(idFloat), nil
|
||||
}
|
||||
if idFloat, ok := data["repo_id"].(float64); ok {
|
||||
return int(idFloat), nil
|
||||
}
|
||||
if idFloat, ok := data["id"].(float64); ok {
|
||||
return int(idFloat), nil
|
||||
}
|
||||
return 0, fmt.Errorf("项目 ID 未找到,请确认仓库是否存在")
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestWikiList(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(123),
|
||||
"name": "repo",
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/wiki/open/wikiPages":
|
||||
common.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: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{})
|
||||
err := common.RunShortcut(t, Shortcuts(), "list", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiView(t *testing.T) {
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(123),
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/wiki/open/getWiki":
|
||||
pageName := r.URL.Query().Get("pageName")
|
||||
if pageName != "Home" {
|
||||
t.Fatalf("expected pageName=Home, got %s", pageName)
|
||||
}
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"title": "Home",
|
||||
"content_base64": base64.StdEncoding.EncodeToString([]byte("Welcome")),
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"name": "Home",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "view", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("view failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiCreate(t *testing.T) {
|
||||
var createPayload map[string]interface{}
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(123),
|
||||
})
|
||||
case r.Method == "POST" && r.URL.Path == "/wiki/open/createWiki":
|
||||
createPayload = common.DecodeJSON(t, r)
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"code": 201,
|
||||
"data": map[string]interface{}{"title": "NewPage"},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"name": "NewPage",
|
||||
"content": "Hello Wiki",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "create", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
|
||||
common.AssertEqual(t, createPayload["pageName"], "NewPage")
|
||||
common.AssertEqual(t, createPayload["owner"], "owner")
|
||||
common.AssertEqual(t, createPayload["repo"], "repo")
|
||||
|
||||
expectedContent := base64.StdEncoding.EncodeToString([]byte("Hello Wiki"))
|
||||
common.AssertEqual(t, createPayload["content_base64"], expectedContent)
|
||||
}
|
||||
|
||||
func TestWikiUpdate(t *testing.T) {
|
||||
var updatePayload map[string]interface{}
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(123),
|
||||
})
|
||||
case r.Method == "PUT" && r.URL.Path == "/wiki/open/updateWiki":
|
||||
updatePayload = common.DecodeJSON(t, r)
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"code": 200,
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"name": "Home",
|
||||
"content": "Updated content",
|
||||
"message": "Update wiki page",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "update", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("update failed: %v", err)
|
||||
}
|
||||
|
||||
common.AssertEqual(t, updatePayload["pageName"], "Home")
|
||||
common.AssertEqual(t, updatePayload["message"], "Update wiki page")
|
||||
}
|
||||
|
||||
func TestWikiDelete(t *testing.T) {
|
||||
var deletePayload map[string]interface{}
|
||||
server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo.json":
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"id": float64(123),
|
||||
})
|
||||
case r.Method == "DELETE" && r.URL.Path == "/wiki/open/deleteWiki":
|
||||
deletePayload = common.DecodeJSON(t, r)
|
||||
common.WriteJSON(t, w, map[string]interface{}{
|
||||
"code": 204,
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{
|
||||
"name": "OldPage",
|
||||
})
|
||||
err := common.RunShortcut(t, Shortcuts(), "delete", ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("delete failed: %v", err)
|
||||
}
|
||||
|
||||
common.AssertEqual(t, deletePayload["pageName"], "OldPage")
|
||||
common.AssertEqual(t, deletePayload["projectId"], float64(123))
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ metadata:
|
|||
| `issue +update` | 更新 Issue | 是 |
|
||||
| `issue +close` | 关闭 Issue | 是 |
|
||||
| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) |
|
||||
| `issue +batch-update` | 批量更新 Issue(状态/优先级/里程碑/标签/负责人) | 是 |
|
||||
| `issue +batch-delete` | 批量删除 Issue(需 `--confirm` 确认) | 是 |
|
||||
| `issue +comment` | 添加评论 | 是 |
|
||||
|
||||
## 使用示例
|
||||
|
|
@ -52,6 +54,18 @@ gitlink-cli issue +batch-close --owner myuser --repo myrepo --numbers 123,124 --
|
|||
# 从 CSV 文件批量关闭 Issue
|
||||
gitlink-cli issue +batch-close --owner myuser --repo myrepo --from issues.csv
|
||||
|
||||
# 预览批量更新 Issue
|
||||
gitlink-cli issue +batch-update --ids 10,20,30 --status closed --dry-run
|
||||
|
||||
# 批量更新 Issue(状态 + 里程碑 + 负责人)
|
||||
gitlink-cli issue +batch-update --ids 10,20,30 --status closed --milestone 5 --assignees 100
|
||||
|
||||
# 预览批量删除 Issue
|
||||
gitlink-cli issue +batch-delete --ids 10,20,30 --dry-run
|
||||
|
||||
# 确认批量删除 Issue
|
||||
gitlink-cli issue +batch-delete --ids 10,20,30 --confirm
|
||||
|
||||
# 添加评论
|
||||
gitlink-cli issue +comment --number 4 --body "已修复,请验证"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
# issue +batch-delete
|
||||
|
||||
批量删除多个 Issue。这是**危险操作**,必须使用 `--confirm` 确认。
|
||||
|
||||
## 使用方法
|
||||
|
||||
```bash
|
||||
# 预览删除(不实际删除)
|
||||
gitlink-cli issue +batch-delete --ids 10,20,30 --dry-run
|
||||
|
||||
# 确认删除
|
||||
gitlink-cli issue +batch-delete --ids 10,20,30 --confirm
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 短选项 | 必需 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| `--ids` | `-i` | 是 | 逗号分隔的 Issue ID 列表 |
|
||||
| `--dry-run` | | 否 | 仅预览,不实际删除 |
|
||||
| `--confirm` | | 否 | 确认执行删除(必须提供此标志才会执行) |
|
||||
|
||||
## API 端点
|
||||
|
||||
`DELETE /api/v1/{owner}/{repo}/issues/batch_destroy.json`
|
||||
|
||||
Body: `{"ids": [10, 20, 30]}`
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# issue +batch-update
|
||||
|
||||
批量更新多个 Issue 的状态、优先级、里程碑、标签和负责人。使用服务端批量 API,一次调用处理所有 Issue。
|
||||
|
||||
## 使用方法
|
||||
|
||||
```bash
|
||||
# 预览变更(不实际修改)
|
||||
gitlink-cli issue +batch-update --ids 10,20,30 --status closed --dry-run
|
||||
|
||||
# 批量更新状态
|
||||
gitlink-cli issue +batch-update --ids 10,20,30 --status closed
|
||||
|
||||
# 批量更新多个字段
|
||||
gitlink-cli issue +batch-update --ids 10,20,30 --status closed --milestone 5 --assignees 100,200
|
||||
|
||||
# 批量更新标签
|
||||
gitlink-cli issue +batch-update --ids 10,20,30 --labels 1,2,3
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 短选项 | 必需 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| `--ids` | `-i` | 是 | 逗号分隔的 Issue ID 列表 |
|
||||
| `--status` | `-s` | 否 | 新状态: open 或 closed |
|
||||
| `--priority` | `-p` | 否 | 优先级 ID |
|
||||
| `--milestone` | `-m` | 否 | 里程碑 ID |
|
||||
| `--labels` | `-l` | 否 | 逗号分隔的标签 ID |
|
||||
| `--assignees` | `-a` | 否 | 逗号分隔的负责人用户 ID |
|
||||
| `--dry-run` | | 否 | 仅预览,不实际修改 |
|
||||
|
||||
## API 端点
|
||||
|
||||
`PATCH /api/v1/{owner}/{repo}/issues/batch_update.json`
|
||||
|
||||
Body: `{"ids": [10, 20], "status_id": 5, "milestone_id": 3, ...}`
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: gitlink-pm
|
||||
version: 1.0.0
|
||||
version: 1.1.0
|
||||
description: "项目管理(PM):Sprint、看板、周报等项目管理功能。当用户需要使用 GitLink PM 功能时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
|
|
@ -14,11 +14,44 @@ metadata:
|
|||
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
GitLink PM 模块提供敏捷项目管理能力,目前通过 Raw API 访问。
|
||||
GitLink PM 模块提供敏捷项目管理能力,包括看板、Sprint、周报等功能。
|
||||
|
||||
## API 端点
|
||||
## Shortcuts
|
||||
|
||||
> 前缀:`/api/pm`
|
||||
| 命令 | 说明 | 认证 |
|
||||
|------|------|------|
|
||||
| `pm +boards` | 查看看板 | Token |
|
||||
| `pm +sprints` | Sprint Issue 列表 | Token |
|
||||
| `pm +weekly` | 周报 | Token |
|
||||
| `pm +tags` | PM Issue 标签 | Token |
|
||||
| `pm +pipelines` | PM 流水线 | Token |
|
||||
| `pm +actions` | Action 运行记录 | Token |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 查看项目看板
|
||||
gitlink-cli pm +boards
|
||||
|
||||
# 查看 Sprint Issue 列表
|
||||
gitlink-cli pm +sprints
|
||||
|
||||
# 查看周报
|
||||
gitlink-cli pm +weekly
|
||||
|
||||
# 查看 PM Issue 标签
|
||||
gitlink-cli pm +tags
|
||||
|
||||
# 查看 PM 流水线
|
||||
gitlink-cli pm +pipelines
|
||||
|
||||
# 查看 Action 运行记录(分页)
|
||||
gitlink-cli pm +actions --page 2 --limit 10
|
||||
```
|
||||
|
||||
## Raw API
|
||||
|
||||
如需更灵活的访问,可直接调用 Raw API:
|
||||
|
||||
```bash
|
||||
# 看板
|
||||
|
|
@ -42,5 +75,7 @@ gitlink-cli api GET /pm/action_runs --query 'project_id=123'
|
|||
|
||||
## 注意事项
|
||||
|
||||
- PM 接口需要项目 ID(`project_id`),可通过 `repo +info` 获取
|
||||
- PM 功能需要项目开启 PM 模块
|
||||
- Shortcut 命令会自动从 git remote 解析 owner/repo 并获取 project_id
|
||||
- 所有 PM 端点均为只读 GET 请求
|
||||
- 如遇到 404 错误,请确认项目已启用 PM 模块
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: gitlink-webhook
|
||||
version: 1.0.0
|
||||
description: "Webhook 管理:列出、创建、删除 Webhook。当用户需要管理 GitLink 项目 Webhook 时触发。"
|
||||
version: 1.1.0
|
||||
description: "Webhook 管理:列出、查看、创建、更新、删除、测试 Webhook。当用户需要管理 GitLink 项目 Webhook 时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
|
|
@ -18,8 +18,12 @@ metadata:
|
|||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| `webhook +list` | 列出 Webhook |
|
||||
| `webhook +view` | 查看 Webhook 详情 |
|
||||
| `webhook +create` | 创建 Webhook |
|
||||
| `webhook +update` | 更新 Webhook 配置 |
|
||||
| `webhook +delete` | 删除 Webhook |
|
||||
| `webhook +history` | 查看 Webhook 推送历史 |
|
||||
| `webhook +test` | 测试 Webhook 推送 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
|
|
@ -27,16 +31,26 @@ metadata:
|
|||
# 列出 Webhook
|
||||
gitlink-cli webhook +list --owner myuser --repo myrepo
|
||||
|
||||
# 查看 Webhook 详情
|
||||
gitlink-cli webhook +view --owner myuser --repo myrepo --id 1
|
||||
|
||||
# 创建 Webhook
|
||||
gitlink-cli webhook +create --owner myuser --repo myrepo \
|
||||
--url https://example.com/webhook \
|
||||
--events push,issues \
|
||||
--secret my-secret-key
|
||||
|
||||
# 创建仅监听 push 事件的 Webhook
|
||||
gitlink-cli webhook +create --owner myuser --repo myrepo \
|
||||
--url https://example.com/push-hook \
|
||||
--events push
|
||||
# 更新 Webhook URL 和事件
|
||||
gitlink-cli webhook +update --owner myuser --repo myrepo \
|
||||
--id 1 \
|
||||
--url https://example.com/new-hook \
|
||||
--events push,issues,pull_request
|
||||
|
||||
# 查看推送历史
|
||||
gitlink-cli webhook +history --owner myuser --repo myrepo --id 1
|
||||
|
||||
# 测试推送
|
||||
gitlink-cli webhook +test --owner myuser --repo myrepo --id 1
|
||||
|
||||
# 删除 Webhook
|
||||
gitlink-cli webhook +delete --owner myuser --repo myrepo --id 1
|
||||
|
|
@ -45,6 +59,8 @@ gitlink-cli webhook +delete --owner myuser --repo myrepo --id 1
|
|||
## API 注意事项
|
||||
|
||||
- Webhook 使用 v1 API:`/v1/{owner}/{repo}/webhooks`
|
||||
- 创建 Webhook 时 `--events` 为逗号分隔的事件列表,支持:push, issues, pull_request 等
|
||||
- 创建 Webhook 时 `--events` 为逗号分隔的事件列表,支持:push, issues, pull_request, create, delete 等
|
||||
- 不指定 `--events` 时默认监听 push 事件
|
||||
- `--content-type` 默认为 json,可选 form
|
||||
- `+test` 命令会实际触发一次 Webhook 推送,请谨慎使用
|
||||
- `+history` 返回 Webhook 的推送记录,包含每次推送的状态和响应
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
name: gitlink-wiki
|
||||
version: 1.0.0
|
||||
description: "Wiki 操作:查看、创建、更新、删除 Wiki 页面。当用户需要管理 GitLink 仓库 Wiki 时触发。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["gitlink-cli"]
|
||||
cliHelp: "gitlink-cli wiki --help"
|
||||
---
|
||||
|
||||
# gitlink-wiki(Wiki 操作)
|
||||
|
||||
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
|
||||
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
|
||||
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
|
||||
|
||||
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
|
||||
|
||||
## Shortcuts
|
||||
|
||||
| Shortcut | 说明 | 需要认证 |
|
||||
|----------|------|----------|
|
||||
| `wiki +list` | 列出 Wiki 页面 | 是 |
|
||||
| `wiki +view` | 查看 Wiki 页面内容 | 是 |
|
||||
| `wiki +create` | 创建 Wiki 页面 | 是 |
|
||||
| `wiki +update` | 更新 Wiki 页面 | 是 |
|
||||
| `wiki +delete` | 删除 Wiki 页面 | 是 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```bash
|
||||
# 列出所有 Wiki 页面
|
||||
gitlink-cli wiki +list --owner myuser --repo myrepo
|
||||
|
||||
# 查看 Wiki 页面
|
||||
gitlink-cli wiki +view --name Home
|
||||
|
||||
# 创建 Wiki 页面
|
||||
gitlink-cli wiki +create --name Guide --content "使用指南内容"
|
||||
|
||||
# 更新 Wiki 页面(带提交信息)
|
||||
gitlink-cli wiki +update --name Guide --content "更新后的内容" --message "更新使用指南"
|
||||
|
||||
# 删除 Wiki 页面
|
||||
gitlink-cli wiki +delete --name OldPage
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- Wiki 命令会自动从仓库信息中获取 `projectId`,无需手动指定
|
||||
- `--content` 参数的内容会自动进行 base64 编码
|
||||
- 在 git 仓库目录下执行时,`--owner` 和 `--repo` 会自动解析
|
||||
Loading…
Reference in New Issue