408 lines
10 KiB
Go
408 lines
10 KiB
Go
package issue
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
)
|
|
|
|
var issueTagCache sync.Map
|
|
|
|
// resolveIssueTags fetches the project's issue tags and returns a name→id mapping.
|
|
// Results are cached per owner/repo.
|
|
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
|
|
}
|
|
|
|
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, e.g. 标题1,标题2"},
|
|
{Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent (default: normal)"},
|
|
{Name: "label", Short: "l", Usage: "Label name, e.g. 缺陷"},
|
|
{Name: "assignee", Short: "a", Usage: "Assignee login name"},
|
|
{Name: "state", Short: "s", Usage: "Initial state: new, in-progress, resolved, closed, rejected (default: new)", Default: "new"},
|
|
{Name: "from", Usage: "CSV file path"},
|
|
{Name: "template", Short: "t", Usage: "Template: bug or feature (only with --from)"},
|
|
{Name: "dry-run", Usage: "Preview without creating issues", Bool: true, Default: "false"},
|
|
},
|
|
Run: runBatchCreate,
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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")))
|
|
|
|
// Collect inputs from --titles and/or --from
|
|
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; use --titles 标题1,标题2 or --from issues.csv")
|
|
}
|
|
|
|
// Apply CLI --state as fallback for inputs without an explicit status
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func buildTemplateDescription(input createIssueInput, template string) string {
|
|
switch template {
|
|
case "bug":
|
|
return buildBugDescription(input)
|
|
case "feature":
|
|
return buildFeatureDescription(input)
|
|
default:
|
|
return input.Body
|
|
}
|
|
}
|
|
|
|
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)
|
|
b.WriteString("\n")
|
|
}
|
|
if input.Severity != "" {
|
|
b.WriteString("\n## 严重程度\n")
|
|
b.WriteString(input.Severity)
|
|
b.WriteString("\n")
|
|
}
|
|
if input.Steps != "" {
|
|
b.WriteString("\n## 复现步骤\n")
|
|
b.WriteString(input.Steps)
|
|
b.WriteString("\n")
|
|
}
|
|
if input.Expected != "" {
|
|
b.WriteString("\n## 期望结果\n")
|
|
b.WriteString(input.Expected)
|
|
b.WriteString("\n")
|
|
}
|
|
if input.Actual != "" {
|
|
b.WriteString("\n## 实际结果\n")
|
|
b.WriteString(input.Actual)
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func buildFeatureDescription(input createIssueInput) string {
|
|
var b strings.Builder
|
|
b.WriteString("## 用户故事\n")
|
|
if input.UserStory != "" {
|
|
b.WriteString(input.UserStory)
|
|
} else {
|
|
b.WriteString(input.Title)
|
|
}
|
|
b.WriteString("\n")
|
|
|
|
if input.Body != "" {
|
|
b.WriteString("\n## 描述\n")
|
|
b.WriteString(input.Body)
|
|
b.WriteString("\n")
|
|
}
|
|
if input.Acceptance != "" {
|
|
b.WriteString("\n## 验收标准\n")
|
|
b.WriteString(input.Acceptance)
|
|
b.WriteString("\n")
|
|
}
|
|
if input.Priority != "" {
|
|
b.WriteString("\n## 优先级\n")
|
|
b.WriteString(input.Priority)
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
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"),
|
|
// Support alternate heading for feature template
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func normalizeHeader(h string) string {
|
|
return strings.ToLower(strings.TrimSpace(h))
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
runes := []rune(s)
|
|
if len(runes) <= n {
|
|
return s
|
|
}
|
|
return string(runes[:n]) + "..."
|
|
}
|