forked from Gitlink/gitlink-cli
525 lines
15 KiB
Go
525 lines
15 KiB
Go
package webhook
|
|
|
|
import (
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"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 repository webhooks",
|
|
Long: `List repository webhooks.
|
|
|
|
Returns all webhooks configured for the current repository.`,
|
|
Example: ` # List all webhooks
|
|
gitlink webhook +list`,
|
|
Run: func(ctx *common.RuntimeContext) error {
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return err
|
|
}
|
|
env, err := ctx.CallAPI("GET", webhookPath(ctx), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ctx.Output(env)
|
|
},
|
|
},
|
|
{
|
|
Name: "view",
|
|
Description: "View webhook details",
|
|
Long: `View webhook details.
|
|
|
|
Shows the full configuration of a specific webhook including
|
|
URL, events, type, and active status.`,
|
|
Example: ` # View a webhook
|
|
gitlink webhook +view --id 10`,
|
|
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
|
|
}
|
|
return ctx.Output(env)
|
|
},
|
|
},
|
|
{
|
|
Name: "create",
|
|
Description: "Create a repository webhook",
|
|
Long: `Create a repository webhook.
|
|
|
|
Creates a new webhook that sends HTTP requests to the specified URL
|
|
when certain events occur. Supported types: gitea, slack, discord,
|
|
dingtalk, telegram, msteams, feishu, matrix, jianmu, softbot.`,
|
|
Example: ` # Create a push webhook
|
|
gitlink webhook +create --url https://example.com/hook --events push
|
|
|
|
# Create a webhook for multiple events
|
|
gitlink webhook +create --url https://example.com/hook --events push,issues_only --secret mysecret`,
|
|
Flags: []common.Flag{
|
|
{Name: "url", Short: "u", Usage: "Webhook target URL", 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: "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 repository webhook while preserving unspecified fields when available",
|
|
Long: `Update a repository webhook while preserving unspecified fields when available.
|
|
|
|
Fetches the current webhook configuration, merges changes from the provided
|
|
flags, and updates the webhook. Fields not specified are preserved from the
|
|
current configuration.`,
|
|
Example: ` # Update a webhook URL
|
|
gitlink webhook +update --id 10 --url https://new.example.com/hook
|
|
|
|
# Update webhook events
|
|
gitlink webhook +update --id 10 --events push,pull_request_only`,
|
|
Flags: []common.Flag{
|
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
|
{Name: "url", Short: "u", Usage: "Webhook target URL"},
|
|
{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: runUpdate,
|
|
},
|
|
{
|
|
Name: "delete",
|
|
Description: "Delete a repository webhook",
|
|
Long: `Delete a repository webhook.
|
|
|
|
Permanently removes the webhook from the repository.`,
|
|
Example: ` # Delete a webhook
|
|
gitlink webhook +delete --id 10`,
|
|
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", webhookItemPath(ctx, id), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ctx.Output(env)
|
|
},
|
|
},
|
|
{
|
|
Name: "test",
|
|
Description: "Trigger a test delivery for a webhook",
|
|
Long: `Trigger a test delivery for a webhook.
|
|
|
|
Sends a test payload to the webhook URL to verify the configuration.`,
|
|
Example: ` # Test a webhook
|
|
gitlink webhook +test --id 10`,
|
|
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/tests", webhookItemPath(ctx, id)), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ctx.Output(env)
|
|
},
|
|
},
|
|
{
|
|
Name: "tasks",
|
|
Description: "List webhook delivery tasks",
|
|
Long: `List webhook delivery tasks.
|
|
|
|
Returns the history of webhook deliveries including status,
|
|
response code, and timing information.`,
|
|
Example: ` # List delivery tasks for a webhook
|
|
gitlink webhook +tasks --id 10`,
|
|
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
|
|
}
|
|
return ctx.Output(env)
|
|
},
|
|
},
|
|
{
|
|
Name: "failed",
|
|
Description: "List failed webhook deliveries",
|
|
Flags: []common.Flag{
|
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
|
{Name: "limit", Short: "l", Usage: "Number of recent failed tasks", Default: "10"},
|
|
},
|
|
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
|
|
}
|
|
raw, ok := env.Data.(map[string]interface{})
|
|
if !ok {
|
|
return ctx.Output(env)
|
|
}
|
|
tasks, ok := raw["hooktasks"].([]interface{})
|
|
if !ok {
|
|
return ctx.OutputData(map[string]interface{}{
|
|
"hooktasks": []interface{}{},
|
|
"total_count": 0,
|
|
})
|
|
}
|
|
limit := 0
|
|
if limitStr := ctx.Arg("limit"); limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
limit = l
|
|
}
|
|
}
|
|
failedTasks := make([]interface{}, 0)
|
|
for _, rawTask := range tasks {
|
|
task, ok := rawTask.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if succeed, ok := task["is_succeed"].(bool); ok && !succeed {
|
|
failedTasks = append(failedTasks, map[string]interface{}{
|
|
"uuid": task["uuid"],
|
|
"event_type": task["event_type"],
|
|
"delivered_time": task["delivered_time"],
|
|
"status": "failed",
|
|
})
|
|
if limit > 0 && len(failedTasks) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return ctx.OutputData(map[string]interface{}{
|
|
"hooktasks": failedTasks,
|
|
"total_count": len(failedTasks),
|
|
})
|
|
},
|
|
},
|
|
{
|
|
Name: "task-view",
|
|
Description: "View webhook delivery task details",
|
|
Flags: []common.Flag{
|
|
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
|
{Name: "task-id", Usage: "Task ID (numeric)", 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
|
|
}
|
|
taskID, err := ctx.RequireArg("task-id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/hooktasks", webhookItemPath(ctx, id)), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, ok := env.Data.(map[string]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("failed to parse hook tasks")
|
|
}
|
|
tasks, ok := raw["hooktasks"].([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("no hook tasks found")
|
|
}
|
|
for _, rawTask := range tasks {
|
|
task, ok := rawTask.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
// Support both numeric ID and UUID
|
|
if tid, ok := task["id"].(float64); ok && fmt.Sprintf("%.0f", tid) == taskID {
|
|
return ctx.OutputData(task)
|
|
}
|
|
if uuid, ok := task["uuid"].(string); ok && uuid == taskID {
|
|
return ctx.OutputData(task)
|
|
}
|
|
}
|
|
return fmt.Errorf("task %s not found", taskID)
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func runCreate(ctx *common.RuntimeContext) error {
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return err
|
|
}
|
|
payload, err := webhookPayloadFromArgs(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
env, err := ctx.CallAPI("POST", webhookPath(ctx), payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ctx.Output(env)
|
|
}
|
|
|
|
func runUpdate(ctx *common.RuntimeContext) error {
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return err
|
|
}
|
|
id, err := ctx.RequireArg("id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
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
|
|
}
|
|
env, err := ctx.CallAPI("PUT", webhookItemPath(ctx, id), payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ctx.Output(env)
|
|
}
|
|
|
|
func webhookPath(ctx *common.RuntimeContext) string {
|
|
return fmt.Sprintf("/v1/%s/%s/webhooks", ctx.Owner, ctx.Repo)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
eventValue := ctx.Arg("events")
|
|
var events []string
|
|
var err error
|
|
if eventValue != "" {
|
|
events, err = parseWebhookEvents(eventValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
events, err = eventsFromMap(current)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if len(events) == 0 {
|
|
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
|
|
}
|
|
|
|
payload := map[string]interface{}{
|
|
"type": webhookType,
|
|
"active": active,
|
|
"content_type": contentType,
|
|
"http_method": httpMethod,
|
|
"url": url,
|
|
"branch_filter": branchFilter,
|
|
"events": events,
|
|
}
|
|
if secret := firstNonEmpty(ctx.Arg("secret"), stringFromMap(current, "secret")); secret != "" {
|
|
payload["secret"] = secret
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
if !allowedWebhookEvents[event] {
|
|
return nil, fmt.Errorf("invalid --events value %q", event)
|
|
}
|
|
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, fmt.Errorf("failed to parse webhook events")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|