forked from Gitlink/gitlink-cli
16 KiB
16 KiB
逐行讲解 shortcuts/issue/batch_create.go(面向 Go 小白)
文件概述
这个文件实现了 Issue 批量创建功能,可以从命令行或 CSV 文件批量创建多个 Issue,并支持 bug 和 feature 两种模板。
一、包声明和导入
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 |
公共工具包 |
二、标签缓存
var issueTagCache sync.Map
作用:缓存项目的标签列表,避免重复请求 API
sync.Map:Go 语言提供的并发安全的 map,可以在多个 goroutine 中安全地读写
三、resolveIssueTags 函数
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 标签列表,并缓存结果
执行流程:
- 构建缓存 key(owner/repo)
- 先从缓存中查找,如果有就直接返回
- 如果缓存中没有,调用 API 获取标签列表
- 解析 API 响应,提取标签名称和 ID
- 处理多种 ID 类型(float64、int、其他)
- 把结果存入缓存
- 返回标签映射
四、命令定义
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:预览模式
五、输入结构体
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 主函数
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
}
执行流程:
- 解析仓库信息
- 获取项目标签列表
- 收集输入(从
--titles和/或--from) - 为没有指定状态的输入应用默认状态
- 遍历创建每个 Issue:
- 如果是 dry-run,标记为 planned
- 否则构建请求体并调用 API
- 从响应中提取新创建的 Issue 编号
- 输出汇总结果
七、buildCreateBody 函数
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 的请求体
逻辑:
- 设置默认值(状态、优先级、完成比例)
- 如果指定了模板,构建模板描述并设置对应的标签
- 否则使用自定义描述
- 应用优先级、标签、分配人等可选参数
八、模板描述构建
8.1 buildTemplateDescription
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
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 描述
输出格式:
## Bug 描述
标题内容
## 版本
v1.0.0
## 严重程度
高
## 复现步骤
步骤1
步骤2
## 期望结果
期望的行为
## 实际结果
实际的行为
8.3 buildFeatureDescription
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 描述
输出格式:
## 用户故事
作为用户,我想...
## 描述
详细描述
## 验收标准
- 标准1
- 标准2
## 优先级
high
九、CSV 读取
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
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
func normalizeHeader(h string) string {
return strings.ToLower(strings.TrimSpace(h))
}
功能:标准化 CSV 表头(转小写、去空格)
10.3 getCol
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
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
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
var b strings.Builder
b.WriteString("## Bug 描述\n")
b.WriteString(input.Title)
return b.String()
作用:高效拼接字符串,避免产生大量临时字符串
3. []interface
body["issue_tag_ids"] = []interface{}{tags["缺陷"]}
作用:创建一个包含任意类型的数组,用于 JSON 序列化
4. switch 类型断言
switch v := tag["id"].(type) {
case float64:
id = int(v)
case int:
id = v
default:
id, _ = strconv.Atoi(fmt.Sprintf("%v", v))
}
作用:根据值的实际类型执行不同的处理逻辑
5. 可变参数
inputs = append(inputs, parseTitles(titlesStr, ctx)...)
... 表示把切片展开成多个参数