diff --git a/README.md b/README.md index 88ef4b4..334aef8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 0b66995..67691ae 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 管理 diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 508976e..091200e 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -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) } diff --git a/shortcuts/webhook/webhook_test.go b/shortcuts/webhook/webhook_test.go index 113bbc1..451ff6d 100644 --- a/shortcuts/webhook/webhook_test.go +++ b/shortcuts/webhook/webhook_test.go @@ -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) + } +} diff --git a/skills/gitlink-webhook/SKILL.md b/skills/gitlink-webhook/SKILL.md index 1fc95da..185c74f 100644 --- a/skills/gitlink-webhook/SKILL.md +++ b/skills/gitlink-webhook/SKILL.md @@ -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 ```