gitlink-cli/doc/reading_notes/04_webhook.md

13 KiB
Raw Blame History

逐行讲解 shortcuts/webhook/webhook.go面向 Go 小白)

文件概述

这个文件实现了 Webhook 管理功能,可以对 GitLink 仓库的 Webhook 进行增删改查操作。


一、包声明和导入

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"
)
导入库 作用
fmt 格式化输出,用于拼接字符串和格式化错误信息
net/url URL 相关操作,用于构建查询参数
strings 字符串处理,用于分割、修剪等操作
clierrors 自定义 CLI 错误类型,用于返回友好的错误提示
output 输出格式化,用于返回统一格式的结果
common 公共工具包,包含 Shortcut、RuntimeContext 等核心类型

二、支持的 Webhook 事件类型

var supportedEvents = []string{
    "push",
    "pull_request",
    "issue",
    "issue_assign",
    "issue_comment",
    "pull_request_assign",
    "pull_request_comment",
    "merge_request",
    "repository",
    "branch",
    "tag",
}

这是一个全局变量,定义了 GitLink 支持的所有 Webhook 事件类型:

  • push:代码推送事件
  • pull_requestPR 事件
  • issueIssue 事件
  • issue_assignIssue 分配事件
  • issue_commentIssue 评论事件
  • pull_request_assignPR 分配事件
  • pull_request_commentPR 评论事件
  • merge_request:合并请求事件
  • repository:仓库事件
  • branch:分支创建/删除事件
  • tag:标签创建/删除事件

三、事件验证函数

func isEventSupported(event string) bool {
    for _, supported := range supportedEvents {
        if event == supported {
            return true
        }
    }
    return false
}

功能:检查某个事件类型是否被支持

工作原理:遍历 supportedEvents 数组,逐一比对,如果找到匹配项就返回 true,否则返回 false


四、解析事件字符串

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
}

功能:把用户输入的逗号分隔的事件字符串(如 "push,pull_request")解析成事件数组

逐行解读

  1. 如果输入为空,返回默认值 ["push"]
  2. 使用 strings.Split 按逗号分割字符串
  3. 遍历每个事件,用 strings.TrimSpace 去掉前后空格
  4. isEventSupported 验证有效性,有效才加入结果数组
  5. 返回过滤后的有效事件数组

五、API 路径构建函数

func webhookRepoPath(ctx *common.RuntimeContext) string {
    return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}

功能:构建 Webhook API 的基础路径

参数ctx 是运行时上下文,包含 Owner(仓库所有者)和 Repo(仓库名)

返回值:类似 /v1/owner/repo 的字符串

注意:注释说明了 BaseURL 已经包含 /api 前缀,所以这里不需要再加


六、Shortcuts 主函数

func Shortcuts() []*common.Shortcut {
    return []*common.Shortcut{
        // list 命令
        // create 命令
        // update 命令
        // delete 命令
        // test 命令
        // info 命令
        // events 命令
    }
}

功能:返回所有 Webhook 相关的 CLI 命令列表

这个函数是整个文件的核心它定义了7个命令

  1. list - 列出所有 Webhook
  2. create - 创建新 Webhook
  3. update - 更新现有 Webhook
  4. delete - 删除 Webhook
  5. test - 测试 Webhook 发送
  6. info - 查看 Webhook 详情
  7. events - 列出所有支持的事件类型

七、命令详解

7.1 list 命令

{
    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 {
        if err := ctx.ResolveOwnerRepo(); err != nil {
            return err
        }
        q := url.Values{}
        q.Set("page", ctx.Arg("page"))
        q.Set("limit", ctx.Arg("limit"))
        env, err := ctx.CallAPIWithQuery("GET", webhookRepoPath(ctx)+"/webhooks", q)
        if err != nil {
            return fmt.Errorf("获取 Webhook 列表失败: %w", err)
        }
        return ctx.Output(env)
    },
}

Flags 参数说明

  • --page/-p页码默认第1页
  • --limit/-l每页条数默认20条

执行流程

  1. 调用 ctx.ResolveOwnerRepo() 解析仓库信息
  2. 创建 URL 查询参数 url.Values{}
  3. 设置 pagelimit 参数
  4. 调用 CallAPIWithQuery 发送 GET 请求到 /v1/owner/repo/webhooks
  5. 返回结果给用户

7.2 create 命令

{
    Name:        "create",
    Description: "Create a new webhook",
    Flags: []common.Flag{
        {Name: "url", Short: "u", Usage: "Webhook callback URL", Required: true},
        {Name: "events", Short: "e", Usage: "Trigger events", Default: "push"},
        {Name: "active", Usage: "Webhook active status", Default: "true"},
        {Name: "secret", Usage: "Webhook secret for HMAC verification"},
        {Name: "description", Short: "d", Usage: "Webhook description"},
    },
    Run: func(ctx *common.RuntimeContext) error {
        if err := ctx.ResolveOwnerRepo(); err != nil {
            return err
        }

        webhookURL, err := ctx.RequireArg("url", "--url https://example.com/hook")
        if err != nil {
            return err
        }

        events := parseEvents(ctx.Arg("events"))
        if len(events) == 0 {
            return clierrors.InputError(...)
        }

        payload := map[string]interface{}{
            "url":          webhookURL,
            "http_method":  "POST",
            "active":       true,
            "content_type": "json",
        }

        // 添加可选参数
        if len(events) > 0 {
            payload["events"] = events
        }
        if secret := ctx.Arg("secret"); secret != "" {
            payload["secret"] = secret
        }
        if description := ctx.Arg("description"); description != "" {
            payload["description"] = description
        }

        env, err := ctx.CallAPI("POST", webhookRepoPath(ctx)+"/webhooks", payload)
        if err != nil {
            return fmt.Errorf("创建 Webhook 失败: %w", err)
        }
        return ctx.Output(env)
    },
}

