forked from Gitlink/gitlink-cli
405 lines
10 KiB
Markdown
405 lines
10 KiB
Markdown
# 逐行讲解 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)` 的切片,避免动态扩容的性能开销 |