forked from Gitlink/gitlink-cli
407 lines
13 KiB
Go
407 lines
13 KiB
Go
package webhook
|
||
|
||
import (
|
||
"fmt"
|
||
"net/url"
|
||
"strings"
|
||
|
||
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors"
|
||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||
)
|
||
|
||
// supportedEvents 定义了 GitLink 支持的所有 Webhook 事件类型
|
||
// push: 代码推送事件
|
||
// pull_request: PR 事件
|
||
// issue: Issue 事件
|
||
// issue_assign: Issue 分配事件
|
||
// issue_comment: Issue 评论事件
|
||
// pull_request_assign: PR 分配事件
|
||
// pull_request_comment: PR 评论事件
|
||
// merge_request: 合并请求事件
|
||
// repository: 仓库事件
|
||
// branch: 分支创建/删除事件
|
||
// tag: 标签创建/删除事件
|
||
var supportedEvents = []string{
|
||
"push",
|
||
"pull_request",
|
||
"issue",
|
||
"issue_assign",
|
||
"issue_comment",
|
||
"pull_request_assign",
|
||
"pull_request_comment",
|
||
"merge_request",
|
||
"repository",
|
||
"branch",
|
||
"tag",
|
||
}
|
||
|
||
// isEventSupported 检查某个事件类型是否被支持
|
||
// 参数: event - 要检查的事件类型
|
||
// 返回: true 表示支持,false 表示不支持
|
||
func isEventSupported(event string) bool {
|
||
for _, supported := range supportedEvents {
|
||
if event == supported {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// parseEvents 把用户输入的逗号分隔的事件字符串解析成事件数组
|
||
// 参数: eventsStr - 用户输入的事件字符串,如 "push,pull_request"
|
||
// 返回: 过滤后的有效事件数组,如果输入为空则返回默认值 ["push"]
|
||
func parseEvents(eventsStr string) []string {
|
||
if eventsStr == "" {
|
||
return []string{"push"} // 默认事件
|
||
}
|
||
events := strings.Split(eventsStr, ",") // 按逗号分割
|
||
var validEvents []string
|
||
for _, event := range events {
|
||
event = strings.TrimSpace(event) // 去掉前后空格
|
||
if isEventSupported(event) {
|
||
validEvents = append(validEvents, event) // 有效事件才加入结果
|
||
}
|
||
}
|
||
return validEvents
|
||
}
|
||
|
||
// webhookRepoPath 构建 Webhook API 的基础路径
|
||
// 参数: ctx - 运行时上下文,包含 Owner(仓库所有者)和 Repo(仓库名)
|
||
// 返回: 类似 /v1/owner/repo 的字符串
|
||
// 注意: BaseURL 已经包含 /api 前缀,所以这里不需要再加
|
||
func webhookRepoPath(ctx *common.RuntimeContext) string {
|
||
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||
}
|
||
|
||
// Shortcuts 返回所有 Webhook 相关的 CLI 命令列表
|
||
// 包含7个命令:list、create、update、delete、test、info、events
|
||
func Shortcuts() []*common.Shortcut {
|
||
return []*common.Shortcut{
|
||
// list 命令:列出仓库的所有 Webhook
|
||
{
|
||
Name: "list",
|
||
Description: "List all webhooks for a repository",
|
||
Flags: []common.Flag{
|
||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
// 步骤1:解析仓库信息(从命令行参数或 Git 远程仓库)
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
// 步骤2:创建 URL 查询参数
|
||
q := url.Values{}
|
||
q.Set("page", ctx.Arg("page"))
|
||
q.Set("limit", ctx.Arg("limit"))
|
||
// 步骤3:调用 API 获取 Webhook 列表
|
||
env, err := ctx.CallAPIWithQuery("GET", webhookRepoPath(ctx)+"/webhooks", q)
|
||
if err != nil {
|
||
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
|
||
}
|
||
// 步骤4:输出结果给用户
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
// create 命令:创建新的 Webhook
|
||
{
|
||
Name: "create",
|
||
Description: "Create a new webhook",
|
||
DryRun: true,
|
||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||
return fmt.Sprintf("创建 Webhook: %s", ctx.Arg("url")), nil
|
||
},
|
||
Flags: []common.Flag{
|
||
{Name: "url", Short: "u", Usage: "Webhook callback URL", Required: true},
|
||
{Name: "events", Short: "e", Usage: "Trigger events (comma-separated), e.g., push,pull_request,issue", Default: "push"},
|
||
{Name: "active", Usage: "Webhook active status (true/false)", Default: "true"},
|
||
{Name: "secret", Usage: "Webhook secret for HMAC verification"},
|
||
{Name: "description", Short: "d", Usage: "Webhook description"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
// 步骤1:解析仓库信息
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 步骤2:获取必需参数 --url(用 RequireArg,如果没提供会报错)
|
||
webhookURL, err := ctx.RequireArg("url", "--url https://example.com/hook")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 步骤3:解析事件类型(逗号分隔,自动过滤无效事件)
|
||
events := parseEvents(ctx.Arg("events"))
|
||
if len(events) == 0 {
|
||
return clierrors.InputError(
|
||
"no valid events specified",
|
||
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
|
||
)
|
||
}
|
||
|
||
// 步骤4:构建请求体(payload),包含必填字段
|
||
payload := map[string]interface{}{
|
||
"url": webhookURL,
|
||
"http_method": "POST",
|
||
"active": true,
|
||
"content_type": "json",
|
||
}
|
||
|
||
// 添加事件列表
|
||
if len(events) > 0 {
|
||
payload["events"] = events
|
||
} else {
|
||
payload["events"] = []string{"push"}
|
||
}
|
||
|
||
// 添加可选参数:secret(签名密钥)
|
||
if secret := ctx.Arg("secret"); secret != "" {
|
||
payload["secret"] = secret
|
||
}
|
||
|
||
// 添加可选参数:description(描述)
|
||
if description := ctx.Arg("description"); description != "" {
|
||
payload["description"] = description
|
||
}
|
||
|
||
// 步骤5:发送 POST 请求创建 Webhook
|
||
env, err := ctx.CallAPI("POST", webhookRepoPath(ctx)+"/webhooks", payload)
|
||
if err != nil {
|
||
return fmt.Errorf("创建 Webhook 失败: %w", err)
|
||
}
|
||
// 步骤6:输出结果
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
// update 命令:更新现有的 Webhook
|
||
{
|
||
Name: "update",
|
||
Description: "Update an existing webhook",
|
||
Flags: []common.Flag{
|
||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||
{Name: "url", Short: "u", Usage: "Webhook callback URL"},
|
||
{Name: "events", Short: "e", Usage: "Trigger events (comma-separated)"},
|
||
{Name: "active", Usage: "Webhook active status (true/false)"},
|
||
{Name: "content_type", Usage: "Content type (json/form)"},
|
||
{Name: "secret", Usage: "Webhook secret for HMAC verification"},
|
||
{Name: "description", Short: "d", Usage: "Webhook description"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
// 步骤1:解析仓库信息
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 步骤2:获取必需参数 --id
|
||
webhookID, err := ctx.RequireArg("id", "--id 1")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 步骤3:初始化请求体
|
||
payload := map[string]interface{}{
|
||
"http_method": "POST",
|
||
"active": true,
|
||
"content_type": "json",
|
||
}
|
||
|
||
// 步骤4:智能获取 URL
|
||
// 如果用户没提供 URL,自动调用 GET API 获取当前 URL(避免用户重复输入)
|
||
webhookURL := ctx.Arg("url")
|
||
if webhookURL == "" {
|
||
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("获取 Webhook 当前信息失败: %w", err)
|
||
}
|
||
// 类型断言:把 Data 转换为 map[string]interface{}
|
||
webhookData, ok := getEnv.Data.(map[string]interface{})
|
||
if !ok {
|
||
return fmt.Errorf("failed to parse webhook data")
|
||
}
|
||
currentURL, ok := webhookData["url"].(string)
|
||
if !ok || currentURL == "" {
|
||
return fmt.Errorf("failed to get current webhook URL")
|
||
}
|
||
webhookURL = currentURL
|
||
}
|
||
payload["url"] = webhookURL
|
||
|
||
// 步骤5:添加可选参数
|
||
if events := ctx.Arg("events"); events != "" {
|
||
validEvents := parseEvents(events)
|
||
if len(validEvents) == 0 {
|
||
return clierrors.InputError(
|
||
"no valid events specified",
|
||
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
|
||
)
|
||
}
|
||
payload["events"] = validEvents
|
||
}
|
||
if contentType := ctx.Arg("content_type"); contentType != "" {
|
||
payload["content_type"] = contentType
|
||
}
|
||
if secret := ctx.Arg("secret"); secret != "" {
|
||
payload["secret"] = secret
|
||
}
|
||
if description := ctx.Arg("description"); description != "" {
|
||
payload["description"] = description
|
||
}
|
||
|
||
// 步骤6:发送 PUT 请求更新 Webhook
|
||
env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), payload)
|
||
if err != nil {
|
||
return fmt.Errorf("更新 Webhook 失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
// delete 命令:删除 Webhook(带双重验证机制)
|
||
{
|
||
Name: "delete",
|
||
Description: "Delete a webhook",
|
||
DryRun: true,
|
||
DryRunHint: func(ctx *common.RuntimeContext) (string, error) {
|
||
return fmt.Sprintf("删除 Webhook #%s", ctx.Arg("id")), nil
|
||
},
|
||
Flags: []common.Flag{
|
||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
// 步骤1:解析仓库信息
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 步骤2:获取必需参数 --id
|
||
webhookID, err := ctx.RequireArg("id", "--id 1")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 步骤3:发送 DELETE 请求
|
||
_, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||
if delErr != nil {
|
||
// 双重验证:如果 DELETE 失败,再调用 GET 检查 Webhook 是否还存在
|
||
_, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||
if viewErr != nil {
|
||
// GET 也失败,说明 Webhook 确实不存在了,视为删除成功
|
||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||
"message": "Webhook deleted successfully",
|
||
}, nil))
|
||
}
|
||
return fmt.Errorf("删除 Webhook 失败: %w", delErr)
|
||
}
|
||
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
|
||
"message": "Webhook deleted successfully",
|
||
}, nil))
|
||
},
|
||
},
|
||
// test 命令:测试 Webhook(发送测试请求)
|
||
{
|
||
Name: "test",
|
||
Description: "Test a webhook delivery (send a ping event)",
|
||
Flags: []common.Flag{
|
||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||
{Name: "event", Short: "e", Usage: "Event type to test (default: push)", Default: "push"},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
// 步骤1:解析仓库信息
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 步骤2:获取必需参数 --id
|
||
webhookID, err := ctx.RequireArg("id", "--id 1")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 步骤3:验证事件类型
|
||
eventType := ctx.Arg("event")
|
||
if !isEventSupported(eventType) {
|
||
return clierrors.InputError(
|
||
fmt.Sprintf("unsupported event type: %s", eventType),
|
||
fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")),
|
||
)
|
||
}
|
||
|
||
// 步骤4:发送测试请求
|
||
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("测试 Webhook 失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
// info 命令:查看 Webhook 详情
|
||
{
|
||
Name: "info",
|
||
Description: "Show webhook details",
|
||
Flags: []common.Flag{
|
||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
||
},
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
// 步骤1:解析仓库信息
|
||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 步骤2:获取必需参数 --id
|
||
webhookID, err := ctx.RequireArg("id", "--id 1")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 步骤3:调用 GET API 获取详情
|
||
env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
|
||
if err != nil {
|
||
return fmt.Errorf("查看 Webhook 详情失败: %w", err)
|
||
}
|
||
return ctx.Output(env)
|
||
},
|
||
},
|
||
// events 命令:列出所有支持的事件类型
|
||
{
|
||
Name: "events",
|
||
Description: "List all supported event types for webhooks",
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
// 构建事件信息列表
|
||
eventInfo := make([]map[string]interface{}, 0)
|
||
for _, event := range supportedEvents {
|
||
eventInfo = append(eventInfo, map[string]interface{}{
|
||
"event": event,
|
||
"supported": true,
|
||
"description": getEventDescription(event),
|
||
})
|
||
}
|
||
// 输出结果(用 SuccessEnvelope 包装)
|
||
return ctx.Output(output.SuccessEnvelope(eventInfo, nil))
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// getEventDescription 返回事件类型的英文描述
|
||
// 参数: event - 事件类型名称
|
||
// 返回: 事件描述,如果找不到则返回 "Custom event"
|
||
func getEventDescription(event string) string {
|
||
descriptions := map[string]string{
|
||
"push": "Code push events",
|
||
"pull_request": "Pull request events",
|
||
"issue": "Issue events",
|
||
"issue_assign": "Issue assignment events",
|
||
"issue_comment": "Issue comment events",
|
||
"pull_request_assign": "Pull request assignment events",
|
||
"pull_request_comment":"Pull request comment events",
|
||
"merge_request": "Merge request events",
|
||
"repository": "Repository events",
|
||
"branch": "Branch creation/deletion events",
|
||
"tag": "Tag creation/deletion events",
|
||
}
|
||
if desc, ok := descriptions[event]; ok {
|
||
return desc
|
||
}
|
||
return "Custom event"
|
||
}
|