From 4b2d45baeafe31652bed83764fc63a20dbba3f23 Mon Sep 17 00:00:00 2001 From: Donkey_kevin <2930705585@qq.com> Date: Fri, 10 Jul 2026 11:06:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=20milestone=20+view?= =?UTF-8?q?=20=E8=BE=93=E5=87=BA=20+=20issue/board=20assign=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E4=BF=AE=E6=AD=A3=20+=20=E6=96=87=E6=A1=A3=E6=9B=B4?= =?UTF-8?q?=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 milestone +view 显示 No results 的问题(提取 milestone 字段输出) - 修正 issue/board 的 assign 字段:assigned_to_id -> assigner_ids - 添加代码逻辑文档、CLI 优化报告、reading notes - 添加 board 功能示例和修改笔记 --- doc/PR格式模板-board功能示例.md | 450 +++++++++++ doc/reading_notes/01_types.md | 552 +++++++++++++ doc/reading_notes/02_client.md | 560 +++++++++++++ doc/reading_notes/03_wiki.md | 706 +++++++++++++++++ doc/reading_notes/04_webhook.md | 510 ++++++++++++ doc/reading_notes/05_issue_batch.md | 546 +++++++++++++ doc/reading_notes/06_issue_batch_create.md | 673 ++++++++++++++++ doc/reading_notes/07_repo_batch_create.md | 405 ++++++++++ doc/reading_notes/1.txt | 83 ++ doc/代码逻辑.md | 787 +++++++++++++++++++ doc/任务一/CLI优化报告.md | 535 +++++++++++++ doc/任务一/board-shortcut-修改笔记.md | 224 ++++++ doc/任务一/generate_report.py | 386 +++++++++ doc/任务一/创作模板.txt | 39 + shortcuts/board/board.go | 6 +- shortcuts/issue/batch.go | 2 +- shortcuts/issue/issue.go | 2 +- shortcuts/milestone/milestone.go | 6 + 18 files changed, 6467 insertions(+), 5 deletions(-) create mode 100644 doc/PR格式模板-board功能示例.md create mode 100644 doc/reading_notes/01_types.md create mode 100644 doc/reading_notes/02_client.md create mode 100644 doc/reading_notes/03_wiki.md create mode 100644 doc/reading_notes/04_webhook.md create mode 100644 doc/reading_notes/05_issue_batch.md create mode 100644 doc/reading_notes/06_issue_batch_create.md create mode 100644 doc/reading_notes/07_repo_batch_create.md create mode 100644 doc/reading_notes/1.txt create mode 100644 doc/代码逻辑.md create mode 100644 doc/任务一/CLI优化报告.md create mode 100644 doc/任务一/board-shortcut-修改笔记.md create mode 100644 doc/任务一/generate_report.py create mode 100644 doc/任务一/创作模板.txt diff --git a/doc/PR格式模板-board功能示例.md b/doc/PR格式模板-board功能示例.md new file mode 100644 index 00000000..8d2cceda --- /dev/null +++ b/doc/PR格式模板-board功能示例.md @@ -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` 格式 +``` diff --git a/doc/reading_notes/01_types.md b/doc/reading_notes/01_types.md new file mode 100644 index 00000000..df324da4 --- /dev/null +++ b/doc/reading_notes/01_types.md @@ -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 { + // 命令执行逻辑 + } +``` + diff --git a/doc/reading_notes/02_client.md b/doc/reading_notes/02_client.md new file mode 100644 index 00000000..3057fae3 --- /dev/null +++ b/doc/reading_notes/02_client.md @@ -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.Reader(HTTP 请求需要的格式) + 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. 构建 URL:https://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. 解析 JSON:json.Unmarshal → map[string]interface{} +8. 检查业务错误:判断 status 字段 +9. 返回 Envelope:output.SuccessEnvelope(raw, meta) +``` diff --git a/doc/reading_notes/03_wiki.md b/doc/reading_notes/03_wiki.md new file mode 100644 index 00000000..11ee855a --- /dev/null +++ b/doc/reading_notes/03_wiki.md @@ -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 返回 204(No 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 内容更易读,同时节省 token(Base64 编码会增加约 33% 的体积)。 + +--- + +## 第 301-526 行:Lint 相关 \ No newline at end of file diff --git a/doc/reading_notes/04_webhook.md b/doc/reading_notes/04_webhook.md new file mode 100644 index 00000000..6b4f04bd --- /dev/null +++ b/doc/reading_notes/04_webhook.md @@ -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, ",") +``` + +按逗号分割字符串,返回字符串数组 \ No newline at end of file diff --git a/doc/reading_notes/05_issue_batch.md b/doc/reading_notes/05_issue_batch.md new file mode 100644 index 00000000..665a294b --- /dev/null +++ b/doc/reading_notes/05_issue_batch.md @@ -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) +``` + +把字符串转换成整数,如果失败返回错误 \ No newline at end of file diff --git a/doc/reading_notes/06_issue_batch_create.md b/doc/reading_notes/06_issue_batch_create.md new file mode 100644 index 00000000..b5c93a14 --- /dev/null +++ b/doc/reading_notes/06_issue_batch_create.md @@ -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. 构建缓存 key(owner/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)...) +``` + +`...` 表示把切片展开成多个参数 \ No newline at end of file diff --git a/doc/reading_notes/07_repo_batch_create.md b/doc/reading_notes/07_repo_batch_create.md new file mode 100644 index 00000000..d8c46b5b --- /dev/null +++ b/doc/reading_notes/07_repo_batch_create.md @@ -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)` 的切片,避免动态扩容的性能开销 \ No newline at end of file diff --git a/doc/reading_notes/1.txt b/doc/reading_notes/1.txt new file mode 100644 index 00000000..a8929414 --- /dev/null +++ b/doc/reading_notes/1.txt @@ -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 决定输出格式 + + --- \ No newline at end of file diff --git a/doc/代码逻辑.md b/doc/代码逻辑.md new file mode 100644 index 00000000..01ca67bd --- /dev/null +++ b/doc/代码逻辑.md @@ -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 API(Wiki 操作) + +### 关键代码逻辑 + +#### 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 部分 diff --git a/doc/任务一/CLI优化报告.md b/doc/任务一/CLI优化报告.md new file mode 100644 index 00000000..7178a0dc --- /dev/null +++ b/doc/任务一/CLI优化报告.md @@ -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/Example(list, create, view, close, update) | +| `shortcuts/pr/pr.go` | 错误统一 + Choices(state, method) + Long/Example(list, create, merge) | +| `shortcuts/repo/repo.go` | 错误统一 + Choices(category) + Long/Example(list, create, update) | + +所有新增字段零值安全,现有 80+ Shortcut 无需修改即可编译。`output.Print()` 签名不变,内部包装为 `PrintWithOpts`。Workflow 脚本显式使用 `--format json`,不受默认格式变化影响。 + +验证:`go build` 通过,`go vet` 通过(仅 pre-existing milestone 包有构建错误),`go test ./...` 7 个测试包全部通过。 diff --git a/doc/任务一/board-shortcut-修改笔记.md b/doc/任务一/board-shortcut-修改笔记.md new file mode 100644 index 00000000..fc25f9ab --- /dev/null +++ b/doc/任务一/board-shortcut-修改笔记.md @@ -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 . + +# 同步到 npm(npm 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+description(PATCH 前必须调用) +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 | diff --git a/doc/任务一/generate_report.py b/doc/任务一/generate_report.py new file mode 100644 index 00000000..01e18d7f --- /dev/null +++ b/doc/任务一/generate_report.py @@ -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 API(HTTP 原始调用)', '完全透传,适合调试和边缘场景', '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('(1)Wiki 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、Windows(x64/arm64)三个平台。') +add_para('安装方式:') +add_para('(1)一键安装脚本:curl -sSL .../install.sh | bash,自动检测平台和架构。') +add_para('(2)npm 安装:npm install -g @gitlink-ai/cli,postinstall 自动下载对应平台二进制。') +add_para('(3)源码编译:go install . 后手动同步到 npm 包。') +add_para('Windows 特殊处理:npm shim(cli.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('(2)15 个 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}') diff --git a/doc/任务一/创作模板.txt b/doc/任务一/创作模板.txt new file mode 100644 index 00000000..c85cbb5d --- /dev/null +++ b/doc/任务一/创作模板.txt @@ -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:编号、标题、内容摘要、合并状态、链接。 \ No newline at end of file diff --git a/shortcuts/board/board.go b/shortcuts/board/board.go index 240ebb2c..6c33a6b6 100644 --- a/shortcuts/board/board.go +++ b/shortcuts/board/board.go @@ -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 { diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index d2b0732a..fe346ea3 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -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++ diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index fe2bc98c..65698e01 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -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 diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go index f28ba03c..4e4d9e25 100644 --- a/shortcuts/milestone/milestone.go +++ b/shortcuts/milestone/milestone.go @@ -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) }, },