执行流程

  1. 解析仓库信息
  2. 必须获取 --url 参数(用 RequireArg,如果没提供会报错)
  3. 解析事件类型
  4. 创建 payload 映射,包含必填字段
  5. 添加可选的 secret 和 description
  6. 发送 POST 请求创建 Webhook

7.3 update 命令

{
    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"},
        {Name: "active", Usage: "Webhook active status"},
        {Name: "content_type", Usage: "Content type"},
        {Name: "secret", Usage: "Webhook secret"},
        {Name: "description", Short: "d", Usage: "Webhook description"},
    },
    Run: func(ctx *common.RuntimeContext) error {
        // ... 解析仓库和 ID
        
        webhookURL := ctx.Arg("url")
        if webhookURL == "" {
            // 如果用户没提供 URL先获取当前 URL
            getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
            // ... 解析响应获取当前 URL
            webhookURL = currentURL
        }
        payload["url"] = webhookURL
        
        // ... 发送 PUT 请求
    },
}

亮点:如果用户没有提供新的 URL会自动调用 GET API 获取当前 URL这样就不需要用户重复输入


7.4 delete 命令

{
    Name:        "delete",
    Description: "Delete a webhook",
    Flags: []common.Flag{
        {Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
    },
    Run: func(ctx *common.RuntimeContext) error {
        // ... 解析仓库和 ID
        
        _, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
        if delErr != nil {
            // 验证是否真的删除成功
            _, 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(...))
    },
}

亮点:删除操作有一个双重验证机制:

  1. 先调用 DELETE 请求
  2. 如果 DELETE 返回错误,再调用 GET 请求检查 Webhook 是否还存在
  3. 如果 GET 也失败,说明 Webhook 已经被删除了,视为成功

7.5 test 命令

{
    Name:        "test",
    Description: "Test a webhook delivery",
    Flags: []common.Flag{
        {Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
        {Name: "event", Short: "e", Usage: "Event type to test", Default: "push"},
    },
    Run: func(ctx *common.RuntimeContext) error {
        // ... 解析参数
        
        eventType := ctx.Arg("event")
        if !isEventSupported(eventType) {
            return clierrors.InputError(...)
        }
        
        env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
        // ...
    },
}

功能:向指定的 Webhook 发送测试请求,验证 Webhook 是否正常工作


7.6 info 命令

{
    Name:        "info",
    Description: "Show webhook details",
    Flags: []common.Flag{
        {Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
    },
    Run: func(ctx *common.RuntimeContext) error {
        // ... 调用 GET /v1/owner/repo/webhooks/{id}
    },
}

功能:查看单个 Webhook 的详细信息


7.7 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),
            })
        }
        return ctx.Output(output.SuccessEnvelope(eventInfo, nil))
    },
}

功能:列出所有支持的 Webhook 事件类型及其描述


八、事件描述函数

func getEventDescription(event string) string {
    descriptions := map[string]string{
        "push":                "Code push events",
        "pull_request":        "Pull request events",
        "issue":               "Issue events",
        // ... 其他事件描述
    }
    if desc, ok := descriptions[event]; ok {
        return desc
    }
    return "Custom event"
}

功能:返回事件类型的英文描述

工作原理:使用 map 查找事件对应的描述,如果找不到就返回 "Custom event"


九、完整调用流程

用户命令 (gitlink webhook list)
    ↓
解析命令行参数
    ↓
Shortcuts() 返回命令列表
    ↓
匹配到 "list" 命令
    ↓
执行 Run 函数
    ↓
ctx.ResolveOwnerRepo() → 解析仓库信息
    ↓
ctx.CallAPIWithQuery() → 调用 HTTP 客户端
    ↓
内部调用 client.Do() → 发送 GET 请求
    ↓
解析响应 → ctx.Output() → 格式化输出给用户

十、Go 语言知识点

1. map[string]interface{} 类型

payload := map[string]interface{}{
    "url":          webhookURL,
    "http_method":  "POST",
    "active":       true,
}

这是一个万能类型,可以存储任意类型的值:

  • "url" 对应字符串
  • "active" 对应布尔值
  • "events" 对应字符串数组

2. 字符串拼接

fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)

类似 Python 的 f"/v1/{owner}/{repo}",用 %s 占位符

3. 错误包装

return fmt.Errorf("获取 Webhook 列表失败: %w", err)

%w 是 Go 1.13+ 的错误包装语法,保留原始错误信息

4. 函数作为参数

Run: func(ctx *common.RuntimeContext) error {
    // 匿名函数
}

这是一个匿名函数,作为 Shortcut 结构体的 Run 字段值

5. 字符串分割

events := strings.Split(eventsStr, ",")

按逗号分割字符串,返回字符串数组