Merge pull request 'feat: add webhook shortcut group' (#20) from Mengz/gitlink-cli:codex/webhook-shortcut into master

This commit is contained in:
wbtiger 2026-05-23 21:44:22 +08:00
commit 9b6268cc47
7 changed files with 558 additions and 8 deletions

View File

@ -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)**
@ -13,8 +13,8 @@ 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
- **Agent-Native Design** — 13 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, 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

View File

@ -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+ 命令和 13 个 AI Agent [Skills](./skills/)。
**[English](./README.md)**
@ -13,8 +13,8 @@
## 为什么选择 gitlink-cli
- **Agent-Native 设计** — 开箱即用 11 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
- **Agent-Native 设计** — 开箱即用 13 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、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
@ -273,7 +287,7 @@ git push gitlink
## AI Agent Skills
`skills/` 目录包含 11 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
`skills/` 目录包含 13 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
详见 [skills/README.md](skills/README.md)

View File

@ -0,0 +1,12 @@
# Webhook Shortcut
新增 `webhook` Shortcut 组,支持:
- `webhook +list`
- `webhook +create`
- `webhook +view`
- `webhook +update`
- `webhook +delete`
- `webhook +test`
同时补充了对应单元测试、帮助文档和示例说明。

View File

@ -13,6 +13,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/repo"
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
)
// RegisterAll mounts all shortcut groups onto the root command.
@ -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 {

View File

@ -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)
}
}

View File

@ -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 == "/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 == "/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 == "/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 == "/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)
}
}

View File

@ -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-webhookWebhook 管理)
## 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
```