- 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:
parent
49cd590227
commit
4bfbfc6e8f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 管理
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue