forked from Gitlink/gitlink-cli
510 lines
13 KiB
Markdown
510 lines
13 KiB
Markdown
# 逐行讲解 shortcuts/webhook/webhook.go(面向 Go 小白)
|
||
|
||
## 文件概述
|
||
|
||
这个文件实现了 **Webhook 管理**功能,可以对 GitLink 仓库的 Webhook 进行增删改查操作。
|
||
|
||
---
|
||
|
||
## 一、包声明和导入
|
||
|
||
```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"
|
||
)
|
||
```
|
||
|
||
| 导入库 | 作用 |
|
||
|-------|------|
|
||
| `fmt` | 格式化输出,用于拼接字符串和格式化错误信息 |
|
||
| `net/url` | URL 相关操作,用于构建查询参数 |
|
||
| `strings` | 字符串处理,用于分割、修剪等操作 |
|
||
| `clierrors` | 自定义 CLI 错误类型,用于返回友好的错误提示 |
|
||
| `output` | 输出格式化,用于返回统一格式的结果 |
|
||
| `common` | 公共工具包,包含 Shortcut、RuntimeContext 等核心类型 |
|
||
|
||
---
|
||
|
||
## 二、支持的 Webhook 事件类型
|
||
|
||
```go
|
||
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_request`:PR 事件
|
||
- `issue`:Issue 事件
|
||
- `issue_assign`:Issue 分配事件
|
||
- `issue_comment`:Issue 评论事件
|
||
- `pull_request_assign`:PR 分配事件
|
||
- `pull_request_comment`:PR 评论事件
|
||
- `merge_request`:合并请求事件
|
||
- `repository`:仓库事件
|
||
- `branch`:分支创建/删除事件
|
||
- `tag`:标签创建/删除事件
|
||
|
||
---
|
||
|
||
## 三、事件验证函数
|
||
|
||
```go
|
||
func isEventSupported(event string) bool {
|
||
for _, supported := range supportedEvents {
|
||
if event == supported {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
```
|
||
|
||
**功能**:检查某个事件类型是否被支持
|
||
|
||
**工作原理**:遍历 `supportedEvents` 数组,逐一比对,如果找到匹配项就返回 `true`,否则返回 `false`
|
||
|
||
---
|
||
|
||
## 四、解析事件字符串
|
||
|
||
```go
|
||
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 路径构建函数
|
||
|
||
```go
|
||
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 主函数
|
||
|
||
```go
|
||
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 命令
|
||
|
||
```go
|
||
{
|
||
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. 设置 `page` 和 `limit` 参数
|
||
4. 调用 `CallAPIWithQuery` 发送 GET 请求到 `/v1/owner/repo/webhooks`
|
||
5. 返回结果给用户
|
||
|
||
---
|
||
|
||
### 7.2 create 命令
|
||
|
||
```go
|
||
{
|
||
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 命令
|
||
|
||
```go
|
||
{
|
||
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 命令
|
||
|
||
```go
|
||
{
|
||
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 命令
|
||
|
||
```go
|
||
{
|
||
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 命令
|
||
|
||
```go
|
||
{
|
||
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 命令
|
||
|
||
```go
|
||
{
|
||
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 事件类型及其描述
|
||
|
||
---
|
||
|
||
## 八、事件描述函数
|
||
|
||
```go
|
||
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{} 类型
|
||
|
||
```go
|
||
payload := map[string]interface{}{
|
||
"url": webhookURL,
|
||
"http_method": "POST",
|
||
"active": true,
|
||
}
|
||
```
|
||
|
||
这是一个**万能类型**,可以存储任意类型的值:
|
||
- `"url"` 对应字符串
|
||
- `"active"` 对应布尔值
|
||
- `"events"` 对应字符串数组
|
||
|
||
### 2. 字符串拼接
|
||
|
||
```go
|
||
fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
|
||
```
|
||
|
||
类似 Python 的 `f"/v1/{owner}/{repo}"`,用 `%s` 占位符
|
||
|
||
### 3. 错误包装
|
||
|
||
```go
|
||
return fmt.Errorf("获取 Webhook 列表失败: %w", err)
|
||
```
|
||
|
||
`%w` 是 Go 1.13+ 的错误包装语法,保留原始错误信息
|
||
|
||
### 4. 函数作为参数
|
||
|
||
```go
|
||
Run: func(ctx *common.RuntimeContext) error {
|
||
// 匿名函数
|
||
}
|
||
```
|
||
|
||
这是一个**匿名函数**,作为 `Shortcut` 结构体的 `Run` 字段值
|
||
|
||
### 5. 字符串分割
|
||
|
||
```go
|
||
events := strings.Split(eventsStr, ",")
|
||
```
|
||
|
||
按逗号分割字符串,返回字符串数组 |