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/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..6bccf36 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) diff --git a/shortcuts/issue/batch_update.go b/shortcuts/issue/batch_update.go new file mode 100644 index 0000000..1548cac --- /dev/null +++ b/shortcuts/issue/batch_update.go @@ -0,0 +1,196 @@ +package issue + +import ( + "fmt" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type batchUpdateResult struct { + Action string `json:"action"` + IDS []int `json:"ids"` + Status string `json:"status"` + Message string `json:"message"` +} + +func newBatchUpdateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-update", + Description: "Batch update multiple issues (status, priority, milestone, assignee, labels)", + Flags: []common.Flag{ + {Name: "ids", Short: "i", Usage: "Comma-separated issue IDs", Required: true}, + {Name: "status", Short: "s", Usage: "New status: open or closed"}, + {Name: "priority", Short: "p", Usage: "Priority ID"}, + {Name: "milestone", Short: "m", Usage: "Milestone ID"}, + {Name: "labels", Short: "l", Usage: "Comma-separated label/tag IDs"}, + {Name: "assignees", Short: "a", Usage: "Comma-separated assignee user IDs"}, + {Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"}, + }, + Run: runBatchUpdate, + } +} + +func runBatchUpdate(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 + } + + body := map[string]interface{}{ + "ids": ids, + } + + if s := ctx.Arg("status"); s != "" { + statusID, err := normalizeIssueStatus(s) + if err != nil { + return err + } + body["status_id"] = statusID + } + if p := ctx.Arg("priority"); p != "" { + pid, err := strconv.Atoi(p) + if err != nil { + return fmt.Errorf("无效的优先级 ID: %s", p) + } + body["priority_id"] = pid + } + if m := ctx.Arg("milestone"); m != "" { + mid, err := strconv.Atoi(m) + if err != nil { + return fmt.Errorf("无效的里程碑 ID: %s", m) + } + body["milestone_id"] = mid + } + if l := ctx.Arg("labels"); l != "" { + labelIDs, err := parseCommaInts(l) + if err != nil { + return fmt.Errorf("无效的标签 ID: %w", err) + } + body["issue_tag_ids"] = labelIDs + } + if a := ctx.Arg("assignees"); a != "" { + assigneeIDs, err := parseCommaInts(a) + if err != nil { + return fmt.Errorf("无效的负责人 ID: %w", err) + } + body["assigner_ids"] = assigneeIDs + } + + dryRun := parseBatchBool(ctx.Arg("dry-run")) + if dryRun { + return ctx.OutputData(map[string]interface{}{ + "action": "batch-update", + "dry_run": true, + "ids": ids, + "changes": body, + }) + } + + env, err := ctx.CallAPI("PATCH", v1RepoPath(ctx)+"/issues/batch_update", body) + if err != nil { + return err + } + return ctx.Output(env) +} + +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 := parseBatchBool(ctx.Arg("dry-run")) + if dryRun { + return ctx.OutputData(map[string]interface{}{ + "action": "batch-delete", + "dry_run": true, + "ids": ids, + "message": "使用 --confirm 执行实际删除", + }) + } + + if !parseBatchBool(ctx.Arg("confirm")) { + return ctx.OutputData(map[string]interface{}{ + "action": "batch-delete", + "dry_run": true, + "ids": ids, + "message": "批量删除是危险操作,请添加 --confirm 标志确认删除", + }) + } + + // Server-side batch delete + _, err = ctx.CallAPI("DELETE", v1RepoPath(ctx)+"/issues/batch_destroy", map[string]interface{}{ + "ids": ids, + }) + if err != nil { + return err + } + + return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ + "message": fmt.Sprintf("成功删除 %d 个 issue", len(ids)), + "ids": ids, + }, nil)) +} + +// 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 +} + +func parseBatchBool(value string) bool { + b, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && b +} diff --git a/shortcuts/issue/batch_update_test.go b/shortcuts/issue/batch_update_test.go new file mode 100644 index 0000000..ee02e66 --- /dev/null +++ b/shortcuts/issue/batch_update_test.go @@ -0,0 +1,135 @@ +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 TestBatchUpdateDryRun(t *testing.T) { + server := common.NewTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("no API calls expected in dry-run, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + ctx := common.NewTestContext(t, server, "owner", "repo", map[string]string{ + "ids": "1,2,3", + "status": "closed", + "dry-run": "true", + }) + err := common.RunShortcut(t, Shortcuts(), "batch-update", ctx) + if err != nil { + t.Fatalf("batch-update dry-run failed: %v", err) + } +} + +func TestBatchUpdateApply(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/issues/batch_update.json" { + updatePayload = 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", + "status": "closed", + "milestone": "5", + "dry-run": "false", + }) + err := common.RunShortcut(t, Shortcuts(), "batch-update", ctx) + if err != nil { + t.Fatalf("batch-update failed: %v", err) + } + + ids, ok := updatePayload["ids"].([]interface{}) + if !ok { + t.Fatalf("ids not a slice: %T", updatePayload["ids"]) + } + if len(ids) != 2 { + t.Fatalf("expected 2 ids, got %d", len(ids)) + } + if updatePayload["milestone_id"] != float64(5) { + t.Fatalf("expected milestone_id=5, got %v", updatePayload["milestone_id"]) + } +} + +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 4ccc575..bf2b58f 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -22,6 +22,8 @@ type existingIssue struct { func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ newBatchCloseShortcut(), + newBatchUpdateShortcut(), + newBatchDeleteShortcut(), { Name: "list", Description: "List issues", 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/register.go b/shortcuts/register.go index 13e86c3..2dbac36 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -11,6 +11,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/label" "github.com/gitlink-org/gitlink-cli/shortcuts/member" "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" + "github.com/gitlink-org/gitlink-cli/shortcuts/pm" "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" @@ -18,42 +19,47 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/search" "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(), + "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", + "pm": "Project management (kanban, sprints, weekly reports)", + "wiki": "Wiki operations", } for name, shortcuts := range groups { diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 87d4a01..e2d9950 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -46,6 +46,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 +67,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..43fde32 --- /dev/null +++ b/shortcuts/wiki/wiki.go @@ -0,0 +1,201 @@ +package wiki + +import ( + "encoding/base64" + "fmt" + "net/url" + "strconv" + + "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. +func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}, query url.Values) error { + origBase := ctx.Client.BaseURL + 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..0e9ad15 --- /dev/null +++ b/shortcuts/wiki/wiki_test.go @@ -0,0 +1,171 @@ +package wiki + +import ( + "encoding/base64" + "fmt" + "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/wikiPages": + common.WriteJSON(t, w, map[string]interface{}{ + "pages": []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/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{}{ + "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/createWiki": + createPayload = common.DecodeForm(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "message": "201", + "data": fmt.Sprintf(`{"title":"%s"}`, createPayload["pageName"]), + }) + 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["user"], "owner") + common.AssertEqual(t, createPayload["project_name"], "repo") + + // Verify content was base64 encoded + 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/updateWiki": + updatePayload = common.DecodeForm(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "message": "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/deleteWiki": + deletePayload = common.DecodeForm(t, r) + common.WriteJSON(t, w, map[string]interface{}{ + "message": "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": "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"], "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` 会自动解析