gitlink-cli/shortcuts/webhook/webhook.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)
}