feat: 修复 milestone +view 输出 + issue/board assign 字段修正 + 文档更新

- 修复 milestone +view 显示 No results 的问题(提取 milestone 字段输出)
- 修正 issue/board 的 assign 字段:assigned_to_id -> assigner_ids
- 添加代码逻辑文档、CLI 优化报告、reading notes
- 添加 board 功能示例和修改笔记
This commit is contained in:
Donkey_kevin 2026-07-10 11:06:11 +08:00
parent 19b0e31bf8
commit 4b2d45baea
18 changed files with 6467 additions and 5 deletions

View File

@ -0,0 +1,450 @@
# PR 格式模板(以 board 功能为例)
## PR 标题
```
feat(board): 新增项目看板 shortcut — 查看/筛选/移动/指派/统计
```
格式:`type(scope): 简述`,与仓库现有 commit 风格一致。
---
## PR 描述
```markdown
## Summary
- 新增 `board` 命令组,提供 6 个看板操作子命令
- 基于 issue list API 实现看板视图(按 status_id 分组为 5 列)
- 写操作(+move/+assign复用 issue PATCH API支持 --dry-run
- 包含单元测试和帮助文档
## Changes
### 新增文件
- `shortcuts/board/board.go` — 6 个命令 + 辅助函数
- `shortcuts/board/board_test.go` — 单元测试
### 修改文件
- `shortcuts/register.go` — 注册 board 组
## Commands
| 命令 | 类型 | 说明 |
|------|------|------|
| `board +view` | 读 | 按状态分组显示看板 |
| `board +columns` | 读 | 列出各状态列及 issue 数量 |
| `board +issues` | 读 | 按状态/指派人/优先级筛选 |
| `board +move` | 写 | 移动任务状态 |
| `board +assign` | 写 | 指派任务 |
| `board +stats` | 读 | 完成率/工作负载/瓶颈分析 |
## Test plan
- [x] `go test ./shortcuts/board/...` 通过
- [x] `go build ./...` 编译通过
- [x] `board +view` 输出正确的看板结构
- [x] `board +columns` 返回 5 列
- [x] `board +issues --status in-progress` 筛选正确
- [x] `board +move --dry-run` 预览不执行
- [x] `board +assign --dry-run` 预览不执行
- [x] `board +stats` 完成率计算正确
🤖 Generated with [Claude Code](https://claude.com/claude-code)
```
---
## Commit 规范
仓库使用 conventional commits 格式:
```
type(scope): description
```
常用 type
- `feat` — 新功能
- `fix` — 修复
- `refactor` — 重构
- `docs` — 文档
- `test` — 测试
board 功能的 commit 示例:
```
feat(board): 新增 board shortcut — 看板查看/筛选/移动/指派/统计
test(board): 添加 board 命令单元测试
```
如果拆成多个 commit
```
feat(board): 新增 board +view/+columns/+issues 读命令
feat(board): 新增 board +move/+assign 写命令
test(board): 添加 board 命令单元测试
```
---
## 单元测试模板
仓库测试风格:用 `httptest.NewServer` mock API直接构造 `RuntimeContext` 调用 `Run` 函数。
### 文件:`shortcuts/board/board_test.go`
```go
package board
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// === mock server ===
func newBoardTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(payload)
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
return payload
}
// 模拟 issue list API 响应
func mockIssueListResponse() map[string]interface{} {
return map[string]interface{}{
"issues": []interface{}{
map[string]interface{}{
"id": 101,
"subject": "Fix login bug",
"status_id": float64(1),
"status_name": "待处理",
"priority_id": float64(2),
"priority_name": "正常",
"project_issues_index": float64(1),
"assigners": []interface{}{},
},
map[string]interface{}{
"id": 102,
"subject": "Add dark mode",
"status_id": float64(2),
"status_name": "进行中",
"priority_id": float64(3),
"priority_name": "高",
"project_issues_index": float64(2),
"assigners": []interface{}{
map[string]interface{}{"login": "zhangsan", "name": "Zhang San"},
},
},
map[string]interface{}{
"id": 103,
"subject": "Update README",
"status_id": float64(3),
"status_name": "已解决",
"priority_id": float64(1),
"priority_name": "低",
"project_issues_index": float64(3),
"assigners": []interface{}{},
},
},
"total_count": float64(3),
"total_issues_count": float64(3),
}
}
// === helper ===
func runBoardShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findBoardShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findBoardShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
// === 测试用例 ===
func TestBoardViewGroupsByStatus(t *testing.T) {
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issues") {
writeJSON(t, w, mockIssueListResponse())
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runBoardShortcut(t, server, "view", map[string]string{"state": "all"})
if err != nil {
t.Fatalf("board +view failed: %v", err)
}
// 验证view 命令不报错即通过,输出由 ctx.OutputData 处理
}
func TestBoardColumnsReturnsAllStatuses(t *testing.T) {
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issues") {
writeJSON(t, w, mockIssueListResponse())
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
// columns 命令输出到 stdout这里只验证不报错
err := runBoardShortcut(t, server, "columns", map[string]string{"state": "all"})
if err != nil {
t.Fatalf("board +columns failed: %v", err)
}
}
func TestBoardIssuesFilterByStatus(t *testing.T) {
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" && strings.Contains(r.URL.Path, "/issues") {
writeJSON(t, w, mockIssueListResponse())
return
}
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runBoardShortcut(t, server, "issues", map[string]string{
"state": "all",
"status": "in-progress",
})
if err != nil {
t.Fatalf("board +issues --status in-progress failed: %v", err)
}
}
func TestBoardMoveSendsCorrectStatusID(t *testing.T) {
var patchPayload map[string]interface{}
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issues/1"):
writeJSON(t, w, map[string]interface{}{
"subject": "Fix login bug",
"description": "Steps to reproduce...",
})
case r.Method == "PATCH" && strings.Contains(r.URL.Path, "/issues/1"):
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runBoardShortcut(t, server, "move", map[string]string{
"number": "1",
"status": "in-progress",
})
if err != nil {
t.Fatalf("board +move failed: %v", err)
}
// 验证 PATCH body 包含正确的 status_id
if patchPayload["status_id"] != float64(2) {
t.Errorf("expected status_id=2, got %v", patchPayload["status_id"])
}
// 验证 subject 和 description 被保留
if patchPayload["subject"] != "Fix login bug" {
t.Errorf("subject not preserved: got %v", patchPayload["subject"])
}
if patchPayload["description"] != "Steps to reproduce..." {
t.Errorf("description not preserved: got %v", patchPayload["description"])
}
}
func TestBoardAssignSendsAssignedToID(t *testing.T) {
var patchPayload map[string]interface{}
server := newBoardTestServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/users/zhangsan.json":
writeJSON(t, w, map[string]interface{}{"id": float64(999), "login": "zhangsan"})
case r.Method == "GET" && strings.Contains(r.URL.Path, "/issues/1"):
writeJSON(t, w, map[string]interface{}{
"subject": "Fix login bug",
"description": "Steps to reproduce...",
})
case r.Method == "PATCH" && strings.Contains(r.URL.Path, "/issues/1"):
patchPayload = decodeJSON(t, r)
writeJSON(t, w, patchPayload)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
})
defer server.Close()
err := runBoardShortcut(t, server, "assign", map[string]string{
"number": "1",
"assignee": "zhangsan",
})
if err != nil {
t.Fatalf("board +assign failed: %v", err)
}
if patchPayload["assigned_to_id"] != float64(999) {
t.Errorf("expected assigned_to_id=999, got %v", patchPayload["assigned_to_id"])
}
}
func TestParseStatusID(t *testing.T) {
tests := []struct {
input string
want int
err bool
}{
{"new", 1, false},
{"in-progress", 2, false},
{"in_progress", 2, false},
{"resolved", 3, false},
{"closed", 5, false},
{"rejected", 6, false},
{"进行中", 2, false},
{"42", 42, false},
{"invalid", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parseStatusID(tt.input)
if tt.err && err == nil {
t.Errorf("expected error for %q", tt.input)
}
if !tt.err && err != nil {
t.Errorf("unexpected error for %q: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("parseStatusID(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestParsePriorityID(t *testing.T) {
tests := []struct {
input string
want int
err bool
}{
{"low", 1, false},
{"normal", 2, false},
{"high", 3, false},
{"urgent", 4, false},
{"99", 99, false},
{"invalid", 0, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := parsePriorityID(tt.input)
if tt.err && err == nil {
t.Errorf("expected error for %q", tt.input)
}
if !tt.err && err != nil {
t.Errorf("unexpected error for %q: %v", tt.input, err)
}
if got != tt.want {
t.Errorf("parsePriorityID(%q) = %d, want %d", tt.input, got, tt.want)
}
})
}
}
func TestGroupByStatus(t *testing.T) {
issues := []issueItem{
{ID: 1, StatusID: 1},
{ID: 2, StatusID: 2},
{ID: 3, StatusID: 2},
{ID: 4, StatusID: 3},
}
grouped := groupByStatus(issues)
if len(grouped[1]) != 1 {
t.Errorf("expected 1 issue in status 1, got %d", len(grouped[1]))
}
if len(grouped[2]) != 2 {
t.Errorf("expected 2 issues in status 2, got %d", len(grouped[2]))
}
if len(grouped[3]) != 1 {
t.Errorf("expected 1 issue in status 3, got %d", len(grouped[3]))
}
}
```
---
## 帮助文档更新
board 的帮助信息已经在 `board.go``Description`/`Long`/`Example` 字段中定义,`board --help` 会自动输出。无需额外文档文件。
如果要更新项目 README 或 skill 文档,在对应文件中添加:
```markdown
### Board (看板)
```bash
# 查看看板
gitlink-cli board +view
# 按状态筛选
gitlink-cli board +issues --status in-progress --assignee zhangsan
# 移动任务
gitlink-cli board +move --number 42 --status resolved
# 统计分析
gitlink-cli board +stats
```
```
---
## PR Checklist
```markdown
## Checklist
- [ ] `go build ./...` 编译通过
- [ ] `go test ./shortcuts/board/...` 测试通过
- [ ] `go vet ./...` 无警告
- [ ] 新命令 `--help` 输出正确
- [ ] 写命令支持 `--dry-run`
- [ ] 错误信息使用 `clierrors.OpError` 包装
- [ ] commit message 符合 `type(scope): description` 格式
```

View File

