diff --git a/README.md b/README.md index 2ef60c1..3bd8bb6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows, with 40+ commands and 12 AI Agent [Skills](./skills/). +The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, CI/CD, and AI-powered workflows, with 40+ commands and 13 AI Agent [Skills](./skills/). **[中文文档](./README.zh-CN.md)** @@ -14,7 +14,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans ## Why gitlink-cli? - **Agent-Native Design** — 12 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup -- **Wide Coverage** — Repository, Issue, PR, Branch, Release, CI, Org, Search, User — all core domains covered +- **Wide Coverage** — Repository, Issue, PR, Webhook, Branch, Release, CI, Org, Search, User — all core domains covered - **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output - **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded - **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install` @@ -33,6 +33,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | 🏷️ Release | Create, view, delete releases | | 🏢 Org | Manage organizations, members, teams | | 🔧 CI | View builds, logs, CI/CD operations | +| 🔔 Webhook | Manage repo webhooks and test deliveries | | 🔍 Search | Search repositories, users | | 👤 User | View user profiles and info | | 📋 PM | Sprint management, kanban boards, weekly reports | @@ -55,7 +56,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans **From npm (recommended):** ```bash -# One command: installs CLI binary + all 12 AI Agent Skills +# One command: installs CLI binary + all 13 AI Agent Skills npm install -g @gitlink-ai/cli ``` @@ -143,6 +144,20 @@ gitlink-cli repo +create -n my-project -d "Project description" gitlink-cli repo +fork --owner Gitlink --repo forgeplus ``` +### Webhook Management + +```bash +# List webhooks +gitlink-cli webhook +list --owner Gitlink --repo forgeplus + +# Create a webhook +gitlink-cli webhook +create --owner Gitlink --repo forgeplus \ + --url https://example.com/hook --events push,create + +# Test a webhook +gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 +``` + ### Issue Management ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index ba4be15..48747d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,7 +5,7 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 11 个 AI Agent [Skills](./skills/)。 +[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、Webhook、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 12 个 AI Agent [Skills](./skills/)。 **[English](./README.md)** @@ -14,7 +14,7 @@ ## 为什么选择 gitlink-cli? - **Agent-Native 设计** — 开箱即用 11 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink -- **广泛覆盖** — 仓库、Issue、PR、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖 +- **广泛覆盖** — 仓库、Issue、PR、Webhook、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖 - **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出 - **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装 - **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用 @@ -155,6 +155,20 @@ gitlink-cli repo +create -n my-project -d "项目描述" gitlink-cli repo +fork --owner Gitlink --repo forgeplus ``` +### Webhook 管理 + +```bash +# 列出 webhook +gitlink-cli webhook +list --owner Gitlink --repo forgeplus + +# 创建 webhook +gitlink-cli webhook +create --owner Gitlink --repo forgeplus \ + --url https://example.com/hook --events push,create + +# 测试 webhook +gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 +``` + ### Issue 管理 ```bash diff --git a/shortcuts/register.go b/shortcuts/register.go index 210500e..b1af6f9 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -11,6 +11,7 @@ import ( "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/webhook" "github.com/gitlink-org/gitlink-cli/shortcuts/search" "github.com/gitlink-org/gitlink-cli/shortcuts/user" ) @@ -27,6 +28,7 @@ func RegisterAll(root *cobra.Command) { "user": user.Shortcuts(), "search": search.Shortcuts(), "ci": ci.Shortcuts(), + "webhook": webhook.Shortcuts(), } descriptions := map[string]string{ @@ -39,6 +41,7 @@ func RegisterAll(root *cobra.Command) { "user": "User operations", "search": "Search operations", "ci": "CI/CD operations", + "webhook": "Webhook operations", } for name, shortcuts := range groups { diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go new file mode 100644 index 0000000..508976e --- /dev/null +++ b/shortcuts/webhook/webhook.go @@ -0,0 +1,312 @@ +package webhook + +import ( + "fmt" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List webhooks", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/webhooks", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a webhook", + Flags: []common.Flag{ + {Name: "url", Short: "u", Usage: "Webhook target URL", Required: true}, + {Name: "type", Short: "t", Usage: "Webhook provider type", Default: "gitea"}, + {Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,create,delete", Required: true}, + {Name: "secret", Short: "s", Usage: "Webhook secret"}, + {Name: "content-type", Usage: "POST content type: json or form", Default: "json"}, + {Name: "http-method", Usage: "Delivery method: GET or POST", Default: "POST"}, + {Name: "branch-filter", Usage: "Branch filter glob", Default: "*"}, + {Name: "active", Short: "a", Usage: "Enable webhook (true/false)", Default: "true"}, + }, + Run: runCreateWebhook, + }, + { + Name: "view", + Description: "Show 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", ctx.RepoPath(), 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 target URL"}, + {Name: "type", Short: "t", Usage: "Webhook provider type"}, + {Name: "events", Short: "e", Usage: "Comma-separated events"}, + {Name: "secret", Short: "s", Usage: "Webhook secret"}, + {Name: "content-type", Usage: "POST content type: json or form"}, + {Name: "http-method", Usage: "Delivery method: GET or POST"}, + {Name: "branch-filter", Usage: "Branch filter glob"}, + {Name: "active", Short: "a", Usage: "Enable webhook (true/false)"}, + }, + Run: runUpdateWebhook, + }, + { + Name: "delete", + Description: "Delete a webhook", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", ctx.RepoPath(), id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "test", + Description: "Trigger a webhook test 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", ctx.RepoPath(), id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func runCreateWebhook(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + payload, err := buildWebhookPayload(ctx, nil, true) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/webhooks", payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runUpdateWebhook(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + + currentEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", ctx.RepoPath(), id), nil) + if err != nil { + return err + } + current, _ := currentEnv.Data.(map[string]interface{}) + + payload, err := buildWebhookPayload(ctx, current, false) + if err != nil { + return err + } + env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", ctx.RepoPath(), id), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func buildWebhookPayload(ctx *common.RuntimeContext, current map[string]interface{}, requireEvents bool) (map[string]interface{}, error) { + payload := map[string]interface{}{} + + if v := ctx.Arg("type"); v != "" { + payload["type"] = v + } else if current != nil { + if v, ok := current["type"].(string); ok && v != "" { + payload["type"] = v + } + } + + if v := ctx.Arg("url"); v != "" { + payload["url"] = v + } else if current != nil { + if v, ok := current["url"].(string); ok && v != "" { + payload["url"] = v + } + } + + if v := ctx.Arg("content-type"); v != "" { + payload["content_type"] = v + } else if current != nil { + if v, ok := current["content_type"].(string); ok && v != "" { + payload["content_type"] = v + } + } + + if v := ctx.Arg("http-method"); v != "" { + payload["http_method"] = v + } else if current != nil { + if v, ok := current["http_method"].(string); ok && v != "" { + payload["http_method"] = v + } + } + + if v := ctx.Arg("secret"); v != "" { + payload["secret"] = v + } else if current != nil { + if v, ok := current["secret"].(string); ok && v != "" { + payload["secret"] = v + } + } + + if v := ctx.Arg("branch-filter"); v != "" { + payload["branch_filter"] = v + } else if current != nil { + if v, ok := current["branch_filter"].(string); ok && v != "" { + payload["branch_filter"] = v + } + } + + eventsValue := ctx.Arg("events") + if eventsValue != "" { + events, err := parseCommaList(eventsValue) + if err != nil { + return nil, err + } + payload["events"] = events + } else if current != nil { + if events, ok := normalizeStringSlice(current["events"]); ok { + payload["events"] = events + } + } + + activeValue := ctx.Arg("active") + if activeValue != "" { + active, err := parseBoolString(activeValue) + if err != nil { + return nil, err + } + payload["active"] = active + } else if current != nil { + if active, ok := current["active"].(bool); ok { + payload["active"] = active + } + } + + if requireEvents { + if _, ok := payload["events"]; !ok { + return nil, fmt.Errorf("required flag --events is missing") + } + } + + if _, ok := payload["type"]; !ok { + payload["type"] = "gitea" + } + if _, ok := payload["content_type"]; !ok { + payload["content_type"] = "json" + } + if _, ok := payload["http_method"]; !ok { + payload["http_method"] = "POST" + } + if _, ok := payload["branch_filter"]; !ok { + payload["branch_filter"] = "*" + } + if _, ok := payload["active"]; !ok { + payload["active"] = true + } + + return payload, nil +} + +func parseCommaList(value string) ([]string, error) { + items := strings.Split(value, ",") + results := make([]string, 0, len(items)) + for _, item := range items { + value := strings.TrimSpace(item) + if value == "" { + continue + } + results = append(results, value) + } + if len(results) == 0 { + return nil, fmt.Errorf("no values provided") + } + return results, nil +} + +func normalizeStringSlice(value interface{}) ([]string, bool) { + switch v := value.(type) { + case []string: + return v, true + case []interface{}: + items := make([]string, 0, len(v)) + for _, item := range v { + s, ok := item.(string) + if !ok { + continue + } + items = append(items, s) + } + return items, len(items) > 0 + default: + return nil, false + } +} + +func parseBoolString(value string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "1", "yes", "y", "on": + return true, nil + case "false", "0", "no", "n", "off": + return false, nil + default: + return false, fmt.Errorf("invalid boolean value %q: use true or false", value) + } +} diff --git a/shortcuts/webhook/webhook_test.go b/shortcuts/webhook/webhook_test.go new file mode 100644 index 0000000..93fae89 --- /dev/null +++ b/shortcuts/webhook/webhook_test.go @@ -0,0 +1,163 @@ +package webhook + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestWebhookCreateBuildsPayload(t *testing.T) { + var createPayload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks.json": + createPayload = decodeWebhookJSON(t, r) + writeWebhookJSON(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runWebhookShortcut(t, server, "create", map[string]string{ + "url": "https://example.com/hook", + "events": "push,create", + "type": "slack", + "content-type": "form", + "http-method": "GET", + "secret": "abc123", + "branch-filter": "master", + "active": "false", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + + assertWebhookEqual(t, createPayload["url"], "https://example.com/hook") + assertWebhookEqual(t, createPayload["type"], "slack") + assertWebhookEqual(t, createPayload["content_type"], "form") + assertWebhookEqual(t, createPayload["http_method"], "GET") + assertWebhookEqual(t, createPayload["secret"], "abc123") + assertWebhookEqual(t, createPayload["branch_filter"], "master") + assertWebhookEqual(t, createPayload["active"], false) +} + +func TestWebhookUpdatePreservesExistingFields(t *testing.T) { + var updatePayload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/68.json": + writeWebhookJSON(t, w, map[string]interface{}{ + "id": float64(68), + "type": "gitea", + "url": "https://old.example.com/hook", + "content_type": "json", + "http_method": "POST", + "secret": "old-secret", + "branch_filter": "*", + "events": []interface{}{"push", "create"}, + "active": true, + }) + case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/webhooks/68.json": + updatePayload = decodeWebhookJSON(t, r) + writeWebhookJSON(t, w, map[string]interface{}{"id": float64(68)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runWebhookShortcut(t, server, "update", map[string]string{ + "id": "68", + "url": "https://new.example.com/hook", + "active": "false", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + assertWebhookEqual(t, updatePayload["url"], "https://new.example.com/hook") + assertWebhookEqual(t, updatePayload["type"], "gitea") + assertWebhookEqual(t, updatePayload["content_type"], "json") + assertWebhookEqual(t, updatePayload["http_method"], "POST") + assertWebhookEqual(t, updatePayload["secret"], "old-secret") + assertWebhookEqual(t, updatePayload["branch_filter"], "*") + assertWebhookEqual(t, updatePayload["active"], false) +} + +func TestWebhookTestTriggersDelivery(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "POST" && r.URL.Path == "/v1/owner/repo/webhooks/68/tests.json": + called = true + writeWebhookJSON(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() + + err := runWebhookShortcut(t, server, "test", map[string]string{"id": "68"}) + if err != nil { + t.Fatalf("test shortcut failed: %v", err) + } + if !called { + t.Fatal("webhook test endpoint was not called") + } +} + +func runWebhookShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findWebhookShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) +} + +func findWebhookShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func decodeWebhookJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeWebhookJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertWebhookEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if got != want { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/skills/gitlink-webhook/SKILL.md b/skills/gitlink-webhook/SKILL.md new file mode 100644 index 0000000..1fc95da --- /dev/null +++ b/skills/gitlink-webhook/SKILL.md @@ -0,0 +1,31 @@ +--- +name: gitlink-webhook +version: 1.0.0 +description: "Webhook 配置与测试:列出、创建、查看、更新、删除并触发 webhook 测试。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli webhook --help" +--- + +# gitlink-webhook(Webhook 管理) + +## Shortcuts + +| Shortcut | 说明 | +|----------|------| +| `webhook +list` | 列出仓库 webhook | +| `webhook +create` | 创建 webhook | +| `webhook +view` | 查看 webhook 详情 | +| `webhook +update` | 更新 webhook | +| `webhook +delete` | 删除 webhook | +| `webhook +test` | 触发 webhook 测试投递 | + +## 使用示例 + +```bash +gitlink-cli webhook +list --owner Gitlink --repo forgeplus +gitlink-cli webhook +create --owner Gitlink --repo forgeplus \ + --url https://example.com/hook --events push,create +gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68 +```