forked from Gitlink/gitlink-cli
534 lines
16 KiB
Go
534 lines
16 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{
|
||
// +list:列出仓库的所有 Web 钩子
|
||
{
|
||
Name: "list",
|
||
Description: "列出仓库的所有 Web 钩子",
|
||
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)
|
||
},
|
||
},
|
||
// +view:查看 Web 钩子的详细信息
|
||
{
|
||
Name: "view",
|
||
Description: "查看 Web 钩子的详细信息",
|
||
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)
|
||
},
|
||
},
|
||
// +create:创建仓库 Web 钩子
|
||
{
|
||
Name: "create",
|
||
Description: "创建仓库 Web 钩子",
|
||
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,
|
||
},
|
||
// +update:更新仓库 Web 钩子,未指定的字段尽可能保留原值
|
||
{
|
||
Name: "update",
|
||
Description: "更新仓库 Web 钩子,未指定的字段尽可能保留原值",
|
||
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,
|
||
},
|
||
// +delete:删除仓库 Web 钩子
|
||
{
|
||
Name: "delete",
|
||
Description: "删除仓库 Web 钩子",
|
||
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)
|
||
},
|
||
},
|
||
// +test:触发 Web 钩子的测试投递
|
||
{
|
||
Name: "test",
|
||
Description: "触发 Web 钩子的测试投递",
|
||
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)
|
||
},
|
||
},
|
||
// +tasks:列出 Web 钩子的投递任务
|
||
{
|
||
Name: "tasks",
|
||
Description: "列出 Web 钩子的投递任务",
|
||
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)
|
||
},
|
||
},
|
||
// +failed:列出投递失败的 Web 钩子任务
|
||
{
|
||
Name: "failed",
|
||
Description: "列出投递失败的 Web 钩子任务",
|
||
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),
|
||
})
|
||
},
|
||
},
|
||
// +task-view:查看 Web 钩子投递任务的详细信息
|
||
{
|
||
Name: "task-view",
|
||
Description: "查看 Web 钩子投递任务的详细信息",
|
||
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)
|
||
}
|