@ -0,0 +1,552 @@
# shortcuts/common/types.go 阅读笔记(面向 Go 小白)
***
## 第 1 行:`package common`
**字面意思**:声明这个文件属于 `common`
**运行时作用**:这是一个通用工具包,里面定义的类型和函数可以被所有其他 shortcut 模块wiki、webhook、issue 等)复用。
**小白补充**
- 包名 `common` 表示"公共的",说明这里的内容是大家都需要用的
- 其他文件通过 `import "github.com/gitlink-org/gitlink-cli/shortcuts/common"` 来使用
***
## 第 3-18 行import 导入依赖
```go
import (
"bufio" // 缓冲输入(用于读取用户确认)
"encoding/json" // JSON 处理
"fmt" // 格式化输出
"net/http" // HTTP 客户端
"net/url" // URL 处理
"os" // 操作系统交互
"strings" // 字符串操作
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil" // 命令行工具(全局变量)
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端
"github.com/gitlink-org/gitlink-cli/internal/config" // 配置管理
"github.com/gitlink-org/gitlink-cli/internal/context" // 上下文解析owner/repo
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
)
```
**小白补充**
| 包名 | 用途 |
| ------------------ | ----------------------------------------------------------- |
| `bufio` | 读取用户输入(比如确认操作时的 y/N |
| `cmd/cmdutil` | 存放全局变量(如 `cmdutil.Owner`, `cmdutil.Repo`, `cmdutil.Format` |
| `internal/config` | 加载配置文件 |
| `internal/context` | 从 git remote 解析 owner/repo |
***
## 第 20-28 行:`Shortcut` 结构体(核心!)
```go
type Shortcut struct {
Name string
Description string
Flags []Flag
DryRun bool
DryRunHint func(ctx *RuntimeContext) (string, error)
Run func(ctx *RuntimeContext) error
}
```
**字面意思**:定义一个名为 `Shortcut` 的结构体类型
**运行时作用**:这是整个 CLI 命令系统的**核心数据结构**,每个 `Shortcut` 代表一个可执行的命令(如 `wiki +list`, `issue +create`)。
**小白补充**
### ① 结构体是什么?
结构体struct是 Go 语言中用来**组织相关数据和函数**的方式。可以把它想象成一个"数据容器",里面装着各种属性。
### ② 每个字段的含义:
| 字段 | 类型 | 含义 |
| ------------- | ------------------------------------------- | -------------------------- |
| `Name` | `string` | 命令名,用户通过 `+name` 调用 |
| `Description` | `string` | 命令描述,`--help` 时显示 |
| `Flags` | `[]Flag` | 命令行参数列表(如 `--title`, `-t` |
| `DryRun` | `bool` | 是否支持预览模式(`--dry-run` |
| `DryRunHint` | `func(ctx *RuntimeContext) (string, error)` | 预览时显示的提示信息 |
| `Run` | `func(ctx *RuntimeContext) error` | **真正执行的函数**,命令的核心逻辑 |
### ③ 函数类型字段:
注意 `DryRunHint``Run` 的类型是**函数**!这在 Go 中是完全合法的,函数可以作为结构体的字段。
```go
Run func(ctx *RuntimeContext) error
```
- 这表示 `Run` 字段存储了一个**函数**
- 这个函数接收 `*RuntimeContext` 类型的参数
- 返回 `error` 类型的值(如果执行失败)
***
## 第 30-38 行:`Flag` 结构体
```go
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
}
```
**字面意思**:定义命令行参数的结构
**运行时作用**:描述一个命令行参数,比如 `--title "Home"``-t "Home"`
**小白补充**
| 字段 | 含义 | 例子 |
| ---------- | ------ | ------------------------- |
| `Name` | 参数名 | `"title"``--title` |
| `Short` | 短参数名 | `"t"``-t` |
| `Usage` | 帮助说明 | `"Page title"` |
| `Required` | 是否必填 | `true` → 用户必须提供 |
| `Default` | 默认值 | `"1"` → 不提供时使用的默认值 |
| `Bool` | 是否布尔类型 | `true``--dry-run` 不需要值 |
***
## 第 40-50 行:`RuntimeContext` 结构体(核心!)
```go
type RuntimeContext struct {
Client *client.Client
Owner string
Repo string
Format string
CommandName string
Args map[string]string
GatewayBaseURL string
GatewayHTTPClient *http.Client
}
```
**字面意思**:定义运行时上下文的结构
**运行时作用**:这是每个命令执行时的**全局环境**,包含了所有需要的信息。
**小白补充**
### ① 为什么需要 RuntimeContext
每个命令执行时都需要很多信息:
- 用哪个 HTTP 客户端发请求?
- 当前操作的仓库是哪个owner/repo
- 输出格式是 JSON 还是 Table
- 用户传入了哪些参数?
`RuntimeContext` 把这些信息打包在一起,方便传递和使用。
### ② 每个字段的含义:
| 字段 | 类型 | 含义 |
| ------------------- | ------------------- | --------------------------- |
| `Client` | `*client.Client` | HTTP 客户端,用来调用 GitLink API |
| `Owner` | `string` | 仓库所有者(如 `zzx-coder` |
| `Repo` | `string` | 仓库名称(如 `gitlink-cli` |
| `Format` | `string` | 输出格式(`json`/`table`/`yaml` |
| `CommandName` | `string` | 当前命令名(如 `"wiki +list"` |
| `Args` | `map[string]string` | 用户传入的所有参数key-value |
| `GatewayBaseURL` | `string` | Wiki Gateway API 的地址 |
| `GatewayHTTPClient` | `*http.Client` | 可选的自定义 HTTP 客户端(主要用于测试) |
### ③ `*client.Client` 是什么?
- `*` 表示这是一个**指针**类型
- `client.Client``internal/client` 包中定义的结构体
- 指针的好处:避免拷贝大对象,多个地方共享同一个实例
***
## 第 52-80 行:`NewRuntimeContext` 函数
```go
func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeContext, error) {
// 1. 创建 HTTP 客户端
cli, err := client.New()
if err != nil {
return nil, err
}
cli.Debug = cmdutil.Debug // 设置调试模式
// 2. 确定输出格式
format := cmdutil.Format
if format == "" {
format = "json" // 默认 JSON 格式
}
// 3. 获取 Gateway URL
gatewayBaseURL := config.DefaultGatewayBaseURL
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
gatewayBaseURL = cfg.GatewayBaseURL // 使用配置文件中的地址
}
// 4. 创建并返回 RuntimeContext
return &RuntimeContext{
Client: cli,
Owner: cmdutil.Owner,
Repo: cmdutil.Repo,
Format: format,
CommandName: commandName,
Args: args,
GatewayBaseURL: gatewayBaseURL,
GatewayHTTPClient: nil,
}, nil
}
```
**字面意思**:创建一个新的 RuntimeContext 实例
**运行时作用**:这是 `RuntimeContext` 的**构造函数**,负责初始化所有字段。
**小白补充**
### ① 构造函数模式:
Go 没有专门的构造函数语法,通常约定用 `NewXXX()` 函数来创建结构体实例。
### ② `cmdutil` 是什么?
`cmdutil``cmd/cmdutil/globals.go` 中定义的全局变量模块:
```go
// cmd/cmdutil/globals.go 中定义
var (
Owner string // 通过 --owner 参数设置
Repo string // 通过 --repo 参数设置
Format string // 通过 --format 参数设置
Debug bool // 通过 --debug 参数设置
)
```
这些是**全局变量**,在命令行参数解析时被赋值,然后在这里被读取。
### ③ 配置加载:
```go
gatewayBaseURL := config.DefaultGatewayBaseURL
if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" {
gatewayBaseURL = cfg.GatewayBaseURL
}
```
- 先使用默认值 `config.DefaultGatewayBaseURL`
- 尝试加载配置文件,如果配置文件中有自定义的 Gateway URL就使用配置文件中的值
***
## 第 82-91 行:`ResolveOwnerRepo` 方法
```go
func (ctx *RuntimeContext) ResolveOwnerRepo() error {
owner, repo, err := context.ResolveOwnerRepo(ctx.Owner, ctx.Repo)
if err != nil {
return err
}
ctx.Owner = owner
ctx.Repo = repo
return nil
}
```
**字面意思**:解析 owner 和 repo
**运行时作用**:这是 `RuntimeContext` 的**方法**,用来确定当前操作的仓库。
**小白补充**
### ① 方法是什么?
方法是和结构体绑定的函数。在 Go 中:
```go
func (ctx *RuntimeContext) ResolveOwnerRepo() error {
// ...
}
```
- `(ctx *RuntimeContext)` 表示这个函数绑定到 `RuntimeContext` 类型
- `ctx` 是方法内部的**接收器**receiver类似其他语言的 `this``self`
- 调用方式:`ctx.ResolveOwnerRepo()`
### ② 解析逻辑:
`context.ResolveOwnerRepo(ctx.Owner, ctx.Repo)` 的作用:
1. 如果用户通过 `--owner``--repo` 参数明确指定了,直接使用
2. 如果没有指定,尝试从当前目录的 `git remote` 中自动解析
***
## 第 93-96 行:`CallAPI` 方法
```go
func (ctx *RuntimeContext) CallAPI(method, path string, body interface{}) (*output.Envelope, error) {
return ctx.Client.Do(method, path, body, nil)
}
```
**字面意思**:调用 API无查询参数
**运行时作用**:封装 HTTP 请求,是所有 API 调用的入口。
**小白补充**
- 这是一个**包装方法**,把 `ctx.Client.Do()` 包装一层
- 其他模块只需要调用 `ctx.CallAPI()` 就能发送请求,不需要关心底层的 `client.Client`
***
## 第 98-101 行:`CallAPIWithQuery` 方法
```go
func (ctx *RuntimeContext) CallAPIWithQuery(method, path string, query url.Values) (*output.Envelope, error) {
return ctx.Client.Do(method, path, nil, query)
}
```
**字面意思**:调用 API带查询参数
**运行时作用**:和 `CallAPI` 类似,但支持 URL 查询参数(`?key=value`)。
***
## 第 103-106 行:`PaginateAll` 方法
```go
func (ctx *RuntimeContext) PaginateAll(path string, params url.Values) ([]json.RawMessage, error) {
return ctx.Client.PaginateAll(path, params)
}
```
**字面意思**:获取所有分页数据
**运行时作用**:处理分页 API自动获取所有页的数据。
**小白补充**
- GitLink API 常用分页返回大量数据(如 `page=1&limit=20`
- `PaginateAll` 会自动遍历所有页,把结果合并成一个大列表
***
## 第 108-111 行:`Output` 方法
```go
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
return output.Print(env, ctx.Format)
}
```
**字面意思**:输出结果
**运行时作用**根据用户指定的格式JSON/Table/YAML输出 API 响应。
***
## 第 113-116 行:`OutputData` 方法
```go
func (ctx *RuntimeContext) OutputData(data interface{}) error {
return output.Print(output.SuccessEnvelope(data, nil), ctx.Format)
}
```
**字面意思**:输出数据(自动包装成 Envelope
**运行时作用**:如果只有数据,没有完整的 Envelope可以用这个方法自动包装。
**小白补充**
- `output.SuccessEnvelope(data, nil)` 创建一个成功的包装结构:`{"ok": true, "data": ...}`
***
## 第 118-121 行:`RepoPath` 方法
```go
func (ctx *RuntimeContext) RepoPath() string {
return fmt.Sprintf("/%s/%s", ctx.Owner, ctx.Repo)
}
```
**字面意思**:返回仓库的 API 路径前缀
**运行时作用**:生成 `/owner/repo` 格式的路径,避免重复拼接。
***
## 第 123-129 行:`Arg` 方法
```go
func (ctx *RuntimeContext) Arg(name string) string {
if v, ok := ctx.Args[name]; ok {
return v
}
return ""
}
```
**字面意思**:获取命令行参数值
**运行时作用**:从 `ctx.Args` map 中获取指定参数的值。
**小白补充**
- `ctx.Args``map[string]string` 类型
- 调用方式:`ctx.Arg("title")` → 获取 `--title` 参数的值
***
## 第 131-145 行:`RequireArg` 方法(核心!)
```go
func (ctx *RuntimeContext) RequireArg(name, example string) (string, error) {
v := ctx.Arg(name)
if v == "" {
suggestion := fmt.Sprintf("请提供 --%s 参数", name)
if example != "" {
suggestion += fmt.Sprintf(",例如:%s", example)
}
return "", clierrors.InputError(
fmt.Sprintf("required flag --%s is missing", name),
suggestion,
).WithCommand(ctx.CommandName)
}
return v, nil
}
```
**字面意思**:获取必填参数,如果缺失则返回错误
**运行时作用**:强制检查必填参数,确保用户提供了必要的输入。
**小白补充**
### ① 使用场景:
```go
title, err := ctx.RequireArg("title", `--title "Home Page"`)
if err != nil {
return err // 用户没提供 --title直接返回错误
}
```
### ② 错误处理:
如果用户没提供参数,会返回一个 `CLIError`,包含:
- `Kind`: `KindInput`(输入错误)
- `Message`: `"required flag --title is missing"`
- `Suggestion`: `"请提供 --title 参数,例如:--title \"Home Page\""`
***
## 第 147-150 行:`IsDryRun` 方法
```go
func (ctx *RuntimeContext) IsDryRun() bool {
return ctx.Arg("dry-run") == "true"
}
```
**字面意思**:检查是否是预览模式
**运行时作用**:判断用户是否传入了 `--dry-run` 参数。
***
## 第 152-166 行:`ConfirmAction` 函数
```go
func ConfirmAction(ctx *RuntimeContext) (bool, error) {
if !ctx.IsDryRun() {
return true, nil // 不是预览模式,直接执行
}
// 预览模式,提示用户确认
fmt.Fprint(os.Stderr, "\nProceed? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "y" || answer == "yes" {
return true, nil // 用户确认,继续执行
}
fmt.Fprintln(os.Stderr, "Aborted.")
return false, nil // 用户取消,不执行
}
```
**字面意思**:确认操作(预览模式下)
**运行时作用**:在 `--dry-run` 模式下,提示用户确认是否真的要执行操作。
**小白补充**
### ① `fmt.Fprint(os.Stderr, ...)`
- `os.Stderr` 是标准错误输出流
- 把提示信息输出到 stderr 而不是 stdout这样 stdout 可以保持干净(用于管道输出)
### ② `bufio.NewReader(os.Stdin)`
- `os.Stdin` 是标准输入流(用户键盘输入)
- `bufio.NewReader` 创建一个缓冲读取器,用来读取用户输入
***
## 调用关系图
```
NewRuntimeContext(args, commandName)
↓ 创建
RuntimeContext{
Client: client.New(), // HTTP 客户端
Owner: cmdutil.Owner, // 全局变量
Repo: cmdutil.Repo, // 全局变量
Format: cmdutil.Format, // 全局变量
Args: args, // 命令行参数
}
RuntimeContext 的方法:
├── ResolveOwnerRepo() → 解析 owner/repo自动或手动
├── CallAPI() → 调用 API无参数
├── CallAPIWithQuery() → 调用 API带参数
├── PaginateAll() → 获取所有分页数据
├── Output() → 输出结果
├── OutputData() → 输出数据(自动包装)
├── RepoPath() → 返回 /owner/repo 路径
├── Arg() → 获取参数值
├── RequireArg() → 获取必填参数(缺则报错)
└── IsDryRun() → 检查预览模式
Shortcut 结构体:
├── Name: "list"
├── Flags: [{Name:"title", Short:"t", Required:true}]
└── Run: func(ctx *RuntimeContext) error {
// 命令执行逻辑
}
```

View File

@ -0,0 +1,560 @@
# internal/client/client.go 阅读笔记(面向 Go 小白)
---
## 第 1 行:`package client`
**字面意思**:声明这个文件属于 `client`
**运行时作用**:这是项目的 HTTP 客户端模块,负责所有与 GitLink API 的通信。
---
## 第 3-16 行import 导入依赖
```go
import (
"bytes" // 字节缓冲(用于构造请求体)
"encoding/json" // JSON 序列化/反序列化
"fmt" // 格式化输出
"io" // 输入输出接口
"net/http" // HTTP 协议
"net/url" // URL 处理
"strings" // 字符串操作
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块(带 Token 的 HTTP 客户端)
"github.com/gitlink-org/gitlink-cli/internal/config" // 配置管理
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
)
```
**小白补充**
| 包名 | 用途 | 在本文件中的作用 |
|------|------|-----------------|
| `bytes` | 字节操作 | 把 JSON 数据转成 HTTP 请求体 |
| `io` | 输入输出 | 读取 HTTP 响应体 |
| `net/http` | HTTP 协议 | 创建和发送 HTTP 请求 |
---
## 第 18-23 行:`Client` 结构体(核心!)
```go
type Client struct {
HTTP *http.Client
BaseURL string
Debug bool
SkipJSONSuffix bool
}
```
**字面意思**:定义 HTTP 客户端的结构
**运行时作用**:这是项目封装的 HTTP 客户端,所有 API 调用都通过它来完成。
**小白补充**
### ① 每个字段的含义:
| 字段 | 类型 | 含义 |
|------|------|------|
| `HTTP` | `*http.Client` | Go 标准库的 HTTP 客户端(核心) |
| `BaseURL` | `string` | API 基础地址(如 `https://www.gitlink.org.cn/api` |
| `Debug` | `bool` | 是否开启调试模式(打印请求/响应) |
| `SkipJSONSuffix` | `bool` | 是否跳过自动添加 `.json` 后缀Wiki Gateway 需要) |
### ② `*http.Client` 是什么?
`http.Client` 是 Go 标准库提供的 HTTP 客户端,它包含:
- 连接池管理
- 超时设置
- Cookie 管理
- 传输层配置(如 TLS、代理
我们项目在 `internal/auth/transport.go` 中对它进行了扩展,自动添加认证 Token。
---
## 第 25-35 行:`APIError` 结构体
```go
type APIError struct {
StatusCode int
Code interface{}
Message string
Kind clierrors.ErrorKind
Suggestion string
}
func (e *APIError) Error() string {
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
```
**字面意思**:定义 API 错误的结构
**运行时作用**:封装 API 返回的错误信息,包含错误码、消息和解决建议。
**小白补充**
### ① `Error()` 方法:
```go
func (e *APIError) Error() string {
return fmt.Sprintf("[%v] %s", e.Code, e.Message)
}
```
- 这是实现了 Go 的 `error` 接口
- 任何实现了 `Error() string` 方法的类型都可以作为 `error` 返回
- 这样 `APIError` 就可以像普通错误一样使用:`return apiErr`
### ② 为什么需要自定义错误类型?
普通的 `error` 只能包含一条消息,而我们需要:
- `StatusCode`HTTP 状态码404/403/500 等)
- `Code`API 返回的业务错误码
- `Kind`:错误分类(认证错误/输入错误/服务器错误等)
- `Suggestion`:给用户的解决建议
---
## 第 37-46 行:`New` 函数(构造函数)
```go
func New() (*Client, error) {
// 1. 加载配置
cfg, err := config.Load()
if err != nil {
return nil, err
}
// 2. 创建并返回 Client
return &Client{
HTTP: auth.NewHTTPClient(), // 带认证的 HTTP 客户端
BaseURL: cfg.BaseURL, // 从配置获取 API 地址
}, nil
}
```
**字面意思**:创建一个新的 Client 实例
**运行时作用**:这是 Client 的构造函数,自动加载配置并创建带认证的 HTTP 客户端。
**小白补充**
### ① `auth.NewHTTPClient()` 做了什么?
这个函数在 `internal/auth/transport.go` 中,它创建了一个 HTTP 客户端,并且:
- 自动从配置文件读取 Token
- 在每个请求的 `Authorization` 头中添加 `Bearer {token}`
- 处理 Token 过期等情况
### ② 配置文件的内容:
配置文件位于 `~/.config/gitlink-cli/config.yaml`,内容大致如下:
```yaml
base_url: https://www.gitlink.org.cn/api
gateway_base_url: https://gateway.gitlink.org.cn/api
token: your-token-here
```
---
## 第 48-168 行:`Do` 方法(核心!)
这是整个文件中**最重要的函数**,负责发送 HTTP 请求并解析响应。
### ① 路径处理(第 48-67 行)
```go
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
// 自动添加 .json 后缀GitLink API 约定)
if c.shouldAppendJSONSuffix(path) {
if idx := strings.Index(path, "?"); idx != -1 {
// 路径已经包含查询参数,在 ? 前面加 .json
basePath := path[:idx]
queryStr := path[idx:]
path = basePath + ".json" + queryStr
} else {
// 路径没有查询参数,直接加 .json
path += ".json"
}
}
// 构建完整 URL
fullURL := c.BaseURL + path
if query != nil && len(query) > 0 {
sep := "?"
if strings.Contains(fullURL, "?") {
sep = "&" // URL 已经有 ?,用 & 连接
}
fullURL += sep + query.Encode()
}
// ...
}
```
**字面意思**:处理请求路径,构建完整 URL
**运行时作用**GitLink API 约定所有路径都需要 `.json` 后缀,这里自动添加。
**小白补充**
- `c.BaseURL``https://www.gitlink.org.cn/api`
- `path``/users/me`
- 最终 `fullURL` 变成 `https://www.gitlink.org.cn/api/users/me.json`
### ② 请求体处理(第 69-77 行)
```go
// 处理请求体
var bodyReader io.Reader
if body != nil {
// 把 body 序列化成 JSON
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
// 转成 io.ReaderHTTP 请求需要的格式)
bodyReader = bytes.NewReader(data)
}
```
**字面意思**:把请求体转成 HTTP 可以发送的格式
**运行时作用**:如果有请求体(如 POST/PUT 请求),把 Go 的 map 转成 JSON 字符串,再转成字节流。
**小白补充**
- `json.Marshal(body)`:把 Go 结构体/map 转成 JSON 字节数组
- `bytes.NewReader(data)`:把字节数组包装成 `io.Reader`HTTP 请求体需要这个接口)
### ③ 创建 HTTP 请求(第 79-87 行)
```go
// 创建 HTTP 请求
req, err := http.NewRequest(method, fullURL, bodyReader)
if err != nil {
return nil, err
}
// 调试模式:打印请求信息
if c.Debug {
fmt.Printf("→ %s %s\n", method, fullURL)
}
```
**字面意思**:创建一个 HTTP 请求对象
**运行时作用**`http.NewRequest` 创建请求对象包含方法、URL 和请求体。
### ④ 发送请求(第 89-92 行)
```go
// 发送请求
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close() // 确保响应体被关闭
```
**字面意思**:发送 HTTP 请求并获取响应
**运行时作用**`c.HTTP.Do(req)` 发送请求,返回响应对象。
**小白补充**
- `defer resp.Body.Close()`**非常重要!** 确保响应体被关闭,避免资源泄漏
- `defer` 是 Go 的关键字,它会在函数返回前执行后面的语句
- 如果不关闭 `resp.Body`HTTP 连接池会被占满,导致后续请求失败
### ⑤ 读取响应体(第 94-101 行)
```go
// 读取响应体
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// 调试模式:打印响应信息
if c.Debug {
fmt.Printf("← %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)]))
}
```
**字面意思**:把响应体读取成字节数组
**运行时作用**`io.ReadAll(resp.Body)` 读取整个响应体内容。
**小白补充**
- `resp.StatusCode` 是 HTTP 状态码200=成功404=未找到500=服务器错误)
### ⑥ HTTP 状态码检查(第 103-113 行)
```go
// 检查 HTTP 状态码
if resp.StatusCode >= 400 {
info := lookupStatusInfo(resp.StatusCode)
return nil, &APIError{
StatusCode: resp.StatusCode,
Code: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))),
Kind: info.kind,
Suggestion: info.suggestion,
}
}
```
**字面意思**:如果状态码 >= 400返回错误
**运行时作用**HTTP 4xx/5xx 都是错误,这里封装成 `APIError` 返回。
**小白补充**
- `lookupStatusInfo(resp.StatusCode)` 根据状态码查找对应的错误分类和建议
### ⑦ JSON 解析(第 115-120 行)
```go
// 解析 JSON 响应
var raw map[string]interface{}
if err := json.Unmarshal(respData, &raw); err != nil {
// 不是 JSON直接返回原始内容
return output.SuccessEnvelope(string(respData), nil), nil
}
```
**字面意思**:把响应体解析成 Go 的 map
**运行时作用**`json.Unmarshal` 把 JSON 字符串转成 Go 的 `map[string]interface{}`
**小白补充**
- `json.Unmarshal` 的第二个参数需要传递**指针**`&raw`
- `interface{}` 是 Go 的"万能类型",可以存储任何值
- 如果响应不是 JSON比如返回的是 HTML 错误页面),就直接返回字符串
### ⑧ GitLink 业务错误检查(第 122-142 行)
```go
// 检查 GitLink 业务错误(响应体中的 status 字段)
if status, ok := raw["status"]; ok {
var statusCode float64
switch v := status.(type) {
case float64:
statusCode = v
case int:
statusCode = float64(v)
}
// status 不为 0、1、200 都是错误
if statusCode != 0 && statusCode != 200 && statusCode != 1 {
msg, _ := raw["message"].(string)
info := lookupStatusInfo(int(statusCode))
return output.ErrorEnvelope(int(statusCode), msg, info.suggestion), &APIError{
StatusCode: int(statusCode),
Code: int(statusCode),
Message: msg,
Kind: info.kind,
Suggestion: info.suggestion,
}
}
}
```
**字面意思**:检查 GitLink API 返回的业务错误码
**运行时作用**GitLink API 有时 HTTP 状态码是 200但响应体中的 `status` 字段表示业务失败(如参数校验失败)。
**小白补充**
GitLink API 的响应格式:
```json
{
"status": 0, // 0=失败, 1=成功, 200=成功
"message": "...", // 错误信息
"data": {...} // 数据
}
```
### ⑨ 自动解析 JSON 字符串数据(第 144-150 行)
```go
// 自动解析 JSON 字符串数据GitLink API 的一个特性)
if dataStr, ok := raw["data"].(string); ok {
var parsedData interface{}
if err := json.Unmarshal([]byte(dataStr), &parsedData); err == nil {
raw["data"] = json.RawMessage(dataStr)
}
}
```
**字面意思**:处理 data 字段是 JSON 字符串的情况
**运行时作用**GitLink 某些 API 返回的 `data` 字段是字符串形式的 JSON需要再次解析。
**小白补充**
比如响应是这样的:
```json
{
"status": 1,
"data": "{\"name\": \"test\"}" // data 是字符串!
}
```
这里需要把 `"{\"name\": \"test\"}"` 再解析成 `{"name": "test"}`
### ⑩ 构建分页元数据(第 152-166 行)
```go
// 构建分页元数据
var meta *output.Meta
if tc, ok := raw["total_count"]; ok {
meta = &output.Meta{}
if v, ok := tc.(float64); ok {
meta.TotalCount = int(v)
}
if v, ok := raw["page"].(float64); ok {
meta.Page = int(v)
}
if v, ok := raw["limit"].(float64); ok {
meta.Limit = int(v)
}
}
// 返回成功的 Envelope
return output.SuccessEnvelope(raw, meta), nil
```
**字面意思**:从响应中提取分页信息
**运行时作用**:如果 API 返回了分页信息total_count/page/limit提取出来作为 `Meta`
---
## 第 170-184 行:便捷方法
```go
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
return c.Do("GET", path, nil, query)
}
func (c *Client) Post(path string, body interface{}) (*output.Envelope, error) {
return c.Do("POST", path, body, nil)
}
func (c *Client) Put(path string, body interface{}) (*output.Envelope, error) {
return c.Do("PUT", path, body, nil)
}
func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) {
return c.Do("DELETE", path, nil, query)
}
```
**字面意思**:封装常见的 HTTP 方法
**运行时作用**:提供更简洁的调用方式,比如 `client.Get("/users/me", nil)` 而不是 `client.Do("GET", "/users/me", nil, nil)`
---
## 第 186-225 行:错误信息映射
```go
type statusInfo struct {
kind clierrors.ErrorKind
message string
suggestion string
}
var statusMessages = map[int]statusInfo{
-2: {clierrors.KindAuth, "未登录或 Token 已过期",
"运行 gitlink-cli auth login 重新登录"},
-1: {clierrors.KindInput, "参数校验失败",
"检查必填参数是否缺失"},
401: {clierrors.KindAuth, "认证失败",
"运行 gitlink-cli auth login 登录"},
403: {clierrors.KindForbidden, "权限不足",
"请确认账号有此仓库的访问权限"},
404: {clierrors.KindNotFound, "资源不存在",
"检查 owner/repo/id 是否正确"},
// ... 更多状态码
}
func lookupStatusInfo(code int) statusInfo {
if info, ok := statusMessages[code]; ok {
return info
}
return statusInfo{
kind: clierrors.KindUnknown,
message: fmt.Sprintf("API 返回错误码 %d", code),
}
}
```
**字面意思**:根据错误码查找对应的错误信息
**运行时作用**:把枯燥的错误码转换成人类可读的错误信息和解决建议。
---
## 第 227-246 行:`shouldAppendJSONSuffix` 方法
```go
func (c *Client) shouldAppendJSONSuffix(path string) bool {
// 1. 如果设置了 SkipJSONSuffix不添加
if c.SkipJSONSuffix {
return false
}
// 2. 如果已经有 .json 后缀,不添加
if strings.HasSuffix(path, ".json") {
return false
}
// 3. 如果是 raw 内容路径,不添加
parts := strings.Split(strings.Trim(path, "/"), "/")
for i, part := range parts {
if part == "raw" && i >= 2 && i+2 < len(parts) {
return false
}
}
// 4. 其他情况,添加 .json 后缀
return true
}
```
**字面意思**:判断是否应该添加 `.json` 后缀
**运行时作用**:控制是否自动添加 `.json` 后缀。
**小白补充**
为什么需要这个方法?
- Wiki Gateway API 不需要 `.json` 后缀(设置 `SkipJSONSuffix: true`
- 某些路径(如 `/owner/repo/raw/...`)返回的是原始文件内容,不是 JSON
---
## 完整调用流程
```
ctx.CallAPI("GET", "/users/me", nil)
Client.Do("GET", "/users/me", nil, nil)
1. 路径处理:/users/me → /users/me.json
2. 构建 URLhttps://www.gitlink.org.cn/api/users/me.json
3. 创建 HTTP 请求http.NewRequest("GET", url, nil)
4. 发送请求c.HTTP.Do(req)
↓ (auth.NewHTTPClient() 自动添加 Authorization 头)
5. 读取响应体io.ReadAll(resp.Body)
6. 检查状态码:如果 >= 400返回 APIError
7. 解析 JSONjson.Unmarshal → map[string]interface{}
8. 检查业务错误:判断 status 字段
9. 返回 Envelopeoutput.SuccessEnvelope(raw, meta)
```

View File

@ -0,0 +1,706 @@
# shortcuts/wiki/wiki.go 阅读笔记(面向 Go 小白)
---
## 第 1 行:`package wiki`
**字面意思**:声明这个文件属于 `wiki`
**运行时作用**Go 语言规定每个文件必须属于一个包。包名决定了其他文件如何引用这里的函数/变量。
**小白补充**
- 包就像"工具箱"`wiki` 包就是专门处理 Wiki 功能的工具箱
- 同一个包下的文件可以直接互相调用函数,不需要导入
- 包名一般和目录名一致(这里文件在 `shortcuts/wiki/` 目录下,所以包名是 `wiki`
---
## 第 3-22 行import 导入依赖
```go
import (
"encoding/base64" // Base64 编解码
"encoding/json" // JSON 序列化/反序列化
"errors" // 错误处理
"fmt" // 格式化输出(类似 Python 的 print
"net/http" // HTTP 客户端
"net/url" // URL 编码/解析
"os" // 操作系统交互(读文件等)
"regexp" // 正则表达式
"strconv" // 字符串转数字
"strings" // 字符串操作
"sync" // 并发同步(锁、线程安全)
"time" // 时间处理
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端封装
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // CLI 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 通用工具
)
```
**字面意思**:导入需要用到的外部库/包
**运行时作用**:告诉 Go 编译器,我需要使用这些包提供的功能。编译时会把这些包的代码链接进来。
**小白补充**
| 包名 | 一句话解释 | 在本文件中的用途 |
|------|-----------|-----------------|
| `encoding/base64` | 把文字转成 Base64 编码 | Wiki 内容需要用 Base64 编码后发送 |
| `encoding/json` | 处理 JSON 数据 | 解析 API 返回的 JSON |
| `errors` | Go 标准错误处理工具 | 判断错误类型 |
| `fmt` | 格式化打印 | 输出错误信息、拼接字符串 |
| `net/http` | HTTP 协议客户端 | 发送 HTTP 请求 |
| `net/url` | URL 处理 | 构建查询参数、URL 编码 |
| `os` | 操作系统接口 | 读取本地文件内容 |
| `regexp` | 正则表达式 | 匹配 Markdown 链接和图片 |
| `strconv` | 字符串转换 | 把字符串转成数字 |
| `strings` | 字符串操作 | 切割、查找、替换字符串 |
| `sync` | 并发同步 | 提供线程安全的缓存(`sync.Map` |
| `time` | 时间处理 | 设置 HTTP 请求超时 |
| `internal/auth` | 项目内部认证模块 | 获取带 Token 的 HTTP 客户端 |
| `internal/client` | 项目内部客户端模块 | 封装 API 调用逻辑 |
| `internal/errors` | 项目内部错误定义 | 自定义错误类型 |
| `internal/output` | 项目内部输出模块 | 格式化输出结果JSON/Table |
| `shortcuts/common` | 通用工具模块 | 提供 RuntimeContext 等基础结构 |
---
## 第 24 行:`var projectIDCache sync.Map`
**字面意思**:声明一个全局变量 `projectIDCache`,类型是 `sync.Map`
**运行时作用**:这是一个**线程安全的缓存**,用来存储 `owner/repo -> projectID` 的映射关系,避免重复调用 API 获取项目 ID。
**小白补充**
- `var` 是 Go 声明变量的关键字
- `sync.Map` 是 Go 标准库提供的**并发安全的 map**(普通 map 在多线程下读写会崩溃)
- `projectIDCache` 是全局变量(在函数外面声明),整个包内都可以访问
- 为什么需要缓存?因为每次操作 Wiki 都需要 projectID但获取 projectID 需要调用一次 API缓存可以节省网络请求
---
## 第 26-28 行:`wikiPath` 函数
```go
func wikiPath(endpoint string) string {
return "/wiki/open/" + endpoint
}
```
**字面意思**:定义一个函数 `wikiPath`,接收一个字符串参数 `endpoint`返回一个字符串Wiki 功能调用的是 Gateway API (网关 API所有 Wiki 相关的接口都有一个固定的前缀 /wiki/open/
**运行时作用**:拼接 Wiki API 的路径前缀。比如传入 `"wikiPages"`,返回 `"/wiki/open/wikiPages"`
**小白补充**
- `func` 是 Go 定义函数的关键字
- `wikiPath(endpoint string)`:函数名是 `wikiPath`,参数名是 `endpoint`,参数类型是 `string`
- `string`(返回类型):表示函数执行完返回一个字符串
- 这是一个**工具函数**,用来避免重复写相同的路径前缀
---
## 第 30-49 行:`getGatewayClient` 函数
```go
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
baseURL := ctx.GatewayBaseURL
if baseURL == "" {
baseURL = "https://gateway.gitlink.org.cn/api"
}
httpClient := ctx.GatewayHTTPClient
if httpClient == nil {
httpClient = auth.NewHTTPClient()
}
return &client.Client{
HTTP: httpClient,
BaseURL: baseURL,
SkipJSONSuffix: true,
Debug: ctx.Client.Debug,
}
}
```
**字面意思**:定义一个函数 `getGatewayClient`,接收 `*common.RuntimeContext` 类型的指针参数 `ctx`,返回 `*client.Client` 类型的指针
**运行时作用**:创建一个专门访问 **Wiki Gateway API** 的客户端实例。
**小白补充**
### ① 为什么需要单独的 Gateway 客户端?
GitLink 的 Wiki API 和主 API 不在同一个域名:
- 主 API`https://www.gitlink.org.cn/api`(用于获取项目信息等)
- Wiki Gateway API`https://gateway.gitlink.org.cn/api`(专门处理 Wiki 操作)
### ② 代码逐句解析:
```go
baseURL := ctx.GatewayBaseURL // 从上下文获取 Gateway 地址
if baseURL == "" { // 如果没配置,用默认地址
baseURL = "https://gateway.gitlink.org.cn/api"
}
```
```go
httpClient := ctx.GatewayHTTPClient // 获取自定义的 HTTP 客户端
if httpClient == nil { // 如果没有自定义的,创建一个带认证的默认客户端
httpClient = auth.NewHTTPClient()
}
```
```go
return &client.Client{...} // 创建并返回 Client 结构体实例
```
### ③ 结构体初始化语法:
```go
&client.Client{
HTTP: httpClient, // 使用上面创建的 HTTP 客户端
BaseURL: baseURL, // Gateway API 地址
SkipJSONSuffix: true, // 关键Gateway API 不需要 .json 后缀
Debug: ctx.Client.Debug, // 继承调试模式
}
```
- `&` 符号表示取地址返回指针Go 中结构体传参常用指针,避免拷贝)
- `client.Client` 是一个**结构体类型**,里面定义了客户端的各种配置
---
## 第 51-58 行:`callWikiAPI` 函数
```go
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, body, nil)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
```
**字面意思**:定义函数 `callWikiAPI`接收上下文、HTTP 方法、路径、请求体,返回 `(*output.Envelope, error)`
**运行时作用**:封装对 Wiki Gateway API 的调用流程。
**小白补充**
### ① 参数说明:
- `ctx *common.RuntimeContext`运行时上下文包含认证信息、owner/repo 等
- `method string`HTTP 方法GET/POST/PUT/DELETE
- `path string`API 路径
- `body interface{}`请求体可以是任何类型Go 中 `interface{}` 表示万能类型)
### ② 返回值说明:
- `(*output.Envelope, error)`Go 可以返回多个值!第一个是 API 返回的包装数据,第二个是错误
### ③ 执行流程:
1. `gc := getGatewayClient(ctx)` → 获取 Gateway 客户端
2. `gc.Do(...)` → 调用客户端的 Do 方法发送 HTTP 请求
3. `unwrapGatewayResponse(env)` → 解析并处理响应(后面会讲)
---
## 第 60-67 行:`callWikiAPIWithQuery` 函数
```go
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, nil, query)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
```
**字面意思**:和 `callWikiAPI` 类似,但专门用于带查询参数的请求
**运行时作用**:当需要发送带 `?key=value` 查询参数的 GET 请求时使用。
**小白补充**
- `url.Values` 是 Go 标准库类型,本质是 `map[string][]string`,用来存储 URL 查询参数
- 比如 `?owner=zzx&repo=test` 会被表示为 `{"owner": ["zzx"], "repo": ["test"]}`
---
## 第 69-99 行:`unwrapGatewayResponse` 函数(核心!)
```go
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
// 1. 尝试把响应数据转成 map
resp, ok := env.Data.(map[string]interface{})
if !ok {
return env, nil // 不是 map 格式,直接返回
}
// 2. 检查响应中的 code 字段
if code, ok := resp["code"]; ok {
switch v := code.(type) {
case float64:
// HTTP 2xx 都算成功(包括 200/201/204 等)
if v < 200 || v >= 300 {
// 错误情况:提取错误信息
msg, _ := resp["msg"].(string)
kind := clierrors.KindServer
if int(v) == 404 {
kind = clierrors.KindNotFound
} else if int(v) == 401 || int(v) == 403 {
kind = clierrors.KindForbidden
}
// 返回自定义错误
return nil, clierrors.New(kind, msg,
"检查 owner/repo 是否正确,或确认仓库已在 GitLink 网页端开启 Wiki 功能")
}
}
}
// 3. 如果响应有 data 字段,提取出来作为新的响应数据
if innerData, ok := resp["data"]; ok {
return output.SuccessEnvelope(innerData, env.Meta), nil
}
return env, nil
}
```
**字面意思**"拆开" Gateway API 的响应,提取真正的数据
**运行时作用**:处理 Gateway API 返回的特殊格式,统一成标准的 `Envelope` 结构。
**小白补充**
### ① Gateway API 的响应格式:
Gateway API 返回的 JSON 格式是这样的:
```json
{
"code": 200,
"msg": "success",
"data": { "真正的数据在这里" }
}
```
而我们需要的是直接拿到 `data` 里面的内容。
### ② 类型断言Go 的特色语法):
```go
resp, ok := env.Data.(map[string]interface{})
```
- 这是**类型断言**,把 `env.Data`(类型是 `interface{}`)转换成 `map[string]interface{}`
- `ok` 是一个布尔值,表示转换是否成功
- 如果转换失败(比如 `env.Data` 是个字符串而不是 map`ok` 就是 `false`
### ③ switch type 语法:
```go
switch v := code.(type) {
case float64:
// code 是浮点数类型时执行这里
}
```
- 这是 Go 的**类型 switch**,用来判断一个 `interface{}` 变量的具体类型
- JSON 解析数字时,默认会转成 `float64` 类型
### ④ 为什么要判断 2xx 状态码?
```go
if v < 200 || v >= 300 {
// 错误处理
}
```
- HTTP 状态码中200-299 表示成功
- 之前的代码只判断了 200/201导致 DELETE 返回 204No Content时被误判为失败
- 现在扩展到所有 2xx 都算成功
---
## 第 101-135 行:`resolveProjectID` 函数(核心!)
```go
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
// 1. 生成缓存 key
key := ctx.Owner + "/" + ctx.Repo
// 2. 先查缓存
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil // 缓存命中,直接返回
}
// 3. 缓存没命中,调用主 API 获取项目详情
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return "", fmt.Errorf("failed to fetch project details (needed for projectId): %w", err)
}
// 4. 从响应中提取 project_id
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("unexpected response from project detail API")
}
pid, ok := data["project_id"]
if !ok {
return "", fmt.Errorf("project_id not found in project detail response")
}
// 5. 处理 project_id 的不同类型(可能是 float64 或 int
var pidStr string
switch v := pid.(type) {
case float64:
pidStr = fmt.Sprintf("%.0f", v)
case int:
pidStr = fmt.Sprintf("%d", v)
default:
pidStr = fmt.Sprintf("%v", v)
}
// 6. 存入缓存
projectIDCache.Store(key, pidStr)
return pidStr, nil
}
```
**字面意思**:根据 owner/repo 解析出项目的数字 ID
**运行时作用**Wiki API 需要 `projectId`(数字),但用户只知道 `owner/repo`(字符串),这个函数就是做转换的。
**小白补充**
### ① 为什么需要 projectID
GitLink 的 Wiki Gateway API 设计要求传入数字形式的 `projectId`,而不是字符串形式的 `owner/repo`。所以必须先调用主 API 获取项目详情,从中提取 `project_id`
### ② 缓存机制:
```go
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
}
```
- `projectIDCache.Load(key)` 从缓存中查找
- 如果找到(`ok == true`),直接返回缓存的值,不需要再调用 API
- 这是**性能优化**,避免重复请求
### ③ fmt.Sprintf 的用法:
```go
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
```
- 类似 Python 的 `"%s/%s/detail" % (owner, repo)`
- `%s` 是占位符,会被后面的参数替换
### ④ ctx.CallAPI 是什么?
```go
env, err := ctx.CallAPI("GET", path, nil)
```
- `ctx``*common.RuntimeContext` 类型
- `CallAPI``RuntimeContext` 结构体的**方法**(后面会详细讲)
- 它内部调用 `ctx.Client.Do()` 发送 HTTP 请求
---
## 第 137-140 行:`parseProjectIDInt` 函数
```go
func parseProjectIDInt(pid string) int {
n, _ := strconv.Atoi(pid)
return n
}
```
**字面意思**:把字符串形式的 projectID 转成整数
**运行时作用**Wiki API 的某些接口要求 `projectId` 是整数类型,所以需要转换。
**小白补充**
- `strconv.Atoi` 是 string convert to int 的缩写
- `_` 是 Go 语言的"忽略符",表示忽略返回的错误(这里假设 pid 一定是合法数字)
---
## 第 142-154 行:`resolveUpdateContent` 函数
```go
func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) {
if text != "" {
return text, nil // 直接使用提供的文本
}
if filePath != "" {
data, err := os.ReadFile(filePath) // 从文件读取
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("no content provided")
}
```
**字面意思**:解析更新 Wiki 时的内容来源
**运行时作用**:支持两种方式提供内容:直接文本(`--cover`)或文件路径(`--file`)。
---
## 第 156-179 行:`fetchPageContent` 函数(带自动重试)
```go
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) {
// 第一次尝试
c, actual, err := fetchPageContentOnce(ctx, projectID, pageName)
if err == nil {
return c, actual, nil // 成功了,直接返回
}
// 第一次失败,且 pageName 不带 ".-" 后缀,自动重试
if !strings.HasSuffix(pageName, ".-") {
c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-")
if err2 == nil {
return c2, actual2, nil // 重试成功
}
}
// 两次都失败
return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
}
```
**字面意思**:获取 Wiki 页面的明文内容,带自动重试机制
**运行时作用**:解决 GitLink 后端的一个命名问题。
**小白补充**
### ① GitLink 后端的命名 bug
GitLink 创建 Wiki 页面时,会自动给内部存储的 `sub_url` 追加 `".-"` 后缀。但 `wiki +list` 返回的 `title` 不带后缀。
比如:
- 用户创建页面 "Home"
- 后端实际存储的 key 是 "Home.-"
- 但 list API 返回的 title 是 "Home"
所以用 "Home" 去查询会 404必须用 "Home.-" 才能查到。
### ② 自动重试逻辑:
1. 先用原始 `pageName` 尝试查询
2. 如果失败,且 `pageName` 不带 `".-"` 后缀
3. 自动用 `pageName+".-"` 重试一次
---
## 第 181-205 行:`fetchPageContentOnce` 函数(单次查询)
```go
func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) {
// 构建查询参数
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
// 调用 API
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if err != nil {
return "", "", err
}
// 解析响应
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("unexpected response from getWiki")
}
// 提取 base64 编码的内容
b64, _ := data["content_base64"].(string)
if b64 == "" {
return "", pageName, nil // 内容为空,返回空字符串
}
// Base64 解码
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("failed to decode page content: %w", err)
}
return string(decoded), pageName, nil
}
```
**字面意思**:单次尝试获取 Wiki 页面内容
**运行时作用**:发送 GET 请求到 `/wiki/open/getWiki`,获取页面数据并解码。
**小白补充**
### ① URL 查询参数构建:
```go
q := url.Values{}
q.Set("owner", ctx.Owner)
```
- `url.Values` 是 map 类型,用来存储查询参数
- 最终会变成 `?owner=zzx&repo=test&projectId=12345&pageName=Home`
### ② Base64 编解码:
```go
b64, _ := data["content_base64"].(string) // 获取 base64 编码的内容
decoded, err := base64.StdEncoding.DecodeString(b64) // 解码
return string(decoded), pageName, nil // 转成字符串返回
```
- Wiki API 返回的内容是 Base64 编码的(可能是为了支持二进制文件)
- 需要解码才能得到人类可读的文本
---
## 第 207-216 行:`fetchWikiPage` 函数
```go
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
}
```
**字面意思**:获取 Wiki 页面的完整响应(不解码)
**运行时作用**:和 `fetchPageContent` 类似,但返回完整的 `Envelope` 而不是解码后的文本。
---
## 第 218-230 行:`resolveContent` 函数
```go
func resolveContent(ctx *common.RuntimeContext) (string, error) {
if content := ctx.Arg("content"); content != "" {
return content, nil
}
if filePath := ctx.Arg("file"); filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("--content or --file is required to provide wiki page content")
}
```
**字面意思**:解析创建 Wiki 时的内容来源
**运行时作用**:支持 `--content` 直接传内容,或 `--file` 从文件读取。
**小白补充**
- `ctx.Arg("content")` 是从命令行参数中获取 `--content` 的值
- 如果两个参数都没提供,返回错误
---
## 第 232-249 行:`cleanWikiList` 函数
```go
func cleanWikiList(env *output.Envelope) {
// 把 data 转成 slice
items, ok := env.Data.([]interface{})
if !ok {
return
}
// 遍历每个 wiki 页面
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
// 删除不需要的字段
delete(m, "wiki_clone_link")
// URL 解码 sub_url
if raw, ok := m["sub_url"].(string); ok {
if decoded, err := url.QueryUnescape(raw); err == nil {
m["sub_url"] = decoded
}
}
}
}
```
**字面意思**:清理 Wiki 列表数据
**运行时作用**:对 `wiki +list` 返回的数据进行清洗,去掉无用字段,解码 URL。
---
## 第 251-299 行:`outputWithDecodedContent` 函数
```go
func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error {
data := env.Data
// 处理 JSON 字符串形式的 data
if raw, ok := data.(json.RawMessage); ok {
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err == nil {
data = m
env.Data = m
}
}
// 转成 map
m, ok := data.(map[string]interface{})
if !ok {
return ctx.Output(env)
}
// content_base64 → content重命名并解码
if b64, ok := m["content_base64"].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m["content"] = string(decoded)
delete(m, "content_base64") // 删除原字段
}
}
// sidebar / footer 原地解码
for _, field := range []string{"sidebar", "footer"} {
if b64, ok := m[field].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m[field] = string(decoded)
}
}
}
return ctx.Output(env)
}
```
**字面意思**:解码 Wiki 响应中的所有 Base64 字段,用明文替换
**运行时作用**:让返回的 Wiki 内容更易读,同时节省 tokenBase64 编码会增加约 33% 的体积)。
---
## 第 301-526 行Lint 相关

View File

@ -0,0 +1,510 @@
# 逐行讲解 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, ",")
```
按逗号分割字符串,返回字符串数组

View File

@ -0,0 +1,546 @@
# 逐行讲解 shortcuts/issue/batch.go面向 Go 小白)
## 文件概述
这个文件实现了 **Issue 批量操作**功能,可以对多个 Issue 进行批量关闭、修改状态、修改优先级、分配人和修改标签等操作。
---
## 一、包声明和导入
```go
package issue
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `encoding/csv` | CSV 文件解析,用于从文件读取 Issue 编号 |
| `fmt` | 格式化输出 |
| `os` | 文件操作,用于打开 CSV 文件 |
| `strconv` | 字符串和数字之间的转换 |
| `strings` | 字符串处理 |
| `common` | 公共工具包 |
---
## 二、常量定义
```go
const (
priorityLow = 1
priorityNormal = 2
priorityHigh = 3
priorityUrgent = 4
)
```
**优先级常量**:定义了 Issue 优先级对应的数字 ID
```go
const (
statusNew = 1
statusInProgress = 2
statusResolved = 3
statusClosed = 5
statusRejected = 6
)
```
**状态常量**:定义了 Issue 状态对应的数字 ID
```go
const (
trackerBug = 1
trackerFeature = 2
trackerSupport = 3
trackerDoc = 4
trackerTest = 5
trackerDuplicate = 6
trackerQuestion = 7
)
```
**类型常量**:定义了 Issue 类型对应的数字 ID
---
## 三、名称映射表
```go
var priorityNames = map[int]string{
priorityLow: "low",
priorityNormal: "normal",
priorityHigh: "high",
priorityUrgent: "urgent",
}
var statusNames = map[int]string{
statusNew: "new",
statusInProgress: "in-progress",
statusResolved: "resolved",
statusClosed: "closed",
statusRejected: "rejected",
}
var trackerNames = map[int]string{
trackerBug: "bug",
trackerFeature: "feature",
// ...
}
```
**作用**:把数字 ID 转换成可读的英文名称,方便输出结果
---
## 四、标签 ID 映射
```go
var tagIDs = map[string]int{
"缺陷": 315526,
"功能": 315527,
"文档": 315533,
"重复": 315525,
"疑问": 315528,
"支持": 315529,
"任务": 315530,
"测试": 315534,
"协助": 315531,
"搁置": 315532,
}
```
**作用**:中文标签名称到 GitLink 标签 ID 的映射
**注意**:这些 ID 是从网页端 DevTools 抓包获取的,不同项目可能不同
---
## 五、结果结构体
```go
type BatchResult struct {
Number string `json:"number" yaml:"number"`
Action string `json:"action" yaml:"action"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
```
**单个操作结果**:记录单个 Issue 的操作结果
```go
type BatchSummary struct {
Repository string `json:"repository" yaml:"repository"`
Action string `json:"action" yaml:"action"`
Value string `json:"value,omitempty" yaml:"value,omitempty"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Results []BatchResult `json:"results" yaml:"results"`
}
```
**批量操作汇总**:记录整个批量操作的统计信息
---
## 六、批量操作命令
### 6.1 batch-close 命令
```go
func newBatchCloseShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-close",
Description: "Close multiple issues by issue numbers or a CSV file",
Flags: []common.Flag{
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers"},
{Name: "from", Usage: "Read issue numbers from a CSV file"},
{Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"},
},
Run: runBatchClose,
}
}
```
**执行函数**
```go
func runBatchClose(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
if err != nil {
return err
}
if len(numbers) == 0 {
return fmt.Errorf("no issue numbers provided")
}
dryRun := parseBool(ctx.Arg("dry-run"))
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "close",
DryRun: dryRun,
Total: len(numbers),
Results: make([]BatchResult, 0, len(numbers)),
}
for _, number := range numbers {
result := BatchResult{Number: number, Action: "close"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "closed"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total)
}
return nil
}
```
**执行流程**
1. 解析仓库信息
2. 收集 Issue 编号(从 `--numbers` 参数或 CSV 文件)
3. 初始化 `BatchSummary` 汇总对象
4. 遍历每个 Issue 编号:
- 如果是 dry-run直接标记为 planned
- 否则调用 `updateIssueField` 更新状态为 closed
5. 输出汇总结果
---
### 6.2 batch-status 命令
```go
func runBatchStatus(ctx *common.RuntimeContext) error {
// ... 解析参数
state := ctx.Arg("state")
statusID, err := parseStatus(state)
if err != nil {
return err
}
// ... 遍历更新
updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID})
}
```
**功能**:批量修改 Issue 状态
**参数**`--state` 指定目标状态new/in-progress/resolved/closed/rejected
---
### 6.3 batch-priority 命令
```go
func runBatchPriority(ctx *common.RuntimeContext) error {
// ...
priority := ctx.Arg("priority")
priorityID, err := parsePriority(priority)
// ...
updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID})
}
```
**功能**:批量修改 Issue 优先级
**参数**`--priority` 指定目标优先级low/normal/high/urgent
---
### 6.4 batch-assign 命令
```go
func runBatchAssign(ctx *common.RuntimeContext) error {
// ...
assignee := ctx.Arg("assignee")
var assigneeID interface{}
if !dryRun {
id, err := resolveUserID(ctx, assignee)
assigneeID = id
}
// ...
updateIssueField(ctx, number, map[string]interface{}{"assigned_to_id": assigneeID})
}
```
**功能**:批量分配 Issue 给指定用户
**亮点**:需要先把用户名转换成用户 ID
---
### 6.5 batch-label 命令
```go
func runBatchLabel(ctx *common.RuntimeContext) error {
// ...
label := ctx.Arg("label")
trackerID, err := parseTracker(label)
// ...
updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{trackerID}})
}
```
**功能**:批量修改 Issue 的标签
**参数**`--label` 可以是英文bug/feature或中文缺陷/功能)
---
## 七、核心辅助函数
### 7.1 updateIssueField
```go
func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error {
current, err := fetchExistingIssue(ctx, number)
if err != nil {
return fmt.Errorf("fetch issue #%s: %w", number, err)
}
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
}
for k, v := range fields {
body[k] = v
}
if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil {
return fmt.Errorf("update issue #%s: %w", number, err)
}
return nil
}
```
**功能**:更新 Issue 的指定字段
**关键点**
1. 先调用 `fetchExistingIssue` 获取当前 Issue 的标题和描述
2. 必须在请求体中包含 `subject``description`,否则会被清空
3. 把要更新的字段合并到 body 中
4. 发送 PATCH 请求
---
### 7.2 resolveUserID
```go
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) {
if id, err := strconv.Atoi(login); err == nil {
return id, nil
}
env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil)
if err != nil {
return nil, fmt.Errorf("lookup user %q: %w", login, err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response for user %q", login)
}
idFloat, ok := data["id"].(float64)
if ok {
return int(idFloat), nil
}
userIDFloat, ok := data["user_id"].(float64)
if ok {
return int(userIDFloat), nil
}
return nil, fmt.Errorf("cannot determine user ID for %q", login)
}
```
**功能**:把用户名转换成用户 ID
**工作原理**
1. 如果输入已经是数字,直接返回
2. 否则调用 `/users/{login}` API 获取用户信息
3. 从响应中提取 `id``user_id` 字段
4. API 返回的数字是 float64 类型,需要转换成 int
---
### 7.3 collectIssueNumbers
```go
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
numbers, err := parseIssueNumbers(numbersValue)
if err != nil {
return nil, err
}
if csvPath == "" {
return numbers, nil
}
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
if err != nil {
return nil, err
}
return mergeIssueNumbers(numbers, csvNumbers), nil
}
```
**功能**:从 `--numbers` 参数和 CSV 文件中收集 Issue 编号
---
### 7.4 readIssueNumbersFromCSV
```go
func readIssueNumbersFromCSV(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
numberColumn := -1
startRow := 0
for i, cell := range records[0] {
switch strings.ToLower(strings.TrimSpace(cell)) {
case "number", "issue_number", "project_issues_index":
numberColumn = i
startRow = 1
}
}
if numberColumn == -1 {
numberColumn = 0
}
values := make([]string, 0, len(records)-startRow)
for _, record := range records[startRow:] {
if numberColumn >= len(record) {
continue
}
values = append(values, record[numberColumn])
}
return normalizeIssueNumbers(values)
}
```
**功能**:从 CSV 文件读取 Issue 编号
**智能表头识别**
- 自动识别 `number`、`issue_number`、`project_issues_index` 列
- 如果没有匹配的表头,默认使用第一列
- 跳过表头行,从第二行开始读取
---
## 八、类型转换函数
```go
func parseStatus(state string) (int, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "new":
return statusNew, nil
case "in-progress", "in_progress", "inprogress":
return statusInProgress, nil
// ...
default:
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return 0, fmt.Errorf("invalid state %q", state)
}
}
```
**功能**:把用户输入的状态字符串转换成数字 ID
**容错处理**
- 支持多种写法:`in-progress`、`in_progress`、`inprogress`
- 如果输入是数字,直接返回
`parsePriority``parseTracker` 函数类似
---
## 九、Go 语言知识点
### 1. const 常量定义
```go
const (
priorityLow = 1
priorityNormal = 2
)
```
`const` 块中后续常量会继承前一个常量的值并自动加1
### 2. defer 语句
```go
file, err := os.Open(path)
defer file.Close()
```
`defer` 会在函数返回前执行,确保文件被关闭
### 3. map 遍历
```go
for k, v := range fields {
body[k] = v
}
```
遍历 map 的键值对
### 4. type assertion类型断言
```go
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("unexpected response")
}
```
把接口类型转换成具体类型,`ok` 表示转换是否成功
### 5. strconv.Atoi
```go
id, err := strconv.Atoi(login)
```
把字符串转换成整数,如果失败返回错误

View File

@ -0,0 +1,673 @@
# 逐行讲解 shortcuts/issue/batch_create.go面向 Go 小白)
## 文件概述
这个文件实现了 **Issue 批量创建**功能,可以从命令行或 CSV 文件批量创建多个 Issue并支持 bug 和 feature 两种模板。
---
## 一、包声明和导入
```go
package issue
import (
"encoding/csv"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"sync"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `encoding/csv` | CSV 文件解析 |
| `fmt` | 格式化输出 |
| `net/url` | URL 查询参数构建 |
| `os` | 文件操作 |
| `strconv` | 字符串和数字转换 |
| `strings` | 字符串处理 |
| `sync` | 并发安全,用于缓存 |
| `common` | 公共工具包 |
---
## 二、标签缓存
```go
var issueTagCache sync.Map
```
**作用**:缓存项目的标签列表,避免重复请求 API
**sync.Map**Go 语言提供的并发安全的 map可以在多个 goroutine 中安全地读写
---
## 三、resolveIssueTags 函数
```go
func resolveIssueTags(ctx *common.RuntimeContext) (map[string]int, error) {
key := ctx.Owner + "/" + ctx.Repo
if cached, ok := issueTagCache.Load(key); ok {
return cached.(map[string]int), nil
}
path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo)
q := url.Values{}
q.Set("only_name", "true")
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return nil, fmt.Errorf("获取项目标签列表失败: %w", err)
}
data, ok := env.Data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("标签列表响应格式异常")
}
rawTags, ok := data["issue_tags"].([]interface{})
if !ok {
return nil, fmt.Errorf("标签列表响应缺少 issue_tags 字段")
}
tags := make(map[string]int, len(rawTags))
for _, item := range rawTags {
tag, ok := item.(map[string]interface{})
if !ok {
continue
}
name, _ := tag["name"].(string)
if name == "" {
continue
}
var id int
switch v := tag["id"].(type) {
case float64:
id = int(v)
case int:
id = v
default:
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
}
if id == 0 {
continue
}
tags[name] = id
}
if len(tags) == 0 {
return nil, fmt.Errorf("项目没有配置任何标签,请先在 GitLink 网页端创建标签")
}
issueTagCache.Store(key, tags)
return tags, nil
}
```
**功能**:获取项目的 Issue 标签列表,并缓存结果
**执行流程**
1. 构建缓存 keyowner/repo
2. 先从缓存中查找,如果有就直接返回
3. 如果缓存中没有,调用 API 获取标签列表
4. 解析 API 响应,提取标签名称和 ID
5. 处理多种 ID 类型float64、int、其他
6. 把结果存入缓存
7. 返回标签映射
---
## 四、命令定义
```go
func newBatchCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-create",
Description: "Create multiple issues from CLI flags or a CSV file",
Flags: []common.Flag{
{Name: "titles", Usage: "Comma-separated issue titles"},
{Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent"},
{Name: "label", Short: "l", Usage: "Label name"},
{Name: "assignee", Short: "a", Usage: "Assignee login name"},
{Name: "state", Short: "s", Usage: "Initial state", Default: "new"},
{Name: "from", Usage: "CSV file path"},
{Name: "template", Short: "t", Usage: "Template: bug or feature"},
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
},
Run: runBatchCreate,
}
}
```
**Flags 参数说明**
- `--titles`:逗号分隔的 Issue 标题
- `--priority/-p`:优先级
- `--label/-l`:标签
- `--assignee/-a`:分配人
- `--state/-s`:初始状态
- `--from`CSV 文件路径
- `--template/-t`模板类型bug/feature
- `--dry-run`:预览模式
---
## 五、输入结构体
```go
type createIssueInput struct {
Title string
Body string
Priority string
Label string
Assignee string
Status string
// template-specific fields
Version string
Severity string
Steps string
Expected string
Actual string
UserStory string
Acceptance string
}
```
**作用**:存储创建 Issue 的所有输入参数
**模板专用字段**
- `Version`、`Severity`、`Steps`、`Expected`、`Actual`:用于 bug 模板
- `UserStory`、`Acceptance`:用于 feature 模板
---
## 六、runBatchCreate 主函数
```go
func runBatchCreate(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
tags, err := resolveIssueTags(ctx)
if err != nil {
return err
}
dryRun := parseBool(ctx.Arg("dry-run"))
template := strings.ToLower(strings.TrimSpace(ctx.Arg("template")))
var inputs []createIssueInput
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
}
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
if err != nil {
return err
}
inputs = append(inputs, csvInputs...)
}
if len(inputs) == 0 {
return fmt.Errorf("no issue titles provided")
}
cliState := ctx.Arg("state")
for i := range inputs {
if inputs[i].Status == "" {
inputs[i].Status = cliState
}
}
summary := BatchSummary{
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
Action: "create",
Value: template,
DryRun: dryRun,
Total: len(inputs),
Results: make([]BatchResult, 0, len(inputs)),
}
for i, input := range inputs {
label := fmt.Sprintf("#%d", i+1)
if input.Title != "" {
label = truncate(input.Title, 40)
}
result := BatchResult{Number: label, Action: "create"}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
body := buildCreateBody(ctx, input, template, tags)
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
if data, ok := env.Data.(map[string]interface{}); ok {
if num, ok := data["project_issues_index"]; ok {
result.Number = fmt.Sprintf("%v", num)
}
}
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total)
}
return nil
}
```
**执行流程**
1. 解析仓库信息
2. 获取项目标签列表
3. 收集输入(从 `--titles` 和/或 `--from`
4. 为没有指定状态的输入应用默认状态
5. 遍历创建每个 Issue
- 如果是 dry-run标记为 planned
- 否则构建请求体并调用 API
- 从响应中提取新创建的 Issue 编号
6. 输出汇总结果
---
## 七、buildCreateBody 函数
```go
func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string, tags map[string]int) map[string]interface{} {
statusID := statusNew
if input.Status != "" {
if sid, err := parseStatus(input.Status); err == nil {
statusID = sid
}
}
body := map[string]interface{}{
"subject": input.Title,
"status_id": statusID,
"priority_id": priorityNormal,
"done_ratio": 0,
}
if template != "" {
body["description"] = buildTemplateDescription(input, template)
if template == "bug" {
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
} else if template == "feature" {
body["issue_tag_ids"] = []interface{}{tags["功能"]}
}
} else if input.Body != "" {
body["description"] = input.Body
}
if input.Priority != "" {
if pid, err := parsePriority(input.Priority); err == nil {
body["priority_id"] = pid
}
}
if input.Label != "" {
if tid, err := parseLabel(input.Label, tags); err == nil {
body["issue_tag_ids"] = []interface{}{tid}
}
}
if input.Assignee != "" {
if id, err := resolveUserID(ctx, input.Assignee); err == nil {
body["assigner_ids"] = []interface{}{id}
}
}
return body
}
```
**功能**:构建创建 Issue 的请求体
**逻辑**
1. 设置默认值(状态、优先级、完成比例)
2. 如果指定了模板,构建模板描述并设置对应的标签
3. 否则使用自定义描述
4. 应用优先级、标签、分配人等可选参数
---
## 八、模板描述构建
### 8.1 buildTemplateDescription
```go
func buildTemplateDescription(input createIssueInput, template string) string {
switch template {
case "bug":
return buildBugDescription(input)
case "feature":
return buildFeatureDescription(input)
default:
return input.Body
}
}
```
### 8.2 buildBugDescription
```go
func buildBugDescription(input createIssueInput) string {
var b strings.Builder
b.WriteString("## Bug 描述\n")
b.WriteString(input.Title)
b.WriteString("\n")
if input.Version != "" {
b.WriteString("\n## 版本\n")
b.WriteString(input.Version)
}
if input.Severity != "" {
b.WriteString("\n## 严重程度\n")
b.WriteString(input.Severity)
}
if input.Steps != "" {
b.WriteString("\n## 复现步骤\n")
b.WriteString(input.Steps)
}
if input.Expected != "" {
b.WriteString("\n## 期望结果\n")
b.WriteString(input.Expected)
}
if input.Actual != "" {
b.WriteString("\n## 实际结果\n")
b.WriteString(input.Actual)
}
return b.String()
}
```
**功能**:构建标准化的 Bug 描述
**输出格式**
```markdown
## Bug 描述
标题内容
## 版本
v1.0.0
## 严重程度
## 复现步骤
步骤1
步骤2
## 期望结果
期望的行为
## 实际结果
实际的行为
```
### 8.3 buildFeatureDescription
```go
func buildFeatureDescription(input createIssueInput) string {
var b strings.Builder
b.WriteString("## 用户故事\n")
if input.UserStory != "" {
b.WriteString(input.UserStory)
} else {
b.WriteString(input.Title)
}
if input.Body != "" {
b.WriteString("\n## 描述\n")
b.WriteString(input.Body)
}
if input.Acceptance != "" {
b.WriteString("\n## 验收标准\n")
b.WriteString(input.Acceptance)
}
if input.Priority != "" {
b.WriteString("\n## 优先级\n")
b.WriteString(input.Priority)
}
return b.String()
}
```
**功能**:构建标准化的 Feature 描述
**输出格式**
```markdown
## 用户故事
作为用户,我想...
## 描述
详细描述
## 验收标准
- 标准1
- 标准2
## 优先级
high
```
---
## 九、CSV 读取
```go
func readCreateInputsFromCSV(path string, template string) ([]createIssueInput, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
}
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[normalizeHeader(h)] = i
}
if _, ok := col["title"]; !ok {
return nil, fmt.Errorf("CSV must have a 'title' column")
}
var inputs []createIssueInput
for _, record := range records[1:] {
input := createIssueInput{
Title: getCol(record, col, "title"),
Body: getCol(record, col, "body"),
Priority: getCol(record, col, "priority"),
Label: getCol(record, col, "label"),
Assignee: getCol(record, col, "assignee"),
Status: getCol(record, col, "status"),
Version: getCol(record, col, "version"),
Severity: getCol(record, col, "severity"),
Steps: getCol(record, col, "steps"),
Expected: getCol(record, col, "expected"),
Actual: getCol(record, col, "actual"),
UserStory: getCol(record, col, "user_story"),
Acceptance: getCol(record, col, "acceptance"),
}
if input.UserStory == "" {
input.UserStory = getCol(record, col, "user story")
}
if input.Title == "" {
continue
}
inputs = append(inputs, input)
}
return inputs, nil
}
```
**CSV 列支持**
- `title`必填Issue 标题
- `body`:描述内容
- `priority`:优先级
- `label`:标签
- `assignee`:分配人
- `status`:状态
- `version`版本bug 模板)
- `severity`严重程度bug 模板)
- `steps`复现步骤bug 模板)
- `expected`期望结果bug 模板)
- `actual`实际结果bug 模板)
- `user_story` / `user story`用户故事feature 模板)
- `acceptance`验收标准feature 模板)
---
## 十、辅助函数
### 10.1 parseTitles
```go
func parseTitles(titlesStr string, ctx *common.RuntimeContext) []createIssueInput {
parts := strings.Split(titlesStr, ",")
inputs := make([]createIssueInput, 0, len(parts))
for _, title := range parts {
title = strings.TrimSpace(title)
if title == "" {
continue
}
inputs = append(inputs, createIssueInput{
Title: title,
Priority: ctx.Arg("priority"),
Label: ctx.Arg("label"),
Assignee: ctx.Arg("assignee"),
Status: ctx.Arg("state"),
})
}
return inputs
}
```
**功能**:从逗号分隔的标题字符串创建输入对象
### 10.2 normalizeHeader
```go
func normalizeHeader(h string) string {
return strings.ToLower(strings.TrimSpace(h))
}
```
**功能**:标准化 CSV 表头(转小写、去空格)
### 10.3 getCol
```go
func getCol(record []string, col map[string]int, name string) string {
if idx, ok := col[name]; ok && idx < len(record) {
return strings.TrimSpace(record[idx])
}
return ""
}
```
**功能**:从 CSV 记录中获取指定列的值
### 10.4 truncate
```go
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "..."
}
```
**功能**:截断字符串到指定长度,超出部分用 `...` 表示
**注意**:使用 `[]rune` 处理,可以正确处理中文等多字节字符
---
## 十一、Go 语言知识点
### 1. sync.Map
```go
var issueTagCache sync.Map
// 读取
if cached, ok := issueTagCache.Load(key); ok {
return cached.(map[string]int), nil
}
// 写入
issueTagCache.Store(key, tags)
```
**作用**:并发安全的 map用于多个 goroutine 同时读写
### 2. strings.Builder
```go
var b strings.Builder
b.WriteString("## Bug 描述\n")
b.WriteString(input.Title)
return b.String()
```
**作用**:高效拼接字符串,避免产生大量临时字符串
### 3. []interface{}
```go
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
```
**作用**:创建一个包含任意类型的数组,用于 JSON 序列化
### 4. switch 类型断言
```go
switch v := tag["id"].(type) {
case float64:
id = int(v)
case int:
id = v
default:
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
}
```
**作用**:根据值的实际类型执行不同的处理逻辑
### 5. 可变参数
```go
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
```
`...` 表示把切片展开成多个参数

View File

@ -0,0 +1,405 @@
# 逐行讲解 shortcuts/repo/batch_create.go面向 Go 小白)
## 文件概述
这个文件实现了 **仓库批量创建**功能,可以从命令行或 CSV 文件批量创建多个 GitLink 仓库。
---
## 一、包声明和导入
```go
package repo
import (
"encoding/csv"
"fmt"
"os"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
```
| 导入库 | 作用 |
|-------|------|
| `encoding/csv` | CSV 文件解析 |
| `fmt` | 格式化输出 |
| `os` | 文件操作 |
| `strings` | 字符串处理 |
| `common` | 公共工具包 |
---
## 二、结构体定义
### 2.1 repoCreateInput
```go
type repoCreateInput struct {
Name string
Description string
Private bool
}
```
**作用**:存储创建单个仓库的输入参数
| 字段 | 类型 | 说明 |
|-----|------|------|
| `Name` | string | 仓库名称 |
| `Description` | string | 仓库描述 |
| `Private` | bool | 是否私有仓库 |
### 2.2 repoBatchResult
```go
type repoBatchResult struct {
Name string `json:"name" yaml:"name"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
```
**作用**:存储单个仓库创建的结果
| 字段 | 类型 | 说明 |
|-----|------|------|
| `Name` | string | 仓库名称 |
| `Status` | string | 创建状态planned/created/failed |
| `Error` | string | 错误信息(如果失败) |
### 2.3 repoBatchSummary
```go
type repoBatchSummary struct {
Owner string `json:"owner" yaml:"owner"`
Action string `json:"action" yaml:"action"`
DryRun bool `json:"dry_run" yaml:"dry_run"`
Total int `json:"total" yaml:"total"`
Succeeded int `json:"succeeded" yaml:"succeeded"`
Failed int `json:"failed" yaml:"failed"`
Results []repoBatchResult `json:"results" yaml:"results"`
}
```
**作用**:存储批量创建的汇总结果
| 字段 | 类型 | 说明 |
|-----|------|------|
| `Owner` | string | 仓库所有者(用户名) |
| `Action` | string | 操作类型create |
| `DryRun` | bool | 是否是预览模式 |
| `Total` | int | 总数量 |
| `Succeeded` | int | 成功数量 |
| `Failed` | int | 失败数量 |
| `Results` | []repoBatchResult | 每个仓库的详细结果 |
---
## 三、命令定义
```go
func newBatchCreateShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-create",
Description: "Create multiple repositories from CLI flags or a CSV file",
Flags: []common.Flag{
{Name: "names", Short: "n", Usage: "Comma-separated repository names"},
{Name: "from", Usage: "CSV file path"},
{Name: "description", Short: "d", Usage: "Shared description for all repos"},
{Name: "private", Usage: "Make repos private", Bool: true, Default: "false"},
{Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"},
},
Run: runBatchCreate,
}
}
```
**Flags 参数说明**
- `--names/-n`:逗号分隔的仓库名称
- `--from`CSV 文件路径
- `--description/-d`:所有仓库共享的描述
- `--private`:创建私有仓库
- `--dry-run`:预览模式
---
## 四、runBatchCreate 主函数
```go
func runBatchCreate(ctx *common.RuntimeContext) error {
var inputs []repoCreateInput
if namesStr := ctx.Arg("names"); namesStr != "" {
for _, name := range strings.Split(namesStr, ",") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
inputs = append(inputs, repoCreateInput{
Name: name,
Description: ctx.Arg("description"),
Private: ctx.Arg("private") == "true",
})
}
}
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readRepoInputsFromCSV(csvPath)
if err != nil {
return err
}
inputs = append(inputs, csvInputs...)
}
if len(inputs) == 0 {
return fmt.Errorf("no repository names provided")
}
dryRun := ctx.Arg("dry-run") == "true"
var login string
var userID int
if !dryRun {
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
if err != nil {
return fmt.Errorf("failed to get current user: %w", err)
}
userData, _ := userEnv.Data.(map[string]interface{})
login, _ = userData["login"].(string)
if login == "" {
return fmt.Errorf("cannot determine current user login")
}
if uid, ok := userData["user_id"].(float64); ok {
userID = int(uid)
}
}
summary := repoBatchSummary{
Owner: login,
Action: "create",
DryRun: dryRun,
Total: len(inputs),
Results: make([]repoBatchResult, 0, len(inputs)),
}
for _, input := range inputs {
result := repoBatchResult{Name: input.Name}
if dryRun {
result.Status = "planned"
summary.Succeeded++
summary.Results = append(summary.Results, result)
continue
}
body := map[string]interface{}{
"name": input.Name,
"repository_name": input.Name,
"user_id": userID,
}
if input.Description != "" {
body["description"] = input.Description
}
if input.Private {
body["private"] = true
}
if _, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, input.Name), body); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "created"
summary.Succeeded++
}
summary.Results = append(summary.Results, result)
}
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d repo(s) failed to create", summary.Failed, summary.Total)
}
return nil
}
```
**执行流程**
1. 收集输入(从 `--names` 和/或 `--from`
2. 如果不是 dry-run调用 `/users/me` 获取当前用户信息
3. 初始化汇总对象
4. 遍历每个仓库:
- 如果是 dry-run标记为 planned
- 否则构建请求体并调用 API
- 记录结果
5. 输出汇总结果
---
## 五、readRepoInputsFromCSV 函数
```go
func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("read CSV: %w", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.TrimLeadingSpace = true
records, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("parse CSV: %w", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("CSV must have a header row and at least one data row")
}
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[strings.ToLower(strings.TrimSpace(h))] = i
}
if _, ok := col["name"]; !ok {
return nil, fmt.Errorf("CSV must have a 'name' column")
}
var inputs []repoCreateInput
for _, record := range records[1:] {
name := getCol(record, col, "name")
if name == "" {
continue
}
private := false
if p := strings.ToLower(getCol(record, col, "private")); p == "true" || p == "1" {
private = true
}
inputs = append(inputs, repoCreateInput{
Name: name,
Description: getCol(record, col, "description"),
Private: private,
})
}
return inputs, nil
}
```
**CSV 列支持**
- `name`(必填):仓库名称
- `description`:仓库描述
- `private`是否私有true/false 或 1/0
---
## 六、getCol 函数
```go
func getCol(record []string, col map[string]int, name string) string {
if idx, ok := col[name]; ok && idx < len(record) {
return strings.TrimSpace(record[idx])
}
return ""
}
```
**功能**:从 CSV 记录中获取指定列的值
**逻辑**
1. 查找列名对应的索引
2. 检查索引是否有效
3. 返回该位置的值(去除前后空格)
4. 如果找不到,返回空字符串
---
## 七、完整调用流程
```
用户命令 (gitlink repo batch-create -n repo-a,repo-b)
解析命令行参数
newBatchCreateShortcut() 返回命令定义
执行 runBatchCreate 函数
收集输入(解析 --names 参数)
调用 /users/me 获取当前用户信息
遍历每个仓库名称:
构建请求体name, repository_name, user_id
调用 POST /{login}/{repo_name} 创建仓库
记录创建结果
输出汇总结果
```
---
## 八、Go 语言知识点
### 1. 结构体标签Struct Tags
```go
type repoBatchResult struct {
Name string `json:"name" yaml:"name"`
Status string `json:"status" yaml:"status"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
```
**作用**:告诉序列化库(如 JSON、YAML如何给字段命名
- `json:"name"`JSON 序列化时使用 `name` 作为字段名
- `json:"error,omitempty"`:如果 `Error` 为空JSON 中不包含这个字段
### 2. 布尔值判断
```go
dryRun := ctx.Arg("dry-run") == "true"
```
**注意**`ctx.Arg()` 返回的是字符串,需要和字符串 `"true"` 比较,不能直接用 `bool()` 转换
### 3. interface{} 类型断言
```go
userData, _ := userEnv.Data.(map[string]interface{})
login, _ = userData["login"].(string)
```
**作用**:把 `interface{}` 类型转换成具体类型
### 4. float64 转 int
```go
if uid, ok := userData["user_id"].(float64); ok {
userID = int(uid)
}
```
**原因**JSON 解析后,数字默认是 `float64` 类型,需要手动转换成 `int`
### 5. defer 语句
```go
file, err := os.Open(path)
defer file.Close()
```
**作用**:确保文件在函数返回前被关闭,防止资源泄漏
### 6. make 和预分配容量
```go
Results: make([]repoBatchResult, 0, len(inputs))
```
**作用**:创建一个初始长度为 0、容量为 `len(inputs)` 的切片,避免动态扩容的性能开销

83
doc/reading_notes/1.txt Normal file
View File

@ -0,0 +1,83 @@
ctx 就是 *common.RuntimeContext。一句话它是每个 Shortcut
命令的"工具箱"所有能力发API请求、读参数、输出结果都挂在这个对象上。
---
它长什么样shortcuts/common/types.go:41-50
type RuntimeContext struct {
Client *client.Client // ← 发 HTTP 请求的客户端
Owner string // ← --owner 的值(如 "zzx-coder"
Repo string // ← --repo 的值(如 "gitlink-cli"
Format string // ← --format 的值("json" / "table" / "yaml"
CommandName string // ← 当前命令名(如 "wiki +delete"
Args map[string]string // ← 所有 flag 的键值对(如 {"title":"Home","dry-run":"false"}
GatewayBaseURL string // ← Wiki/Webhook网关地址跟标准API不同
GatewayHTTPClient *http.Client // ← 网关专用 HTTP 客户端nil 则自动创建)
}
---
它怎么创建出来的types.go:53-80
你敲 gitlink-cli wiki +delete --title "Home" 时:
第1步cobra 解析命令行 → flagValues = {"title": "Home"}
第2步runner.go:34 → NewRuntimeContext(flagValues, "wiki +delete")
第3步NewRuntimeContext 内部:
→ client.New() // 读取配置文件,拿到 BaseURL + 带 auth 的 HTTP Client
→ 读取全局 flag // cmdutil.Owner, cmdutil.Repo, cmdutil.Format
→ 组装成 RuntimeContext // 把所有东西塞进去
第4步传给 s.Run(ctx) // 你的业务逻辑拿到这个 ctx
---
它上面的方法(你可以直接用 ctx.XXX() 调用的)
┌──────────────────────────────────────┬───────────────────────────────────┬──────────────────────────────────────┐
│ 方法 │ 做什么 │ 例 │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.Arg("title") │ 读用户传入的 flag 值 │ "Home" │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.RequireArg("title", "提示") │ 读必填参数,为空就报 CLIError │ 同上,但自动校验 │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.CallAPI("GET", path, body) │ 调 GitLink 标准 API │ ctx.CallAPI("GET", "/users/me", nil) │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.CallAPIWithQuery("GET", path, │ 带查询参数的 API │ ctx.CallAPIWithQuery("GET", │
│ query) │ │ "/issues", q) │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.Output(env) │ 输出 API 响应(自动选 │ ctx.Output(env) │
│ │ json/table/yaml │ │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.OutputData(data) │ 包装数据成成功 envelope 再输出 │ ctx.OutputData(myStruct) │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.ResolveOwnerRepo() │ 从 git remote 自动推断 owner/repo │ 没传 --owner 时自动填充 │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.RepoPath() │ 返回 "/owner/repo" 字符串 │ "/zzx-coder/gitlink-cli" │
├──────────────────────────────────────┼───────────────────────────────────┼──────────────────────────────────────┤
│ ctx.IsDryRun() │ 判断用户是否传了 --dry-run │ true / false │
└──────────────────────────────────────┴───────────────────────────────────┴──────────────────────────────────────┘
---
一条命令里 ctx 的完整生命周期
以 wiki +delete --title "Home" 为例:
1. runner.go:20-31 收集 flag
flagValues = {"title": "Home"}
2. runner.go:34 创建 ctx
ctx = NewRuntimeContext({"title":"Home"}, "wiki +delete")
→ ctx.Client = 带认证的HTTP客户端BaseURL = https://gitlink.org.cn/api
→ ctx.Owner = "zzx-coder"(从 git remote 或 --owner 来的)
→ ctx.Repo = "gitlink-cli"
→ ctx.Format = "table"(默认值)
→ ctx.Args = {"title": "Home"}
3. runner.go:60 调用你的业务逻辑
err = s.Run(ctx)
4. wiki.go:747 你的 Run 函数里用 ctx
title, _ = ctx.RequireArg("title", ...) // → "Home"
projectID, _ = resolveProjectID(ctx) // → ctx 传进子函数
body = {..., "pageName": actualPageName}
callWikiAPI(ctx, "DELETE", ..., body) // → ctx 用于构造网关 Client
ctx.OutputData(result) // → ctx.Format 决定输出格式
---

787
doc/代码逻辑.md Normal file
View File

@ -0,0 +1,787 @@
# gitlink-cli 子任务一代码逻辑说明
## ? 目录
- [系统架构概述](#系统架构概述)
- [Wiki 管理功能](#wiki-管理功能)
- [Webhook 管理功能](#webhook-管理功能)
- [批量操作功能](#批量操作功能)
- [Raw API 功能](#raw-api-功能)
- [命令优化功能](#命令优化功能)
- [跨平台兼容性](#跨平台兼容性)
## ?? 系统架构概述
### 核心设计模式
**Shortcut 架构模式**:
- 每个功能模块wiki、webhook、issue等实现一个 `Shortcuts()` 函数
- 返回 `[]*common.Shortcut` 切片,每个 Shortcut 代表一个命令
- 命令执行通过 `Run: func(ctx *common.RuntimeContext) error` 实现
**RuntimeContext 上下文**:
```go
type RuntimeContext struct {
Client *client.Client // HTTP 客户端
Owner string // 仓库所有者
Repo string // 仓库名称
Format string // 输出格式 (json/table/yaml)
Args map[string]string // 命令行参数
}
```
**API 调用流程**:
1. `ctx.ResolveOwnerRepo()` - 解析 owner/repo支持 git remote 自动解析)
2. `ctx.CallAPI()` - 发送 HTTP 请求到 GitLink API
3. `ctx.Output()` - 格式化输出结果
---
## ? Wiki 管理功能
### 核心架构
**双重 API 调用机制**:
- `BaseURL`: `https://www.gitlink.org.cn/api` - 主 API获取项目信息
- `GatewayBaseURL`: `https://gateway.gitlink.org.cn/api` - Gateway APIWiki 操作)
### 关键代码逻辑
#### 1. Project ID 解析机制 (`resolveProjectID`)
**问题**: Wiki API 需要 `projectId` 数字 ID而用户只知道 `owner/repo`
**解决方案**:
```go
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
key := ctx.Owner + "/" + ctx.Repo
// 1. 检查缓存 (sync.Map 实现线程安全)
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
}
// 2. 调用主 API 获取项目详情
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
// 3. 提取 project_id 并缓存
projectIDCache.Store(key, pidStr)
return pidStr, nil
}
```
#### 2. Base64 编解码处理
**Wiki 内容编码**:
```go
// 创建 Wiki 时编码
body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(content))
// 查看 Wiki 时解码
if b64, ok := data["content_base64"].(string); ok {
decoded, err := base64.StdEncoding.DecodeString(b64)
data["content_decoded"] = string(decoded) // 额外提供解码后的内容
}
```
#### 3. Wiki 更新策略 (`update` 命令)
**三种更新模式**:
```go
if coverText != "" || filePath != "" && coverText == "" && addText == "" {
// --cover 或 --file: 完全覆盖内容
finalContent = content
} else if addText != "" {
// --add: 追加到现有内容
existing, err := fetchPageContent(ctx, projectID, pageName)
finalContent = existing + newPart
}
```
#### 4. 删除验证机制
**问题**: API 删除操作可能返回错误但实际删除成功
**解决方案**:
```go
delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body)
if delErr != nil {
// 验证是否真的删除成功
_, viewErr := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if viewErr != nil {
// GET 也失败,说明已删除成功
return ctx.OutputData(map[string]string{"message": "Wiki page deleted successfully"})
}
return delErr // GET 成功,说明删除确实失败
}
```
### API 路径设计
| 命令 | HTTP 方法 | Gateway API 路径 |
|------|----------|-----------------|
| list | GET | `/wiki/open/wikiPages` |
| view | GET | `/wiki/open/getWiki` |
| create | POST | `/wiki/open/createWiki` |
| update | PUT | `/wiki/open/updateWiki` |
| delete | DELETE | `/wiki/open/deleteWiki` |
---
## ? Webhook 管理功能
### 核心设计
**统一的 API 路径前缀**:
```go
func webhookRepoPath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo)
}
```
### 关键代码逻辑
#### 1. 事件类型管理
**支持的事件列表**:
```go
var supportedEvents = []string{
"push", "pull_request", "issue", "issue_assign", "issue_comment",
"pull_request_assign", "pull_request_comment", "merge_request",
"repository", "branch", "tag",
}
// 事件解析和验证
func parseEvents(eventsStr string) []string {
events := strings.Split(eventsStr, ",")
for _, event := range events {
if isEventSupported(event) {
validEvents = append(validEvents, event)
}
}
return validEvents
}
```
#### 2. Webhook 创建逻辑
**完整的 Payload 构造**:
```go
payload := map[string]interface{}{
"url": webhookURL, // 必需
"http_method": "POST", // 固定
"active": true, // 默认激活
"content_type": "json", // 默认 JSON
"events": validEvents, // 事件列表
}
// 可选字段
if secret := ctx.Arg("secret"); secret != "" {
payload["secret"] = secret // HMAC 验证密钥
}
if description := ctx.Arg("description"); description != "" {
payload["description"] = description
}
```
#### 3. 智能 URL 获取Update 命令)
**问题**: 更新 Webhook 时用户不记得当前 URL
**解决方案**:
```go
webhookURL := ctx.Arg("url")
if webhookURL == "" {
// 自动获取当前 Webhook 的 URL
getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil)
webhookData := getEnv.Data.(map[string]interface{})
currentURL := webhookData["url"].(string)
webhookURL = currentURL // 使用现有 URL
}
payload["url"] = webhookURL
```
#### 4. 删除验证机制
```go
delErr := ctx.CallAPI("DELETE", webhookPath, nil)
if delErr != nil {
// 验证是否真的删除成功
_, viewErr := ctx.CallAPI("GET", webhookPath, nil)
if viewErr != nil {
// GET 返回错误,说明已删除
return ctx.Output(output.SuccessEnvelope(map[string]interface{}{
"message": "Webhook deleted successfully",
}, nil))
}
return delErr
}
```
#### 5. Test 端点修复
**正确路径**: `/webhooks/{id}/tests` (复数)
```go
env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil)
```
### API 路径设计
| 命令 | HTTP 方法 | API 路径 |
|------|----------|---------|
| list | GET | `/v1/{owner}/{repo}/webhooks` |
| create | POST | `/v1/{owner}/{repo}/webhooks` |
| update | PUT | `/v1/{owner}/{repo}/webhooks/{id}` |
| delete | DELETE | `/v1/{owner}/{repo}/webhooks/{id}` |
| test | POST | `/v1/{owner}/{repo}/webhooks/{id}/tests` |
| info | GET | `/v1/{owner}/{repo}/webhooks/{id}` |
| events | - | (本地静态列表,不调用 API) |
---
## ? 批量操作功能
### 核心设计模式
**统一的结果统计结构**:
```go
type BatchSummary struct {
Repository string // 仓库标识
Action string // 操作类型
Value string // 操作值
DryRun bool // 是否预览
Total int // 总数
Succeeded int // 成功数
Failed int // 失败数
Results []BatchResult // 详细结果
}
```
### 关键代码逻辑
#### 1. Issue 批量创建 (`batch_create.go`)
**输入源合并**:
```go
var inputs []createIssueInput
// 1. 从命令行参数收集
if titlesStr := ctx.Arg("titles"); titlesStr != "" {
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
}
// 2. 从 CSV 文件收集
if csvPath := ctx.Arg("from"); csvPath != "" {
csvInputs, err := readCreateInputsFromCSV(csvPath, template)
inputs = append(inputs, csvInputs...)
}
```
**模板支持**:
```go
func buildCreateBody(input createIssueInput, template string) map[string]interface{} {
if template == "bug" {
body["description"] = buildBugDescription(input)
body["issue_tag_ids"] = []interface{}{tagIDs["缺陷"]}
} else if template == "feature" {
body["description"] = buildFeatureDescription(input)
body["issue_tag_ids"] = []interface{}{tagIDs["功能"]}
}
}
```
#### 2. Issue 批量操作 (`batch.go`)
**通用批量操作流程**:
```go
func runBatchClose(ctx *common.RuntimeContext) error {
// 1. 收集 Issue 编号
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
// 2. 初始化统计结构
summary := BatchSummary{Total: len(numbers)}
// 3. 逐个处理
for _, number := range numbers {
if dryRun {
result.Status = "planned" // 预览模式
} else {
err := updateIssueField(ctx, number, payload)
if err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++
} else {
result.Status = "closed"
summary.Succeeded++
}
}
summary.Results = append(summary.Results, result)
}
// 4. 输出统计结果
return ctx.OutputData(summary)
}
```
#### 3. 仓库批量创建 (`batch_create.go`)
**用户信息获取**:
```go
// 获取当前用户信息
userEnv, err := ctx.CallAPI("GET", "/users/me", nil)
login := userData["login"].(string) // 用于 API 路径
userID := int(userData["user_id"].(float64)) // 用于请求体
// 创建仓库
body := map[string]interface{}{
"name": repoName,
"repository_name": repoName,
"user_id": userID,
}
ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, repoName), body)
```
#### 4. CSV 文件处理
**通用 CSV 读取模式**:
```go
func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) {
file, err := os.Open(path)
reader := csv.NewReader(file)
records, err := reader.ReadAll()
// 1. 解析表头
header := records[0]
col := make(map[string]int)
for i, h := range header {
col[strings.ToLower(strings.TrimSpace(h))] = i
}
// 2. 验证必需列
if _, ok := col["name"]; !ok {
return nil, fmt.Errorf("CSV must have a 'name' column")
}
// 3. 读取数据行
for _, record := range records[1:] {
name := getCol(record, col, "name")
inputs = append(inputs, repoCreateInput{Name: name})
}
return inputs, nil
}
```
#### 5. Issue 编号收集
**多源合并**:
```go
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
// 1. 解析命令行参数
numbers, err := parseIssueNumbers(numbersValue) // "1,2,3" -> []string{"1","2","3"}
// 2. 读取 CSV 文件
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
// 3. 合并去重
return mergeIssueNumbers(numbers, csvNumbers)
}
```
### 批量操作对比
| 功能 | 输入源 | 特殊处理 |
|------|--------|----------|
| repo +batch-create | names CSV | 获取当前用户 login/userID |
| issue +batch-create | titles CSV | 支持模板 (bug/feature) |
| issue +batch-close | numbers CSV | 状态 ID 转换 (closed=5) |
| issue +batch-status | numbers CSV | 状态 ID 转换 |
| issue +batch-priority | numbers CSV | 优先级 ID 转换 |
| issue +batch-assign | numbers CSV | 用户名→用户ID解析 |
| issue +batch-label | numbers CSV | 标签名→标签ID映射 |
---
## ? Raw API 功能
### 核心设计
**统一的 HTTP 客户端**:
```go
type Client struct {
HTTP *http.Client
BaseURL string
Debug bool
SkipJSONSuffix bool // Wiki Gateway 不需要 .json 后缀
}
```
### 关键代码逻辑
#### 1. 请求路径处理
**自动添加 .json 后缀**:
```go
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
// GitLink API 约定: 所有路径需要 .json 后缀
if !c.SkipJSONSuffix {
if !strings.HasSuffix(path, ".json") {
path += ".json"
}
}
// 构造完整 URL
fullURL := c.BaseURL + path
if query != nil {
fullURL += "?" + query.Encode()
}
// 发送 HTTP 请求
req, _ := http.NewRequest(method, fullURL, bodyReader)
resp, _ := c.HTTP.Do(req)
}
```
#### 2. 响应解析策略
**多层错误处理**:
```go
// 1. HTTP 状态码检查
if resp.StatusCode >= 400 {
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, body),
}
}
// 2. 解析 JSON 响应
var raw map[string]interface{}
json.Unmarshal(respData, &raw)
// 3. GitLink 业务错误检查
if status, ok := raw["status"]; ok {
if statusCode != 0 && statusCode != 200 {
msg := raw["message"].(string)
suggestion := suggestFix(int(statusCode)) // 智能错误提示
return ErrorEnvelope(code, msg, suggestion)
}
}
// 4. 处理 JSON 字符串数据 (GitLink API 特性)
if dataStr, ok := raw["data"].(string); ok {
var parsedData interface{}
json.Unmarshal([]byte(dataStr), &parsedData)
raw["data"] = parsedData // 自动解析嵌套 JSON
}
```
#### 3. 智能错误提示
```go
func suggestFix(code int) string {
switch code {
case 401:
return "请先运行 gitlink-cli auth login 登录"
case 403:
return "权限不足,请确认账户权限或联系项目管理员"
case 404:
return "资源不存在,请检查 owner/repo/id 是否正确"
case 422:
return "参数校验失败,请检查请求参数"
}
}
```
#### 4. HTTP 方法封装
```go
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
return c.Do("GET", path, nil, query)
}
func (c *Client) Post(path string, body interface{}) (*output.Envelope, error) {
return c.Do("POST", path, body, nil)
}
func (c *Client) Put(path string, body interface{}) (*output.Envelope, error) {
return c.Do("PUT", path, body, nil)
}
func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) {
return c.Do("DELETE", path, nil, query)
}
```
### Raw API 使用示例
```bash
# GET 请求
./gitlink-cli.exe api GET /users/me
# POST 请求
./gitlink-cli.exe api POST /zzx-coder/gitlink-cli/issues --body '{"subject":"测试"}'
# PUT 请求
./gitlink-cli.exe api PUT /zzx-coder/gitlink-cli/issues/123 --body '{"status":"closed"}'
# DELETE 请求
./gitlink-cli.exe api DELETE /zzx-coder/gitlink-cli/issues/123
# 带查询参数
./gitlink-cli.exe api GET "/zzx-coder/gitlink-cli/issues" --query "status=open&limit=20"
```
---
## ? 命令优化功能
### 1. 参数设计优化
**短参数支持**:
```go
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
{Name: "body", Short: "b", Usage: "Issue description"},
}
// 用户可以使用:
// --title "Bug" 或 -t "Bug"
```
**参数别名和映射**:
```go
func parseStatus(state string) (int, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
return 1, nil
case "closed":
return 5, nil
case "in-progress", "in_progress", "inprogress": // 支持多种格式
return 2, nil
}
}
```
### 2. 输出格式优化
**Envelope 结构**:
```go
type Envelope struct {
OK bool // 操作是否成功
Data interface{} // 数据
Error *ErrorInfo // 错误信息
Meta *Meta // 元数据 (分页等)
}
```
**格式化输出**:
```go
// JSON 格式
{
"ok": true,
"data": {...},
"meta": {
"total_count": 100,
"page": 1,
"limit": 20
}
}
// Table 格式 (自动格式化)
+----+-------------------+---------+
| ID | Title | Status |
+----+-------------------+---------+
| 1 | Bug fix | open |
+----+-------------------+---------+
```
### 3. 错误提示优化
**友好的错误消息**:
```go
type ErrorInfo struct {
Code interface{} // 错误代码
Message string // 错误描述
Suggestion string // 解决建议 (新增)
}
// 示例:
{
"ok": false,
"error": {
"code": 401,
"message": "Authentication failed",
"suggestion": "请先运行 gitlink-cli auth login 登录"
}
}
```
**智能错误处理**:
```go
// Wiki 创建时的错误处理
if err != nil {
if strings.Contains(err.Error(), "404") {
return fmt.Errorf("Wiki page not found\n\nSuggestions:\n- Check if the wiki page exists\n- Verify you have the correct permissions\n- Use 'gitlink-cli wiki +list' to see available pages")
}
return err
}
```
### 4. CSV 编码错误提示
```go
// 批量操作时的编码检查
if !isUTF8CSV(file) {
return fmt.Errorf(`? CSV 文件编码错误
文件编码不是 UTF-8当前编码: %s
解决方案:
1. 使用支持 UTF-8 的编辑器重新保存文件
2. 或使用以下命令创建 UTF-8 文件:
cat > repos.csv << 'EOF'
name,description,private
test1,测试1,false
EOF`, currentEncoding)
}
```
---
## ? 跨平台兼容性
### 1. 路径处理
**配置目录解析**:
```go
func ConfigDir() string {
// 优先使用环境变量
if dir := os.Getenv("GITLINK_CONFIG_DIR"); dir != "" {
return dir
}
// 跨平台主目录
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "gitlink-cli")
}
// Windows: C:\Users\{user}\.config\gitlink-cli
// Linux/Mac: /home/{user}/.config/gitlink-cli
```
### 2. Git Remote 解析
**自动解析仓库路径**:
```go
// 1. 从 git remote 获取 owner/repo
gitRemote := "https://gitlink.org.cn/zzx-coder/gitlink-cli.git"
owner, repo := "zzx-coder", "gitlink-cli"
// 2. 支持多种 remote 格式
// https://gitlink.org.cn/owner/repo.git
// git@gitlink.org.cn:owner/repo.git
// ssh://git@gitlink.org.cn/owner/repo.git
```
### 3. 字符编码处理
**Base64 编解码**:
```go
// Wiki 内容处理 (支持多语言)
content := "# 中文内容\n\nThis is English."
encoded := base64.StdEncoding.EncodeToString([]byte(content))
decoded := base64.StdEncoding.DecodeString(encoded)
```
**Emoji 支持**:
```go
// 支持 Emoji 字符
title := "? Feature request ?"
body := "Add emoji support ?"
```
### 4. 平台特定处理
**文件权限**:
```go
// 配置文件权限: 0600 (仅用户可读写)
os.WriteFile(configPath, data, 0600)
// 目录权限: 0700 (仅用户可访问)
os.MkdirAll(configDir, 0700)
```
**二进制文件**:
```bash
# Windows: gitlink-cli.exe
# Linux/Mac: gitlink-cli
```
---
## ? 数据流程示例
### Issue 创建完整流程
```
用户输入:
./gitlink-cli.exe issue +create --owner zzx-coder --repo gitlink-cli --title "Bug" --body "Fix it"
1. 参数解析
Args = {"owner": "zzx-coder", "repo": "gitlink-cli", "title": "Bug", "body": "Fix it"}
2. 创建 RuntimeContext
ctx = RuntimeContext{
Client: httpClient,
Owner: "zzx-coder",
Repo: "gitlink-cli",
Format: "json",
Args: Args
}
3. API 调用
path = "/v1/zzx-coder/gitlink-cli/issues.json"
body = {
"subject": "Bug",
"description": "Fix it",
"status_id": 1,
"priority_id": 2
}
4. HTTP 请求
POST https://www.gitlink.org.cn/api/v1/zzx-coder/gitlink-cli/issues.json
Authorization: Bearer {token}
Content-Type: application/json
5. 响应处理
解析 JSON → Envelope{OK: true, Data: {...}}
6. 输出格式化
格式化为 JSON/Table → 输出到终端
```
### Wiki 创建完整流程
```
用户输入:
./gitlink-cli.exe wiki +create --owner zzx-coder --repo gitlink-cli --title "Test" --content "# Test"
1. 第一次 API 调用 (获取 Project ID)
GET https://www.gitlink.org.cn/api/zzx-coder/gitlink-cli/detail.json
响应: {"project_id": 12345}
2. Project ID 缓存
projectIDCache.Store("zzx-coder/gitlink-cli", "12345")
3. 第二次 API 调用 (创建 Wiki)
POST https://gateway.gitlink.org.cn/api/wiki/open/createWiki
body = {
"owner": "zzx-coder",
"repo": "gitlink-cli",
"projectId": 12345,
"pageName": "Test",
"content_base64": "I1BUV\Q==" // Base64 编码
}
4. Gateway 响应处理
解析 {"code": 200, "data": {...}} → 提取 data 部分

View File

@ -0,0 +1,535 @@
# GitLink CLI 命令系统优化报告
## 一、参数设计
### 问题:枚举参数无校验,错误信息延迟到 API 调用才暴露
命令 `pr +merge --method` 接受 `merge`、`rebase`、`squash` 三种值,`issue +list --state` 接受 `open`、`closed`、`all`,但输入非法值时没有任何拦截。用户输入 `--method unknown` 会直接发往 API等到服务端返回 422 才知道参数错了,反馈链路长。
**修改前** — 校验逻辑散落在 Run 函数内部:
```go
// shortcuts/issue/issue.go
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
}
// 枚举值定义在 Usage 文本里(给人看),校验在 Run() 里手写(给机器做)
// 两者没有关联,容易出现文本和代码不同步
func normalizeIssueStatus(state string) (interface{}, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
return 1, nil
case "closed":
return 5, nil
default:
if id, err := strconv.Atoi(state); err == nil {
return id, nil
}
return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state)
}
}
```
**问题总结**:每个有枚举值的 flag 都需要在 `Run()` 里手写一个 validate 函数,重复劳动且容易遗漏;枚举值在 Usage 文本里写一遍、在代码里再写一遍,两处不同步时有发生。
**解决方案**:在 `Flag` 结构体中增加 `Choices` 字段,框架层在 parse 阶段自动校验,同时将可选值追加到 `--help` 输出。
**修改后**
```go
// ① 结构体扩展 — shortcuts/common/types.go
type Flag struct {
Name string
Short string
Usage string
Required bool
Default string
Bool bool
Choices []string // 新增:枚举校验
Validate func(value string) error // 新增:自定义校验函数
}
// ② 框架自动校验 — shortcuts/common/runner.go
for _, f := range s.Flags {
val := getFlagValue(cmd, f)
// Choices 枚举校验
if len(f.Choices) > 0 && val != "" && val != "false" {
if !contains(f.Choices, val) {
return clierrors.InputError(
fmt.Sprintf("invalid value %q for --%s", val, f.Name),
fmt.Sprintf("有效值: %s。运行 'gitlink-cli %s --help' 查看用法。",
strings.Join(f.Choices, ", "), commandName),
).WithCommand(commandName)
}
}
// 自定义校验
if f.Validate != nil && val != "" {
if err := f.Validate(val); err != nil {
return clierrors.InputError(
fmt.Sprintf("invalid --%s: %v", f.Name, err),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
}
// ③ 命令行只需声明 Choices — shortcuts/pr/pr.go
Flags: []common.Flag{
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
Choices: []string{"merge", "rebase", "squash"}},
}
```
Choices 声明后,无需再写校验函数,`--help` 也会自动追加 `[merge|rebase|squash]`
---
### 问题:跨参数约束靠手写 fmt.Errorf格式不统一
`issue +update` 要求 "至少提供 --title、--body 或 --state 中的一个"`repo +update` 要求 "至少提供 --description 或 --private 中的一个"。这类跨参数约束都散落在 `Run()` 里用 `fmt.Errorf` 写死,每种错误格式各异、中英文混杂。
**修改前**
```go
// shortcuts/issue/issue.go — 在 Run() 内部手写校验
if title == "" && description == "" && state == "" {
return fmt.Errorf("at least one of --title, --body, or --state is required")
}
// shortcuts/repo/repo.go — 同样手写,格式不同
if len(body) == 0 {
return fmt.Errorf("at least one of --description, --private is required")
}
```
**解决方案**:在 `Shortcut` 结构体中增加 `Validate` 字段,支持声明式跨参数校验;同时引入 `clierrors.InputError` 统一错误格式(英文技术消息 + 中文操作建议)。
**修改后**
```go
// ① Shortcut 结构体新增 Validate 字段 — shortcuts/common/types.go
type Shortcut struct {
Name string
Description string
Flags []Flag
Validate func(args map[string]string) error // 新增:跨参数校验
Run func(ctx *RuntimeContext) error
}
// ② MountShortcut 中自动执行 — shortcuts/common/runner.go
if s.Validate != nil {
if err := s.Validate(flagValues); err != nil {
return clierrors.InputError(
err.Error(),
fmt.Sprintf("运行 'gitlink-cli %s --help' 查看用法。", commandName),
).WithCommand(commandName)
}
}
// ③ 命令中统一使用 InputError — shortcuts/issue/issue.go
if title == "" && description == "" && state == "" {
return clierrors.InputError(
"at least one of --title, --body, or --state is required",
"至少需要提供 --title、--body 或 --state 中的一个参数",
).WithCommand(ctx.CommandName)
}
```
---
## 二、输出格式
### 问题:默认格式与帮助文本不一致
`cmd/root.go``--format` 帮助文本写明 "default: table",但 `shortcuts/common/types.go``NewRuntimeContext` 的代码默认值是 `"json"`。用户不传 `--format` 时拿到的是 JSON 而非表格。
**修改前**
```go
// cmd/root.go — 帮助文本说 "default: table"
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "",
"Output format: json, table, yaml (default: table)")
// shortcuts/common/types.go — 代码实际默认 json
format := cmdutil.Format
if format == "" {
format = "json"
}
```
**解决方案**:将代码默认值改为 `"table"`,同时将 persistent flag 的默认值参数从空字符串改为 `"table"`,让 cobra 显示的默认值与实际行为一致。
**修改后**
```go
// cmd/root.go — 默认值显式化为 "table"
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "table",
"Output format: json, table, yaml")
// shortcuts/common/types.go — 代码默认值与帮助文本一致
format := cmdutil.Format
if format == "" {
format = "table"
}
```
---
### 问题:表格输出无列过滤、长文本被硬截断、无可读性增强
现状表格渲染时列全量输出、复杂值JSON 嵌套、长文本)在 60 字符处硬截断后加 `...`、无色彩区分。用户想只看 id + title 两列也要扛着十几列的输出;想看完整描述被 `...` 截断无能为力。
**修改前**
```go
// internal/output/formatter.go — 截断硬编码,无列过滤
func formatValue(v interface{}) string {
// ...
if len(s) > 60 {
return s[:57] + "..." // 硬截断,不可配置
}
return s
}
func Print(envelope *Envelope, format string) error {
// 无任何渲染选项
return PrintTo(os.Stdout, envelope, format)
}
```
**解决方案**:引入 `PrintOptions` 结构体,支持 `Columns`(列过滤)、`NoTruncate`(关闭截断)、`UseColor`(彩色表头);新增 3 个全局 persistent flag`--columns`、`--no-truncate`、`--no-color`);通过 `RuntimeContext` 透明传递到所有输出调用。
**修改后**
```go
// ① PrintOptions 结构体 — internal/output/formatter.go
type PrintOptions struct {
Columns []string // 要显示的列nil = 全部
NoTruncate bool // 禁用 60 字符截断
UseColor bool // 启用 ANSI 颜色
}
func PrintWithOpts(envelope *Envelope, format string, opts PrintOptions) error {
// ...
case "table":
return printTableOpts(w, envelope, opts) // 传递 opts
}
// ② 列过滤实现
func filterColumns(all, wanted []string) []string {
wantedSet := make(map[string]bool, len(wanted))
for _, w := range wanted { wantedSet[w] = true }
result := make([]string, 0, len(wanted))
for _, h := range all {
if wantedSet[h] { result = append(result, h) }
}
return result
}
// ③ 可配置截断
func formatValueOpts(v interface{}, noTruncate bool) string {
if !noTruncate && len(s) > 60 {
return s[:57] + "..."
}
return s
}
// ④ 彩色表头(仅终端且 --no-color 未设置)
func isTerminal(w io.Writer) bool {
if f, ok := w.(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
}
// ⑤ RuntimeContext 无缝传递 — shortcuts/common/types.go
func (ctx *RuntimeContext) Output(env *output.Envelope) error {
opts := output.PrintOptions{
NoTruncate: ctx.NoTruncate,
UseColor: !ctx.NoColor,
}
if ctx.Columns != "" {
// "id,title,state" → []string{"id", "title", "state"}
for _, p := range strings.Split(ctx.Columns, ",") {
p = strings.TrimSpace(p)
if p != "" { opts.Columns = append(opts.Columns, p) }
}
}
return output.PrintWithOpts(env, ctx.Format, opts)
}
// ⑥ 全局 flag 注册 — cmd/root.go
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoTruncate, "no-truncate", false,
"Disable value truncation in table output")
rootCmd.PersistentFlags().StringVar(&cmdutil.Columns, "columns", "",
"Columns to show in table output (comma-separated)")
rootCmd.PersistentFlags().BoolVar(&cmdutil.NoColor, "no-color", false,
"Disable colored output")
```
用法:
```
gitlink-cli issue +list --columns id,subject,status
gitlink-cli issue +list --no-truncate
gitlink-cli issue +list --no-color
```
---
## 三、错误提示
### 问题中英文混用fmt.Errorf 不被框架识别
现状:各快捷命令中的错误用 `fmt.Errorf` 随意构造,中文和英文混用。`fmt.Errorf` 生成的错误不是 `CLIError` 类型,不被 `TryPrintError` 识别,只能走 `cmd.Execute` 的 stderr 兜底输出,无法享受 envelope 结构化错误格式。
**修改前** — 同一项目中三种不同风格:
```go
// 风格 A中文 — shortcuts/issue/issue.go
return fmt.Errorf("获取 Issue 列表失败: %w", err)
return fmt.Errorf("创建 Issue 失败: %w", err)
// 风格 B英文 — shortcuts/repo/repo.go
return fmt.Errorf("failed to list members for %s/%s: %w", ctx.Owner, ctx.Repo, err)
return fmt.Errorf("cannot determine current user login")
// 风格 C中英混合 — shortcuts/pr/pr.go
return fmt.Errorf("获取 PR 列表失败: %w", err)
return fmt.Errorf("添加 PR 评论失败: %w", err)
```
**问题根源**:没有统一的错误构造入口,开发者各自手写 `fmt.Errorf`
**解决方案**:新增 `OpError` 构造函数,入参只需动词和资源名,自动生成英文 `Message`(给脚本 / jq 解析)和中文 `Suggestion`(给用户阅读),且返回 `*CLIError` 类型可被框架自动识别为 envelope 格式。
**修改后**
```go
// ① 统一构造函数 — internal/errors/errors.go
func OpError(kind ErrorKind, op, resource string, cause error) *CLIError {
msg := fmt.Sprintf("failed to %s %s", op, resource)
sugg := opSuggestion(op, resource)
return Wrap(kind, msg, sugg, cause)
}
func opSuggestion(op, resource string) string {
suggestions := map[string]string{
"list": "获取列表失败,请检查参数或网络连接,稍后重试",
"create": "创建失败,请检查必填参数是否正确(--help 查看用法)或 API 权限",
"view": "查看失败,请确认资源 ID 是否存在",
"update": "更新失败,请检查参数值或资源 ID 是否正确",
"delete": "删除失败,请确认资源是否存在或是否有删除权限",
"close": "关闭失败,请确认资源是否存在或已被关闭",
"merge": "合并失败,请检查是否有冲突或权限不足",
"comment": "添加评论失败,请确认资源是否存在",
"fork": "Fork 失败,请确认仓库存在或有权限",
"invite": "邀请失败,请确认用户 ID 是否正确",
"remove": "移除失败,请确认成员存在",
}
if s, ok := suggestions[op]; ok { return s }
return "操作失败,请稍后重试或运行 --help 查看用法"
}
// ② 命令中一行调用 — shortcuts/issue/issue.go
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q)
if err != nil {
return clierrors.OpError(clierrors.KindServer, "list", "issues", err).
WithCommand(ctx.CommandName)
}
// ③ 框架自动识别 — shortcuts/common/error_print.go
// TryPrintError 检测到 *CLIError 类型后自动输出为结构化 JSON
var cliErr *clierrors.CLIError
if errors.As(err, &cliErr) {
env := output.ErrorEnvelope(kindToCode(cliErr.Kind), cliErr.Message, cliErr.Suggestion)
_ = output.Print(env, format)
return true
}
```
输出效果:
```json
{
"ok": false,
"error": {
"code": 500,
"message": "failed to list issues",
"suggestion": "获取列表失败,请检查参数或网络连接,稍后重试"
}
}
```
`message` 用英文保证脚本可解析,`suggestion` 用中文直接给人看。
---
## 四、帮助文档
### 问题Shortcut 无详细帮助Group 命令只有一行描述
现状:`Shortcut` 结构体只有 `Description` 一个短描述字段,没有 `Long`(详细说明)和 `Example`(使用示例)。`--help` 输出只有一个命令行和 flag 列表,用户看不到用法示例。
Group 命令(`repo`、`issue`、`pr` 等同理15 个 group 全部只有一行 `Short`
```go
descriptions := map[string]string{
"repo": "Repository operations",
"pr": "Pull request operations",
// 14 个 group 完全一样...
}
```
**修改前**
```go
// shortcuts/common/types.go — 结构体缺少 Long 和 Example
type Shortcut struct {
Name string
Description string // 仅此一个描述字段
Flags []Flag
Run func(ctx *RuntimeContext) error
}
// shortcuts/common/runner.go — cobra 命令只有 Use 和 Short
cmd := &cobra.Command{
Use: "+" + s.Name,
Short: s.Description,
RunE: /* ... */,
}
// shortcuts/register.go — group 命令也只有 Short
groupCmd := &cobra.Command{
Use: name,
Short: descriptions[name],
}
```
**问题总结**`--help` 输出仅包含一句话描述 + 参数列表,没有用法示例。对于 `pr +merge` 这类参数较多的命令,用户无从得知 `--method` 有哪些可选值、典型调用怎么写。
**解决方案**`Shortcut` 结构体新增 `Long``Example` 字段,挂载到 cobra 的 `Long``Example``register.go` 中 15 个 Group 命令全部补充 Long 描述和使用示例;新增 `completion` 子命令支持 4 种 shell 的自动补全。
**修改后**
```go
// ① Shortcut 结构体扩展 — shortcuts/common/types.go
type Shortcut struct {
Name string
Description string
Long string // 新增:详细帮助文本
Example string // 新增:使用示例
Flags []Flag
Run func(ctx *RuntimeContext) error
}
// ② MountShortcut 挂载到 cobra — shortcuts/common/runner.go
cmd := &cobra.Command{
Use: "+" + s.Name,
Short: s.Description,
Long: s.Long, // 新增
Example: s.Example, // 新增
RunE: /* ... */,
}
// ③ 命令中填写 — shortcuts/pr/pr.go
{
Name: "merge",
Description: "Merge a pull request",
Example: " gitlink-cli pr +merge --id 42\n" +
" gitlink-cli pr +merge --id 42 --method rebase\n" +
" gitlink-cli pr +merge --id 42 --method squash --dry-run",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "method", Short: "m", Usage: "Merge method", Default: "merge",
Choices: []string{"merge", "rebase", "squash"}},
},
}
// ④ Group 命令补充 Long 和 Example — shortcuts/register.go
type groupInfo struct {
Short string
Long string
Example string
}
infos := map[string]groupInfo{
"pr": {
Short: "Pull request operations",
Long: "Manage pull requests: list, create, view, merge, close, review, and view changed files.",
Example: " gitlink-cli pr +list --state open\n" +
" gitlink-cli pr +create --title \"Fix login\" --head feat-branch\n" +
" gitlink-cli pr +merge --id 42",
},
// 其余 14 个 group 同上
}
groupCmd := &cobra.Command{
Use: name,
Short: info.Short,
Long: info.Long,
Example: info.Example,
}
```
### 问题:无 Shell 自动补全
cobra 框架原生支持 bash/zsh/fish/powershell 的补全生成,但 gitlink-cli 没有暴露这个能力。用户需要记忆 15 个 group 和 80+ 个子命令的完整名称。
**解决方案**:新增 `completion` 子命令。
```go
// cmd/root.go
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
RunE: func(cmd *cobra.Command, args []string) error {
shell := "bash"
if len(args) > 0 { shell = args[0] }
switch shell {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell: %s (valid: bash, zsh, fish, powershell)", shell)
}
},
}
```
启用:
```bash
source <(gitlink-cli completion bash) # Bash
source <(gitlink-cli completion zsh) # Zsh
gitlink-cli completion fish | source # fish
gitlink-cli completion powershell | Out-String | Invoke-Expression # PowerShell
```
---
## 五、影响范围
| 文件 | 改动性质 |
|------|----------|
| `cmd/cmdutil/globals.go` | 新增 NoTruncate / Columns / NoColor 全局变量 |
| `cmd/root.go` | 新增 3 个 persistent flag + completion 子命令 + format 默认值修正 |
| `shortcuts/common/types.go` | Shortcut / Flag / RuntimeContext 结构体扩展6 个新增字段) |
| `shortcuts/common/runner.go` | Choices/Validate 校验逻辑 + Long/Example 挂载 + Choices→Usage 自动追加 |
| `internal/output/formatter.go` | PrintOptions + PrintWithOpts + 列过滤 + 去截断 + 彩色表头 |
| `internal/errors/errors.go` | OpError 统一构造函数 |
| `shortcuts/register.go` | 15 个 Group 命令补充 Long / Example |
| `shortcuts/issue/issue.go` | 错误统一 + Choices(state) + Long/Examplelist, create, view, close, update |
| `shortcuts/pr/pr.go` | 错误统一 + Choices(state, method) + Long/Examplelist, create, merge |
| `shortcuts/repo/repo.go` | 错误统一 + Choices(category) + Long/Examplelist, create, update |
所有新增字段零值安全,现有 80+ Shortcut 无需修改即可编译。`output.Print()` 签名不变,内部包装为 `PrintWithOpts`。Workflow 脚本显式使用 `--format json`,不受默认格式变化影响。
验证:`go build` 通过,`go vet` 通过(仅 pre-existing milestone 包有构建错误),`go test ./...` 7 个测试包全部通过。

View File

@ -0,0 +1,224 @@
# Board (看板) Shortcut 修改笔记
## 一、功能概述
新增 `board` 快捷命令组,提供项目看板的查看、筛选、任务操作和统计分析功能。
| 命令 | 类型 | 说明 |
|------|------|------|
| `board +view` | 读 | 按状态分组显示看板全貌 |
| `board +columns` | 读 | 列出各状态列及 issue 数量 |
| `board +issues` | 读 | 按状态/指派人/优先级筛选任务 |
| `board +move` | 写 | 移动任务状态(支持 dry-run |
| `board +assign` | 写 | 指派任务给用户(支持 dry-run |
| `board +stats` | 读 | 完成率、工作负载、瓶颈分析 |
---
## 二、解决思路
### 2.1 API 选型
最初计划使用 PM 看板 API (`GET /pm/dashboards?project_id=...`),但实际测试发现该端点不存在(返回 HTML 页面。skill 参考文档 `pm-kanban.md` 中的 API 描述有误。
**最终方案**:基于已有的 issue list API (`GET /v1/{owner}/{repo}/issues`) 实现看板视图。每个 issue 带有 `status_id`、`status_name`、`assigners`、`priority` 等字段,按 `status_id` 分组即可构建看板。
### 2.2 看板列设计
`status_id` 映射为 5 列:
| status_id | 列名 |
|-----------|------|
| 1 | 待处理 |
| 2 | 进行中 |
| 3 | 已解决 |
| 5 | 已关闭 |
| 6 | 已拒绝 |
### 2.3 写操作复用
`board +move``board +assign` 复用 issue PATCH API (`PATCH /v1/{owner}/{repo}/issues/{id}`)。关键点PATCH body 必须包含 `subject` + `description`,否则会被清空。
---
## 三、代码指令
### 3.1 文件变更
```
新建shortcuts/board/board.go # 6 个命令 + 辅助函数(约 580 行)
修改shortcuts/register.go # 添加 board 组的 import + 注册
```
### 3.2 编译部署
```bash
# 编译
cd /c/Users/Lenovo/Desktop/soft运维/gitlink-cli
go install .
# 同步到 npmnpm shim 调用同目录的 gitlink-cli.exe
cp ~/go/bin/gitlink-cli.exe ~/AppData/Roaming/npm/node_modules/@gitlink-ai/cli/bin/gitlink-cli.exe
```
### 3.3 核心辅助函数
```go
// fetchAllIssuesWithState — 分页获取所有 issue
func fetchAllIssuesWithState(ctx *common.RuntimeContext, state string) ([]issueItem, error)
// groupByStatus — 按 status_id 分组
func groupByStatus(issues []issueItem) map[int][]issueItem
// fetchExistingIssue — 获取 issue 的 subject+descriptionPATCH 前必须调用)
func fetchExistingIssue(ctx *common.RuntimeContext, number string) (string, string, error)
// resolveUserID — 用户名转用户 ID调用 GET /users/{login}
func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error)
```
### 3.4 register.go 变更
```go
// 新增 import
"github.com/gitlink-org/gitlink-cli/shortcuts/board"
// groups map 新增
"board": board.Shortcuts(),
// infos map 新增
"board": {
Short: "Board (kanban) operations",
Long: "View and manage kanban boards: ...",
Example: " gitlink-cli board +view\n ...",
},
```
---
## 四、输出参考
### 4.1 board --help
```
View and manage kanban boards: view board layout, list columns, filter issues,
move tasks between columns, assign people, and analyze workload.
Usage:
gitlink-cli board [command]
Available Commands:
+assign Assign an issue to someone
+columns List status columns with issue counts
+issues List issues with optional filters
+move Move an issue to a different status
+stats Show board analytics and statistics
+view View kanban board layout
```
### 4.2 board +view
```json
{
"ok": true,
"data": {
"repository": "zzx-coder/gitlink-cli",
"total_issues": 44,
"columns": [
{
"status_id": 1,
"status_name": "待处理",
"issue_count": 0,
"issues": []
},
{
"status_id": 2,
"status_name": "进行中",
"issue_count": 0,
"issues": []
},
{
"status_id": 3,
"status_name": "已解决",
"issue_count": 44,
"issues": [
{"number": 16, "id": 143115, "subject": "fix:增加相关test.go文件", "priority": "normal"},
{"number": 25, "id": 143700, "subject": "fix: 统一错误提示优化", "priority": "normal"}
]
},
{"status_id": 5, "status_name": "已关闭", "issue_count": 0, "issues": []},
{"status_id": 6, "status_name": "已拒绝", "issue_count": 0, "issues": []}
]
}
}
```
### 4.3 board +columns
```json
{
"ok": true,
"data": [
{"status_id": 1, "status_name": "待处理", "issue_count": 0},
{"status_id": 2, "status_name": "进行中", "issue_count": 0},
{"status_id": 3, "status_name": "已解决", "issue_count": 44},
{"status_id": 5, "status_name": "已关闭", "issue_count": 0},
{"status_id": 6, "status_name": "已拒绝", "issue_count": 0}
]
}
```
### 4.4 board +issues --status resolved --limit 3
```json
{
"ok": true,
"data": [
{"number": 16, "id": 143115, "subject": "fix:增加相关test.go文件", "status": "已解决", "priority": "normal", "assigned_to": ""},
{"number": 25, "id": 143700, "subject": "fix: 统一错误提示优化", "status": "已解决", "priority": "normal", "assigned_to": ""},
{"number": 2, "id": 142700, "subject": "新增issue批量操作", "status": "已解决", "priority": "normal", "assigned_to": ""}
]
}
```
### 4.5 board +move --number 16 --status in-progress --dry-run
```
[dry-run] Move issue #16 to status "in-progress"
Proceed? [y/N] Aborted.
```
### 4.6 board +stats
```json
{
"ok": true,
"data": {
"repository": "zzx-coder/gitlink-cli",
"total_issues": 44,
"completion_rate": 100,
"column_breakdown": [
{"status_name": "待处理", "count": 0, "percentage": 0},
{"status_name": "进行中", "count": 0, "percentage": 0},
{"status_name": "已解决", "count": 44, "percentage": 100},
{"status_name": "已关闭", "count": 0, "percentage": 0},
{"status_name": "已拒绝", "count": 0, "percentage": 0}
],
"assignee_load": [
{"assignee": "(unassigned)", "count": 44}
],
"bottleneck": "已解决"
}
}
```
---
## 五、踩坑记录
| 问题 | 原因 | 解决 |
|------|------|------|
| `unknown command "board"` | npm shim (`cli.js`) 调用的是 npm 包内的 `gitlink-cli.exe`,不是 `go/bin` 的 | 编译后同步覆盖 npm 包内的 exe |
| `failed to parse dashboard data` | `/pm/dashboards` API 端点不存在,返回 HTML | 改用 issue list API + 客户端分组 |
| `json: cannot unmarshal string` | API 返回 HTML 字符串而非 JSON 对象 | 同上,放弃 PM API |

View File

@ -0,0 +1,386 @@
"""生成子赛题一报告 Word 文档"""
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.oxml.ns import qn
doc = Document()
# === 样式设置 ===
style = doc.styles['Normal']
style.font.name = '宋体'
style.font.size = Pt(12)
style.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
def add_heading(text, level=1):
h = doc.add_heading(text, level=level)
for run in h.runs:
run.font.name = '黑体'
run.element.rPr.rFonts.set(qn('w:eastAsia'), '黑体')
return h
def add_para(text, bold=False):
p = doc.add_paragraph()
run = p.add_run(text)
run.bold = bold
run.font.name = '宋体'
run.font.size = Pt(12)
run.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
return p
def add_table(headers, rows):
table = doc.add_table(rows=1, cols=len(headers), style='Table Grid')
table.alignment = WD_TABLE_ALIGNMENT.CENTER
for i, h in enumerate(headers):
cell = table.rows[0].cells[i]
cell.text = h
for p in cell.paragraphs:
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
for run in p.runs:
run.bold = True
run.font.size = Pt(10)
for row_data in rows:
row = table.add_row()
for i, val in enumerate(row_data):
row.cells[i].text = str(val)
for p in row.cells[i].paragraphs:
for run in p.runs:
run.font.size = Pt(10)
return table
def add_code(text):
p = doc.add_paragraph()
run = p.add_run(text)
run.font.name = 'Consolas'
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
return p
# ============================================================
# 正文
# ============================================================
add_heading('第二章 子赛题一:增强与完善 GitLink-CLI 能力', level=1)
# 2.1
add_heading('2.1 任务目标与整体思路', level=2)
add_para(
'子赛题一的定位是扩展 gitlink-cli 的功能覆盖面和使用体验。本项目选择的切入方向有三个:'
'新增 Shortcut 命令(填补功能空白)、优化命令系统框架(提升开发效率和用户体验)、'
'补全 Raw API 封装(对齐 OpenAPI 接口)。'
)
add_para(
'整体设计思路是"先框架后业务":先完善底层的 Shortcut 抽象层(参数校验、输出格式、错误处理、帮助文档),'
'再基于这个框架快速新增业务命令。这样做的好处是,新增的命令天然继承框架能力'
'dry-run、枚举校验、结构化错误输出不需要每个命令重复造轮子。'
)
# 2.2
add_heading('2.2 三层命令体系架构', level=2)
add_para('gitlink-cli 采用三层命令体系:')
add_table(
['层级', '名称', '说明', '示例'],
[
['第一层', 'Shortcuts+前缀命令)', '人类和 AI Agent 直接使用,参数精简,结构化输出', 'issue +list --state open'],
['第二层', 'API Commands元数据驱动', '自动从 OpenAPI spec 生成,覆盖所有 API 端点', 'api get /v1/{owner}/{repo}/issues'],
['第三层', 'Raw APIHTTP 原始调用)', '完全透传,适合调试和边缘场景', 'raw GET /v1/owner/repo/issues'],
]
)
add_para('')
add_para(
'本次工作主要落在第一层Shortcuts同时涉及底层框架的增强。新增了 board、file、milestone '
'三个 Shortcut 模块,以及对 issue、pr、repo 等已有模块的扩展。'
)
add_para(
'架构说明cmd/root.go 是 cobra 根命令,通过 shortcuts/register.go 挂载 15 个命令组,'
'每个组的子命令通过 common/runner.go 的 MountShortcut() 自动注册 flags、校验逻辑和错误处理。'
'业务模块只需定义 Shortcut 结构体数组。'
)
# 2.3
add_heading('2.3 新增 Shortcut 命令', level=2)
# 2.3.1 Wiki
add_heading('2.3.1 Wiki 管理命令', level=3)
add_para('新增 wiki 命令组,提供 6 个子命令:')
add_table(
['命令', '功能', 'DryRun'],
[
['wiki +list', '列出所有 Wiki 页面', '-'],
['wiki +view', '查看指定页面内容', '-'],
['wiki +create', '创建 Wiki 页面', 'Yes'],
['wiki +update', '更新 Wiki 页面', 'Yes'],
['wiki +delete', '删除 Wiki 页面(带二次验证)', 'Yes'],
['wiki +lint', '检查 Wiki 内容质量', '-'],
]
)
add_para('技术要点:')
add_para('1Wiki API 走网关gateway.gitlink.org.cn/api而非主 API。')
add_para('2内容使用 base64 编码传输outputWithDecodedContent() 自动解码,节省 AI Agent token。')
add_para('3+delete 有删除后验证机制GET 确认页面真的被删除了。')
add_para('4+lint 检查项:空内容、标题层级、内容过短、链接有效性、图片引用。')
# 2.3.2 Webhook
add_heading('2.3.2 Webhook 配置命令', level=3)
add_para('新增 webhook 命令组,提供 7 个子命令:')
add_table(
['命令', '功能', 'DryRun'],
[
['webhook +list', '列出所有 webhook', '-'],
['webhook +create', '创建 webhook', 'Yes'],
['webhook +update', '更新 webhook智能获取当前 URL', 'Yes'],
['webhook +delete', '删除 webhook双重验证', 'Yes'],
['webhook +test', '测试 webhook 触发', '-'],
['webhook +info', '查看 webhook 详情', '-'],
['webhook +events', '列出支持的事件类型', '-'],
]
)
add_para('支持 11 种事件类型push, pull_request, issue, issue_assign, issue_comment, pull_request_assign, pull_request_comment, merge_request, repository, branch, tag。')
# 2.3.3 Board
add_heading('2.3.3 项目看板Board', level=3)
add_para('新增 board 命令组,提供 6 个子命令:')
add_table(
['命令', '功能', '实现方式'],
[
['board +view', '按状态分组显示看板全貌', '基于 issue list API + 客户端按 status_id 分组'],
['board +columns', '列出各状态列及 issue 数量', '同上'],
['board +issues', '按状态/指派人/优先级筛选任务', '同上,增加过滤逻辑'],
['board +move', '移动任务状态', '复用 issue PATCH API'],
['board +assign', '指派任务给用户', '先 resolveUserID再 PATCH'],
['board +stats', '完成率、工作负载、瓶颈分析', '统计聚合'],
]
)
add_para(
'设计决策:最初计划使用 PM 看板 API/pm/dashboards但实际测试发现该端点不存在。'
'最终方案基于已有的 issue list API 实现,按 status_id 映射为 5 列'
'(待处理/进行中/已解决/已关闭/已拒绝)。'
)
# 2.3.4 Milestone
add_heading('2.3.4 里程碑管理Milestone', level=3)
add_para('新增 milestone 命令组,提供 6 个子命令:')
add_table(
['命令', '功能'],
[
['milestone +list', '列出里程碑,支持按状态筛选和排序'],
['milestone +create', '创建里程碑'],
['milestone +view', '查看里程碑详情及关联 Issue'],
['milestone +update', '更新里程碑(自动获取当前值,只覆盖指定字段)'],
['milestone +delete', '删除里程碑(先 GET 获取必填字段再 DELETE'],
['milestone +status', '打开或关闭里程碑'],
]
)
# 2.3.5 File
add_heading('2.3.5 文件操作File', level=3)
add_para('新增 file 命令组,提供 11 个子命令:')
add_table(
['命令', '功能', 'DryRun'],
[
['file +ls', '列出根目录文件', '-'],
['file +tree', '查看子目录/文件详情', '-'],
['file +read', '读取文件内容', '-'],
['file +readme', '读取 README', '-'],
['file +search', '按文件名搜索', '-'],
['file +create', '创建文件(自动 base64 编码)', 'Yes'],
['file +update', '更新文件(自动获取 SHA', 'Yes'],
['file +delete', '删除文件(自动获取 SHA', 'Yes'],
['file +batch', '批量创建/更新/删除文件', 'Yes'],
['file +commits', '提交历史', '-'],
['file +diff', '查看 commit diff', '-'],
]
)
# 2.4
add_heading('2.4 优化部分', level=2)
add_heading('2.4.1 参数设计优化', level=3)
add_para(
'问题:枚举参数无校验,错误信息延迟到 API 调用才暴露。'
'pr +merge --method 接受 merge/rebase/squash但输入非法值时直接发往 API'
'等到服务端返回 422 才知道参数错了。'
)
add_para('方案:在 Flag 结构体中增加 Choices 和 Validate 字段,框架层在 parse 阶段自动校验。')
add_code(
'type Flag struct {\n'
' Name string\n'
' Choices []string // 枚举校验\n'
' Validate func(value string) error // 自定义校验\n'
'}'
)
add_para('同时Choices 声明后 --help 会自动追加 [merge|rebase|squash],无需手动维护。')
add_para('跨参数约束Shortcut 结构体新增 Validate 字段,支持声明式校验(如"至少提供 --title、--body 或 --state 中的一个")。')
add_heading('2.4.2 输出格式优化', level=3)
add_para('问题:默认格式与帮助文本不一致(帮助说 table代码默认 json表格输出无列过滤、长文本被硬截断。')
add_para('方案:')
add_para('1修正默认值为 table。')
add_para('2引入 PrintOptions 结构体,支持 --columns列过滤、--no-truncate关闭截断、--no-color禁用彩色')
add_para('3通过 RuntimeContext 透明传递到所有输出调用。')
add_heading('2.4.3 错误提示优化', level=3)
add_para('问题中英文混用fmt.Errorf 不被框架识别,无法输出结构化错误。')
add_para('方案:新增 OpError 统一构造函数,入参只需动词和资源名,自动生成英文 Message给脚本解析和中文 Suggestion给用户阅读')
add_code('clierrors.OpError(clierrors.KindServer, "list", "issues", err)\n// 输出failed to list issues / 获取列表失败,请检查参数或网络连接')
add_heading('2.4.4 帮助文档优化', level=3)
add_para('问题Shortcut 只有短描述无使用示例15 个 Group 命令全部只有一行 Short。')
add_para('方案Shortcut 结构体新增 Long 和 Example 字段,挂载到 cobra 的对应字段15 个 Group 命令全部补充 Long 描述和使用示例;新增 completion 子命令支持 bash/zsh/fish/powershell 的自动补全。')
# 2.5
add_heading('2.5 批量操作能力增强', level=2)
add_heading('Issue 批量操作6 个)', level=3)
add_table(
['命令', '功能'],
[
['issue +batch-close', '批量关闭 Issue'],
['issue +batch-status', '批量修改状态'],
['issue +batch-priority', '批量修改优先级'],
['issue +batch-assign', '批量指派负责人'],
['issue +batch-label', '批量添加/移除标签'],
['issue +batch-create', '批量创建 Issue'],
]
)
add_heading('Repo 批量操作4 个)', level=3)
add_table(
['命令', '功能'],
[
['repo +batch-create', '批量创建仓库'],
['repo +batch-update', '批量更新仓库设置'],
['repo +batch-delete', '批量删除仓库'],
['repo +batch-member', '批量邀请/移除成员'],
]
)
add_heading('Issue 增强4 个元数据查询 + 3 个评论管理)', level=3)
add_table(
['命令', '功能'],
[
['issue +statuses', '获取所有可用的 Issue 状态'],
['issue +authors', '获取发布过 Issue 的用户列表'],
['issue +assigners', '获取可被指派的用户列表'],
['issue +priorities', '获取所有可用的优先级'],
['issue +comment-edit', '编辑 Issue 评论'],
['issue +comment-delete', '删除 Issue 评论'],
['issue +replies', '查看评论下的回复'],
]
)
add_heading('PR 增强6 个命令 + 2 个评论管理)', level=3)
add_table(
['命令', '功能'],
[
['pr +reopen', '重新打开已关闭的 PR'],
['pr +update', '更新 PR 标题/描述/分支'],
['pr +commits', '查看 PR 中的所有提交'],
['pr +versions', '查看 PR 版本历史'],
['pr +vdiff', '查看 PR 某版本的 diff'],
['pr +filesv1', '查看 PR 变更文件列表v1 API'],
['pr +comment-edit', '编辑 PR 审查评论'],
['pr +comment-delete', '删除 PR 审查评论'],
]
)
# 2.6
add_heading('2.6 跨平台兼容性与安装体验', level=2)
add_para('本项目支持 macOS、Linux、Windowsx64/arm64三个平台。')
add_para('安装方式:')
add_para('1一键安装脚本curl -sSL .../install.sh | bash自动检测平台和架构。')
add_para('2npm 安装npm install -g @gitlink-ai/clipostinstall 自动下载对应平台二进制。')
add_para('3源码编译go install . 后手动同步到 npm 包。')
add_para('Windows 特殊处理npm shimcli.js调用的是 npm 包内的 gitlink-cli.exe编译后需要同步覆盖。路径处理兼容 Windows 反斜杠。')
# 2.7
add_heading('2.7 Raw API 封装补全', level=2)
add_para('对齐 GitLink OpenAPI新封装的接口清单')
add_table(
['模块', '接口数', '涉及 API 端点'],
[
['File 操作', '11', 'entries, sub_entries, readme, files, create_file, update_file, delete_file, batch, commits, diff'],
['Issue 元数据', '4', 'issue_statues, issue_authors, issue_assigners, issue_priorities'],
['Milestone', '6', 'milestones CRUD + update_status'],
['PR 增强', '6', 'reopen, update, commits, versions, versions/diff, files(v1)'],
['评论管理', '5', 'issue journals CRUD + children_journals, PR journals CRUD'],
['合计', '32', '-'],
]
)
# 2.8
add_heading('2.8 单元测试与命令帮助文档', level=2)
add_heading('测试文件位置', level=3)
add_table(
['模块', '测试文件'],
[
['board', 'shortcuts/board/board_test.go'],
['file', 'shortcuts/file/file_test.go'],
['milestone', 'shortcuts/milestone/milestone_test.go'],
['wiki', 'shortcuts/wiki/wiki_test.go'],
['webhook', 'shortcuts/webhook/webhook_test.go'],
['issue', 'shortcuts/issue/issue_test.go, batch_test.go, batch_create_test.go'],
['pr', 'shortcuts/pr/pr_test.go'],
['repo', 'shortcuts/repo/batch_test.go, batch_delete_test.go'],
['common', 'shortcuts/common/runner_test.go'],
]
)
add_heading('测试方法', level=3)
add_para('使用 httptest.NewServer mock API构造 RuntimeContext 直接调用 Run 函数,验证输出和 PATCH body 内容。')
add_heading('帮助文档更新', level=3)
add_para('1所有 Shortcut 的 Long 和 Example 字段已填写。')
add_para('215 个 Group 命令补充了详细描述和使用示例。')
add_para('3新增 completion 子命令支持 4 种 shell 自动补全。')
# 2.9
add_heading('2.9 PR 提交记录与变更说明', level=2)
add_table(
['PR', '标题', '内容摘要', '状态'],
[
['#27', '实现场景1社区运营自动化', 'Webhook 接收器 + 部署 + systemd', '已合并'],
['-', 'feat(board): 新增项目看板 shortcut', '6 个看板命令 + 单元测试', '待提交'],
['-', 'feat(file): 新增文件操作模块', '11 个文件命令 + 单元测试', '待提交'],
['-', 'feat(milestone): 新增里程碑管理', '6 个里程碑命令 + 单元测试', '待提交'],
['-', 'feat(wiki): 新增 Wiki 管理命令', '6 个 Wiki 命令 + lint', '待提交'],
['-', 'feat(webhook): 新增 Webhook 配置', '7 个 Webhook 命令', '待提交'],
['-', 'refactor(shortcuts): 优化命令框架', 'Choices/Validate/PrintOptions/OpError', '待提交'],
['-', 'feat(issue): 批量操作 + 元数据查询 + 评论管理', '13 个命令', '待提交'],
['-', 'feat(pr): PR 增强 + 评论管理', '8 个命令', '待提交'],
]
)
add_para('')
add_para('涉及文件变更汇总:', bold=True)
add_table(
['文件', '改动性质'],
[
['shortcuts/common/types.go', '结构体扩展6 个新增字段)'],
['shortcuts/common/runner.go', '校验逻辑 + Long/Example 挂载'],
['shortcuts/register.go', '注册 board/file/milestone 模块'],
['internal/errors/errors.go', 'OpError 统一构造函数'],
['internal/output/formatter.go', 'PrintOptions + 列过滤 + 彩色表头'],
['cmd/root.go', '3 个 persistent flag + completion 子命令'],
['cmd/cmdutil/globals.go', '新增全局变量'],
['shortcuts/board/board.go', '新建6 个命令'],
['shortcuts/file/file.go', '新建11 个命令'],
['shortcuts/milestone/milestone.go', '新建6 个命令'],
['shortcuts/wiki/wiki.go', '新建6 个命令'],
['shortcuts/webhook/webhook.go', '新建7 个命令'],
['shortcuts/issue/issue.go', '新增 13 个命令'],
['shortcuts/pr/pr.go', '新增 8 个命令'],
]
)
add_para('')
add_para('验证结果go build 通过go test ./... 全部通过。')
# === 保存 ===
output_path = r'C:\Users\Lenovo\Desktop\soft运维\gitlink-cli\doc\任务一\子赛题一报告.docx'
doc.save(output_path)
print(f'报告已生成: {output_path}')

View File

@ -0,0 +1,39 @@
子赛题一增加和完善GitLink-CLI能力
定位扩展CLI功能丨难度中高丨需要Go语言基础
为gitlink-cli增加新功能或优化现有功能包括但不限于
·新增Shortcut命令如Wiki管理、Webhook配置、项目看板增强
·优化现有命令的参数设计、输出格式、错误提示和帮助文档
·增加批量操作能力如批量Issue操作、批量仓库管理、批量成员邀请)
·提升跨平台兼容性和安装体验
·补全RawAPI封装对齐GitLinkOpenAPI中尚未封装的接口
交付要求:
·向gitlink-cli主仓库提交PR可以是多个
·每个PR包含功能代码+单元测试+命令帮助文档更新
·提供变更说明文档
模板:
第二章 子赛题一:增强与完善 GitLink-CLI 能力
2.1 任务目标与整体思路
【占位】说明本子赛题的定位(扩展 CLI 功能)、你选择的切入方向、整体设计思路。
2.2 三层命令体系架构
【占位】用一段话+架构图说明Shortcuts+前缀)→ API Commands元数据驱动→ Raw API 三层结构,以及本次工作落在哪一层。
【占位】此处插入架构图(三层命令体系)。
2.3 新增 Shortcut 命令
2.3.1 Wiki 管理命令
【占位】列出 wiki +list/+view/+create/+update/+delete说明参数设计、输出格式、使用示例。
2.3.2 Webhook 配置命令
【占位】列出 webhook +list/+create/+update/+test/+delete/+events/+info说明设计要点与示例。
2.3.3 项目看板
【占位】列出 milestone 相关命令及使用示例
2.4优化部分
2.5批量操作能力增强 都有哪些,,按大类区分
2.6 跨平台兼容性与安装体验
【占位】Windows PowerShell 脚本、路径处理、一键安装脚本、npm 安装等改进点。
2.7 Raw API 封装补全
【占位】列出对齐 GitLink OpenAPI 新封装的接口清单。
2.8 单元测试与命令帮助文档
【占位】测试文件位置、覆盖率、关键用例help 文本与示例更新说明。
2.9 PR 提交记录与变更说明
【占位】以表格列出各 PR编号、标题、内容摘要、合并状态、链接。

View File

@ -723,9 +723,9 @@ func newAssignShortcut() *common.Shortcut {
return err
}
body := map[string]interface{}{
"subject": subject,
"description": description,
"assigned_to_id": assigneeID,
"subject": subject,
"description": description,
"assigner_ids": []interface{}{assigneeID},
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {

View File

@ -437,7 +437,7 @@ func runBatchAssign(ctx *common.RuntimeContext) error {
continue
}
// 调用 updateIssueField 分配用户
if err := updateIssueField(ctx, number, map[string]interface{}{"assigned_to_id": assigneeID}); err != nil {
if err := updateIssueField(ctx, number, map[string]interface{}{"assigner_ids": []interface{}{assigneeID}}); err != nil {
result.Status = "failed"
result.Error = err.Error()
summary.Failed++

View File

@ -94,7 +94,7 @@ func Shortcuts() []*common.Shortcut {
body["description"] = desc
}
if a := ctx.Arg("assignee"); a != "" {
body["assigned_to_id"] = a
body["assigner_ids"] = []interface{}{a}
}
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m

View File

@ -115,6 +115,12 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return clierrors.OpError(clierrors.KindNotFound, "view", "milestone", err).WithCommand(ctx.CommandName)
}
// API returns {milestone: {...}, issues: [...], ...}; extract milestone for display.
if data, ok := env.Data.(map[string]interface{}); ok {
if ms, ok := data["milestone"]; ok {
return ctx.OutputData(ms)
}
}
return ctx.Output(env)
},
},