gitlink-cli/shortcuts/webhook/webhook.go

407 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
}