diff --git a/doc/progress_人员A.md b/doc/progress_人员A.md new file mode 100644 index 0000000..46e819d --- /dev/null +++ b/doc/progress_人员A.md @@ -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 文档 | diff --git a/internal/client/client.go b/internal/client/client.go index d147d96..c51602a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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{} diff --git a/internal/snippet/store.go b/internal/snippet/store.go new file mode 100644 index 0000000..bb34e96 --- /dev/null +++ b/internal/snippet/store.go @@ -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) +} diff --git a/internal/snippet/store_test.go b/internal/snippet/store_test.go new file mode 100644 index 0000000..206c9f4 --- /dev/null +++ b/internal/snippet/store_test.go @@ -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)) + } +} diff --git a/shortcuts/branch/branch_test.go b/shortcuts/branch/branch_test.go new file mode 100644 index 0000000..6f0e172 --- /dev/null +++ b/shortcuts/branch/branch_test.go @@ -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) + } +} diff --git a/shortcuts/ci/ci_test.go b/shortcuts/ci/ci_test.go new file mode 100644 index 0000000..fce1649 --- /dev/null +++ b/shortcuts/ci/ci_test.go @@ -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) + } +} diff --git a/shortcuts/common/testutil.go b/shortcuts/common/testutil.go index 695b50a..600d9b2 100644 --- a/shortcuts/common/testutil.go +++ b/shortcuts/common/testutil.go @@ -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() diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 15441c9..4569579 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -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 } diff --git a/shortcuts/file/file.go b/shortcuts/file/file.go index a499616..8f146d6 100644 --- a/shortcuts/file/file.go +++ b/shortcuts/file/file.go @@ -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 } diff --git a/shortcuts/issue/batch_delete.go b/shortcuts/issue/batch_delete.go new file mode 100644 index 0000000..7cdccda --- /dev/null +++ b/shortcuts/issue/batch_delete.go @@ -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 +} diff --git a/shortcuts/issue/batch_update_test.go b/shortcuts/issue/batch_update_test.go new file mode 100644 index 0000000..dc20566 --- /dev/null +++ b/shortcuts/issue/batch_update_test.go @@ -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)) + } +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 1beced9..549caf0 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -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) } } diff --git a/shortcuts/label/label.go b/shortcuts/label/label.go index 20df4d6..ba27143 100644 --- a/shortcuts/label/label.go +++ b/shortcuts/label/label.go @@ -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)", diff --git a/shortcuts/label/label_test.go b/shortcuts/label/label_test.go index b87266f..bb2964b 100644 --- a/shortcuts/label/label_test.go +++ b/shortcuts/label/label_test.go @@ -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") + } +} diff --git a/shortcuts/org/org_test.go b/shortcuts/org/org_test.go new file mode 100644 index 0000000..1101bc6 --- /dev/null +++ b/shortcuts/org/org_test.go @@ -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) + } +} diff --git a/shortcuts/pm/pm.go b/shortcuts/pm/pm.go new file mode 100644 index 0000000..0f42e1d --- /dev/null +++ b/shortcuts/pm/pm.go @@ -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 未找到,请确认仓库是否存在") +} diff --git a/shortcuts/pm/pm_test.go b/shortcuts/pm/pm_test.go new file mode 100644 index 0000000..18b498d --- /dev/null +++ b/shortcuts/pm/pm_test.go @@ -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) + } +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index d5c9747..0256180 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -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{}) diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index edefffb..268d486 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -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) } } diff --git a/shortcuts/register.go b/shortcuts/register.go index 13e86c3..83cc59a 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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 { diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 06240bc..c1eab3b 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -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, + }) }, }, } diff --git a/shortcuts/release/release_test.go b/shortcuts/release/release_test.go new file mode 100644 index 0000000..12ef671 --- /dev/null +++ b/shortcuts/release/release_test.go @@ -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) + } +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 75091a4..0a34dcb 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -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{}{ diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go new file mode 100644 index 0000000..005f8cf --- /dev/null +++ b/shortcuts/repo/repo_test.go @@ -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) + } +} diff --git a/shortcuts/snippet/snippet.go b/shortcuts/snippet/snippet.go new file mode 100644 index 0000000..717045b --- /dev/null +++ b/shortcuts/snippet/snippet.go @@ -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) diff --git a/shortcuts/snippet/snippet_test.go b/shortcuts/snippet/snippet_test.go new file mode 100644 index 0000000..8389bc5 --- /dev/null +++ b/shortcuts/snippet/snippet_test.go @@ -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") + } +} diff --git a/shortcuts/user/user_test.go b/shortcuts/user/user_test.go new file mode 100644 index 0000000..5edf666 --- /dev/null +++ b/shortcuts/user/user_test.go @@ -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") + } +} diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 87d4a01..f03d5db 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -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", diff --git a/shortcuts/webhook/webhook_test.go b/shortcuts/webhook/webhook_test.go index 8645626..8a3da01 100644 --- a/shortcuts/webhook/webhook_test.go +++ b/shortcuts/webhook/webhook_test.go @@ -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) + } +} diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go new file mode 100644 index 0000000..093a078 --- /dev/null +++ b/shortcuts/wiki/wiki.go @@ -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 未找到,请确认仓库是否存在") +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go new file mode 100644 index 0000000..8907282 --- /dev/null +++ b/shortcuts/wiki/wiki_test.go @@ -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)) +} diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 1938da6..2ebdec2 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -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 "已修复,请验证" ``` diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-delete.md b/skills/gitlink-issue/references/gitlink-issue-batch-delete.md new file mode 100644 index 0000000..4c6978b --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-batch-delete.md @@ -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]}` diff --git a/skills/gitlink-issue/references/gitlink-issue-batch-update.md b/skills/gitlink-issue/references/gitlink-issue-batch-update.md new file mode 100644 index 0000000..c9f54ce --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-batch-update.md @@ -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, ...}` diff --git a/skills/gitlink-pm/SKILL.md b/skills/gitlink-pm/SKILL.md index f5333ed..b6f5158 100644 --- a/skills/gitlink-pm/SKILL.md +++ b/skills/gitlink-pm/SKILL.md @@ -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 模块 diff --git a/skills/gitlink-webhook/SKILL.md b/skills/gitlink-webhook/SKILL.md index dff7cd8..b67f144 100644 --- a/skills/gitlink-webhook/SKILL.md +++ b/skills/gitlink-webhook/SKILL.md @@ -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 的推送记录,包含每次推送的状态和响应 diff --git a/skills/gitlink-wiki/SKILL.md b/skills/gitlink-wiki/SKILL.md new file mode 100644 index 0000000..c022e23 --- /dev/null +++ b/skills/gitlink-wiki/SKILL.md @@ -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` 会自动解析