Replace PR #20 webhook with PR #22 improved implementation

- Use v1 API paths for all webhook endpoints (fixes view/test returning HTML/404)
- Add +tasks command for listing webhook delivery tasks
- Add webhook event validation (10-event whitelist)
- Add webhook type validation (10-type whitelist)
- Add content-type and http-method validation
- Fix update to preserve unspecified fields via fetch-and-merge
- Add event deduplication in parseWebhookEvents
- Add 9 comprehensive unit tests (up from 3)
- Use --http-method flag (maintains backward compatibility with PR #20)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tiger 2026-05-23 22:45:34 +08:00
parent 49cd590227
commit 4bfbfc6e8f
5 changed files with 400 additions and 243 deletions

View File

@ -156,6 +156,9 @@ gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
# Test a webhook
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
# View webhook delivery tasks
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Issue Management

View File

@ -167,6 +167,9 @@ gitlink-cli webhook +create --owner Gitlink --repo forgeplus \
# 测试 webhook
gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
# 查看 webhook 投递任务
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Issue 管理

View File

@ -7,16 +7,52 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
var allowedWebhookTypes = map[string]bool{
"gitea": true, "slack": true, "discord": true, "dingtalk": true, "telegram": true,
"msteams": true, "feishu": true, "matrix": true, "jianmu": true, "softbot": true,
}
var allowedWebhookContentTypes = map[string]bool{"json": true, "form": true}
var allowedWebhookMethods = map[string]bool{"GET": true, "POST": true}
var allowedWebhookEvents = map[string]bool{
"push": true, "create": true, "delete": true,
"issues_only": true, "issue_assign": true, "issue_label": true, "issue_comment": true,
"pull_request_only": true, "pull_request_assign": true, "pull_request_comment": true,
}
// Shortcuts returns webhook management shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List webhooks",
Description: "List repository webhooks",
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
env, err := ctx.CallAPI("GET", ctx.RepoPath()+"/webhooks", nil)
env, err := ctx.CallAPI("GET", webhookPath(ctx), nil)
if err != nil {
return err
}
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", webhookItemPath(ctx, id), nil)
if err != nil {
return err
}
@ -25,59 +61,38 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "create",
Description: "Create a webhook",
Description: "Create a repository 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: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true},
{Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", Default: "gitea"},
{Name: "content-type", Usage: "Payload content type: json or form", Default: "json"},
{Name: "http-method", Usage: "HTTP method: POST or GET", Default: "POST"},
{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: "branch-filter", Usage: "Branch glob filter for push/create/delete events", Default: "*"},
{Name: "active", Usage: "Whether the webhook is active: true or false", Default: "true"},
},
Run: runCreate,
},
{
Name: "update",
Description: "Update a webhook",
Description: "Update a repository webhook while preserving unspecified fields when available",
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)"},
{Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only"},
{Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot"},
{Name: "content-type", Usage: "Payload content type: json or form"},
{Name: "http-method", Usage: "HTTP method: POST or GET"},
{Name: "secret", Short: "s", Usage: "Webhook secret. Pass it again if the server does not return existing secrets."},
{Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events"},
{Name: "active", Usage: "Whether the webhook is active: true or false"},
},
Run: runUpdateWebhook,
Run: runUpdate,
},
{
Name: "delete",
Description: "Delete a webhook",
Description: "Delete a repository webhook",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
@ -89,7 +104,7 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", ctx.RepoPath(), id), nil)
env, err := ctx.CallAPI("DELETE", webhookItemPath(ctx, id), nil)
if err != nil {
return err
}
@ -98,7 +113,7 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "test",
Description: "Trigger a webhook test delivery",
Description: "Trigger a test delivery for a webhook",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
},
@ -110,7 +125,28 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", ctx.RepoPath(), id), nil)
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/tests", webhookItemPath(ctx, id)), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "tasks",
Description: "List webhook delivery tasks",
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/hooktasks", webhookItemPath(ctx, id)), nil)
if err != nil {
return err
}
@ -120,193 +156,214 @@ func Shortcuts() []*common.Shortcut {
}
}
func runCreateWebhook(ctx *common.RuntimeContext) error {
func runCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
payload, err := buildWebhookPayload(ctx, nil, true)
payload, err := webhookPayloadFromArgs(ctx, nil)
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/webhooks", payload)
env, err := ctx.CallAPI("POST", webhookPath(ctx), payload)
if err != nil {
return err
}
return ctx.Output(env)
}
func runUpdateWebhook(ctx *common.RuntimeContext) error {
func runUpdate(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)
current, err := fetchWebhook(ctx, id)
if err != nil {
return fmt.Errorf("fetch webhook: %w", err)
}
payload, err := webhookPayloadFromArgs(ctx, current)
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)
env, err := ctx.CallAPI("PUT", webhookItemPath(ctx, 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{}{}
func webhookPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s/webhooks", ctx.Owner, ctx.Repo)
}
if v := ctx.Arg("type"); v != "" {
payload["type"] = v
} else if current != nil {
if v, ok := current["type"].(string); ok && v != "" {
payload["type"] = v
}
func webhookItemPath(ctx *common.RuntimeContext, id string) string {
return fmt.Sprintf("%s/%s", webhookPath(ctx), id)
}
func fetchWebhook(ctx *common.RuntimeContext, id string) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", webhookItemPath(ctx, id), nil)
if err != nil {
return nil, err
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("failed to parse webhook data")
}
return data, nil
}
func webhookPayloadFromArgs(ctx *common.RuntimeContext, current map[string]interface{}) (map[string]interface{}, error) {
url := firstNonEmpty(ctx.Arg("url"), stringFromMap(current, "url"))
if url == "" {
return nil, fmt.Errorf("required flag --url is missing")
}
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)
eventValue := ctx.Arg("events")
var events []string
var err error
if eventValue != "" {
events, err = parseWebhookEvents(eventValue)
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)
} else {
events, err = eventsFromMap(current)
if err != nil {
return nil, err
}
payload["active"] = active
} else if current != nil {
if active, ok := current["active"].(bool); ok {
payload["active"] = active
}
}
if len(events) == 0 {
return nil, fmt.Errorf("required flag --events is missing")
}
if requireEvents {
if _, ok := payload["events"]; !ok {
return nil, fmt.Errorf("required flag --events is missing")
}
webhookType := strings.ToLower(firstNonEmpty(ctx.Arg("type"), stringFromMap(current, "type"), "gitea"))
if err := validateOneOf("type", webhookType, allowedWebhookTypes); err != nil {
return nil, err
}
contentType := strings.ToLower(firstNonEmpty(ctx.Arg("content-type"), stringFromMap(current, "content_type"), "json"))
if err := validateOneOf("content-type", contentType, allowedWebhookContentTypes); err != nil {
return nil, err
}
httpMethod := strings.ToUpper(firstNonEmpty(ctx.Arg("http-method"), stringFromMap(current, "http_method"), "POST"))
if err := validateOneOf("http-method", httpMethod, allowedWebhookMethods); err != nil {
return nil, err
}
branchFilter := firstNonEmpty(ctx.Arg("branch-filter"), stringFromMap(current, "branch_filter"), "*")
active, err := activeFromArgs(ctx.Arg("active"), current)
if err != nil {
return nil, err
}
if _, ok := payload["type"]; !ok {
payload["type"] = "gitea"
payload := map[string]interface{}{
"type": webhookType,
"active": active,
"content_type": contentType,
"http_method": httpMethod,
"url": url,
"branch_filter": branchFilter,
"events": events,
}
if _, ok := payload["content_type"]; !ok {
payload["content_type"] = "json"
if secret := firstNonEmpty(ctx.Arg("secret"), stringFromMap(current, "secret")); secret != "" {
payload["secret"] = secret
}
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 == "" {
func parseWebhookEvents(value string) ([]string, error) {
parts := strings.Split(value, ",")
events := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
event := strings.TrimSpace(part)
if event == "" {
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)
if !allowedWebhookEvents[event] {
return nil, fmt.Errorf("invalid --events value %q", event)
}
return items, len(items) > 0
if seen[event] {
continue
}
seen[event] = true
events = append(events, event)
}
if len(events) == 0 {
return nil, fmt.Errorf("required flag --events is missing")
}
return events, nil
}
func eventsFromMap(values map[string]interface{}) ([]string, error) {
if values == nil {
return nil, nil
}
raw, ok := values["events"]
if !ok || raw == nil {
return nil, nil
}
switch events := raw.(type) {
case []interface{}:
result := make([]string, 0, len(events))
for _, event := range events {
name, ok := event.(string)
if !ok {
return nil, fmt.Errorf("failed to parse webhook events")
}
result = append(result, name)
}
return result, nil
case []string:
return events, nil
default:
return nil, false
return nil, fmt.Errorf("failed to parse webhook events")
}
}
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)
func activeFromArgs(value string, current map[string]interface{}) (bool, error) {
if value != "" {
switch strings.ToLower(strings.TrimSpace(value)) {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("invalid --active value %q: use true or false", value)
}
}
if current != nil {
if active, ok := current["active"].(bool); ok {
return active, nil
}
if active, ok := current["is_active"].(bool); ok {
return active, nil
}
}
return true, nil
}
func stringFromMap(values map[string]interface{}, key string) string {
if values == nil {
return ""
}
value, _ := values[key].(string)
return value
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func validateOneOf(name, value string, allowed map[string]bool) error {
if allowed[value] {
return nil
}
return fmt.Errorf("invalid --%s value %q", name, value)
}

View File

@ -4,110 +4,168 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"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)
}
}))
func TestWebhookList(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/webhooks.json")
writeJSON(t, w, map[string]interface{}{"total_count": 1, "webhooks": []interface{}{}})
})
defer server.Close()
if err := runWebhookShortcut(t, server, "list", nil); err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestWebhookView(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/webhooks/7.json")
writeJSON(t, w, map[string]interface{}{"id": 7, "url": "https://example.com/hook"})
})
defer server.Close()
if err := runWebhookShortcut(t, server, "view", map[string]string{"id": "7"}); err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
}
func TestWebhookCreatePayload(t *testing.T) {
var payload map[string]interface{}
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/v1/owner/repo/webhooks.json")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": 1})
})
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",
"events": "push,issues_only,push",
"type": "gitea",
"content-type": "json",
"http-method": "POST",
"secret": "secret-token",
"branch-filter": "master,{release*}",
"active": "true",
})
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)
assertEqual(t, payload["url"], "https://example.com/hook")
assertEqual(t, payload["type"], "gitea")
assertEqual(t, payload["content_type"], "json")
assertEqual(t, payload["http_method"], "POST")
assertEqual(t, payload["secret"], "secret-token")
assertEqual(t, payload["branch_filter"], "master,{release*}")
assertEqual(t, payload["active"], true)
assertStringSlice(t, payload["events"], []string{"push", "issues_only"})
}
func TestWebhookUpdatePreservesExistingFields(t *testing.T) {
var updatePayload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
func TestWebhookUpdatePreservesCurrentFields(t *testing.T) {
var payload map[string]interface{}
server := newWebhookTestServer(t, 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",
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/webhooks/7.json":
writeJSON(t, w, map[string]interface{}{
"id": 7,
"url": "https://old.example.com/hook",
"type": "gitea",
"content_type": "json",
"http_method": "POST",
"secret": "old-secret",
"branch_filter": "*",
"events": []interface{}{"push", "create"},
"events": []string{"push"},
"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)})
case r.Method == "PUT" && r.URL.Path == "/v1/owner/repo/webhooks/7.json":
payload = decodeJSON(t, r)
writeJSON(t, w, payload)
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",
"id": "7",
"url": "https://new.example.com/hook",
"active": "false",
"events": "push,issue_comment",
})
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)
assertEqual(t, payload["url"], "https://new.example.com/hook")
assertEqual(t, payload["type"], "gitea")
assertEqual(t, payload["content_type"], "json")
assertEqual(t, payload["http_method"], "POST")
assertEqual(t, payload["branch_filter"], "*")
assertEqual(t, payload["active"], true)
assertStringSlice(t, payload["events"], []string{"push", "issue_comment"})
}
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)
}
}))
func TestWebhookDelete(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "DELETE", "/v1/owner/repo/webhooks/7.json")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
err := runWebhookShortcut(t, server, "test", map[string]string{"id": "68"})
if err != nil {
if err := runWebhookShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestWebhookTestDelivery(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/v1/owner/repo/webhooks/7/tests.json")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
if err := runWebhookShortcut(t, server, "test", map[string]string{"id": "7"}); err != nil {
t.Fatalf("test shortcut failed: %v", err)
}
if !called {
t.Fatal("webhook test endpoint was not called")
}
func TestWebhookTasks(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/webhooks/7/hooktasks.json")
writeJSON(t, w, map[string]interface{}{"total_count": 0, "hooktasks": []interface{}{}})
})
defer server.Close()
if err := runWebhookShortcut(t, server, "tasks", map[string]string{"id": "7"}); err != nil {
t.Fatalf("tasks shortcut failed: %v", err)
}
}
func TestParseWebhookEventsRejectsInvalidEvent(t *testing.T) {
_, err := parseWebhookEvents("push,invalid")
if err == nil {
t.Fatal("expected invalid event to return an error")
}
}
func TestWebhookCreateRejectsInvalidActive(t *testing.T) {
server := newWebhookTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid active should not call API, got: %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",
"active": "maybe",
})
if err == nil {
t.Fatal("expected invalid active to return an error")
}
}
@ -124,6 +182,9 @@ func runWebhookShortcut(t *testing.T, server *httptest.Server, name string, args
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
@ -138,7 +199,19 @@ func findWebhookShortcut(t *testing.T, name string) *common.Shortcut {
return nil
}
func decodeWebhookJSON(t *testing.T, r *http.Request) map[string]interface{} {
func newWebhookTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func assertRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
}
}
func decodeJSON(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 {
@ -147,7 +220,7 @@ func decodeWebhookJSON(t *testing.T, r *http.Request) map[string]interface{} {
return payload
}
func writeWebhookJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
func writeJSON(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 {
@ -155,9 +228,28 @@ func writeWebhookJSON(t *testing.T, w http.ResponseWriter, payload interface{})
}
}
func assertWebhookEqual(t *testing.T, got interface{}, want interface{}) {
func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if got != want {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}
func assertStringSlice(t *testing.T, got interface{}, want []string) {
t.Helper()
values, ok := got.([]interface{})
if !ok {
t.Fatalf("got %T, want []interface{}", got)
}
result := make([]string, 0, len(values))
for _, value := range values {
text, ok := value.(string)
if !ok {
t.Fatalf("got event %v (%T), want string", value, value)
}
result = append(result, text)
}
if !reflect.DeepEqual(result, want) {
t.Fatalf("got %v, want %v", result, want)
}
}

View File

@ -20,6 +20,7 @@ metadata:
| `webhook +update` | 更新 webhook |
| `webhook +delete` | 删除 webhook |
| `webhook +test` | 触发 webhook 测试投递 |
| `webhook +tasks` | 查看 webhook 投递任务历史 |
## 使用示例
@ -28,4 +29,5 @@ 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
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```