From cea318d1795a0e186f2d6d9951628184bd9d0b91 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Mon, 8 Jun 2026 20:16:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Issue=20=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=AF=BC=E5=87=BA=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + README.zh-CN.md | 3 + doc/changes/issue-export-shortcut.md | 7 + internal/i18n/locales/en-US.json | 7 + internal/i18n/locales/zh-CN.json | 7 + shortcuts/issue/export.go | 524 ++++++++++++++++++ shortcuts/issue/issue.go | 1 + shortcuts/issue/issue_test.go | 179 ++++++ skills/README.md | 2 +- skills/gitlink-issue/SKILL.md | 4 + .../references/gitlink-issue-export.md | 41 ++ 11 files changed, 777 insertions(+), 1 deletion(-) create mode 100644 doc/changes/issue-export-shortcut.md create mode 100644 shortcuts/issue/export.go create mode 100644 skills/gitlink-issue/references/gitlink-issue-export.md diff --git a/README.md b/README.md index e5e4318..0a682eb 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,9 @@ gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 - gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes +# Export filtered issues to CSV for offline triage or reports +gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --keyword bug --export-format csv --output issues.csv + # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..5172aa0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -364,6 +364,9 @@ gitlink-cli issue +batch-update --owner Gitlink --repo forgeplus --ids 101,102 - gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --dry-run gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 --yes +# 将筛选后的 Issue 导出为 CSV,便于离线分析或生成周报 +gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --keyword bug --export-format csv --output issues.csv + # 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" diff --git a/doc/changes/issue-export-shortcut.md b/doc/changes/issue-export-shortcut.md new file mode 100644 index 0000000..b2f1dab --- /dev/null +++ b/doc/changes/issue-export-shortcut.md @@ -0,0 +1,7 @@ +# Issue 批量导出命令 + +新增 `gitlink-cli issue +export`,用于把筛选后的 Issue 跨页导出为 CSV、JSON 或 Markdown。维护者经常需要把 Issue 列表带出 GitLink,用于周报、迁移、离线排查或交给脚本/AI Agent 做进一步分析;过去只能手动翻页复制或依赖原始 API 拼参数,容易漏页,也不方便统一字段。 + +命令复用 `issue +list` 的常用筛选条件,包括状态、关键词、参与范围、作者、负责人、里程碑、状态 ID、标签和排序参数;同时增加 `--limit`、`--max` 控制导出规模,`--fields` 控制输出字段,`--export-format` 选择 CSV/JSON/Markdown,`--output` 写入文件。不传 `--output` 时会直接把导出内容输出到 stdout,方便管道处理。 + +实现上新增独立的 Issue 导出分页逻辑,兼容 GitLink Issue 列表返回的 `issues` 包装结构,并把嵌套的状态、优先级、作者、负责人、标签等字段规范化为稳定列。已补充单元测试覆盖筛选参数、多页导出、`--max` 截断、Markdown 转义和非法参数校验。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..e47c41c 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -43,6 +43,8 @@ "cmd.issue.close.short": "Close an issue", "cmd.issue.comment.short": "Add a comment to an issue", "cmd.issue.create.short": "Create a new issue", + "cmd.issue.export.long": "Export filtered issues across multiple pages to CSV, JSON, or Markdown.\n\nThe command reuses issue list filters and is read-only, making it suitable for reports, migration checklists, offline triage, and AI analysis inputs.\n\nExamples:\n gitlink-cli issue +export --state open --export-format csv --output issues.csv\n gitlink-cli issue +export --state all --keyword bug --fields number,title,status,assignees,updated_at,url --export-format markdown\n gitlink-cli issue +export --tag-ids 1,2 --max 100 --export-format json --output issues.json", + "cmd.issue.export.short": "Export filtered issues to CSV, JSON, or Markdown", "cmd.issue.list.short": "List issues", "cmd.issue.short": "Issue operations", "cmd.issue.update.short": "Update an issue", @@ -158,6 +160,11 @@ "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", "flag.issue.body": "Issue description", + "flag.issue.export_fields": "Comma-separated fields to export", + "flag.issue.export_format": "Export format: csv, json, markdown", + "flag.issue.export_limit": "Items per page, capped at 100", + "flag.issue.export_max": "Maximum issues to export; 0 means no explicit cap", + "flag.issue.export_output": "Write export content to a file instead of stdout", "flag.issue.label": "Label ID", "flag.issue.label_filter": "Filter by existing label", "flag.issue.milestone": "Milestone ID", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..c8df838 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -43,6 +43,8 @@ "cmd.issue.close.short": "关闭议题", "cmd.issue.comment.short": "给议题添加评论", "cmd.issue.create.short": "创建新议题", + "cmd.issue.export.long": "跨页导出筛选后的议题,支持 CSV、JSON 或 Markdown。\n\n命令复用议题列表筛选条件,并且只读取远端数据,适合生成周报、迁移清单、离线排查表和 AI 分析输入。\n\n示例:\n gitlink-cli issue +export --state open --export-format csv --output issues.csv\n gitlink-cli issue +export --state all --keyword bug --fields number,title,status,assignees,updated_at,url --export-format markdown\n gitlink-cli issue +export --tag-ids 1,2 --max 100 --export-format json --output issues.json", + "cmd.issue.export.short": "导出筛选后的议题为 CSV、JSON 或 Markdown", "cmd.issue.list.short": "列出议题", "cmd.issue.short": "议题操作", "cmd.issue.update.short": "更新议题", @@ -158,6 +160,11 @@ "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", "flag.issue.body": "议题描述", + "flag.issue.export_fields": "逗号分隔的导出字段", + "flag.issue.export_format": "导出格式:csv、json、markdown", + "flag.issue.export_limit": "每页条目数,上限 100", + "flag.issue.export_max": "最多导出的议题数;0 表示不额外限制", + "flag.issue.export_output": "将导出内容写入文件;不传时输出到 stdout", "flag.issue.label": "标签 ID", "flag.issue.label_filter": "按已有标签筛选", "flag.issue.milestone": "里程碑 ID", diff --git a/shortcuts/issue/export.go b/shortcuts/issue/export.go new file mode 100644 index 0000000..c8fb159 --- /dev/null +++ b/shortcuts/issue/export.go @@ -0,0 +1,524 @@ +package issue + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "net/url" + "os" + "sort" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +const defaultIssueExportFields = "number,title,state,status,priority,author,assignees,tags,updated_at,url" + +type issueExportRecord map[string]string + +type issueExportSummary struct { + Repository string `json:"repository" yaml:"repository"` + Format string `json:"format" yaml:"format"` + Output string `json:"output,omitempty" yaml:"output,omitempty"` + Total int `json:"total" yaml:"total"` + Pages int `json:"pages" yaml:"pages"` + Fields string `json:"fields" yaml:"fields"` +} + +func newExportShortcut(tr *i18n.Translator) *common.Shortcut { + return &common.Shortcut{ + Name: "export", + Description: tr.T("cmd.issue.export.short"), + Long: tr.T("cmd.issue.export.long"), + Flags: []common.Flag{ + {Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword")}, + {Name: "participant", Usage: tr.T("flag.issue.participant")}, + {Name: "author-id", Usage: tr.T("flag.issue.author_id")}, + {Name: "assignee-id", Usage: tr.T("flag.issue.assignee_id")}, + {Name: "milestone-id", Usage: tr.T("flag.issue.milestone")}, + {Name: "status-id", Usage: tr.T("flag.issue.status_id")}, + {Name: "tag-ids", Usage: tr.T("flag.issue.tag_ids")}, + {Name: "sort-by", Usage: tr.T("flag.sort_by")}, + {Name: "sort-direction", Usage: tr.T("flag.sort_direction")}, + {Name: "limit", Short: "l", Usage: tr.T("flag.issue.export_limit"), Default: "50"}, + {Name: "max", Usage: tr.T("flag.issue.export_max"), Default: "0"}, + {Name: "fields", Usage: tr.T("flag.issue.export_fields"), Default: defaultIssueExportFields}, + {Name: "export-format", Usage: tr.T("flag.issue.export_format"), Default: "csv"}, + {Name: "output", Short: "o", Usage: tr.T("flag.issue.export_output")}, + }, + Run: runIssueExport, + } +} + +func runIssueExport(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + limit, err := boundedPositiveInt(ctx.Arg("limit"), 50, 100, "limit") + if err != nil { + return err + } + maxItems, err := nonNegativeInt(ctx.Arg("max"), "max") + if err != nil { + return err + } + fields, err := parseIssueExportFields(ctx.Arg("fields")) + if err != nil { + return err + } + format, err := normalizeIssueExportFormat(ctx.Arg("export-format")) + if err != nil { + return err + } + + issues, pages, err := fetchIssuesForExport(ctx, buildIssueExportQuery(ctx, limit), limit, maxItems) + if err != nil { + return err + } + records := make([]issueExportRecord, 0, len(issues)) + for _, issue := range issues { + records = append(records, normalizeIssueExportRecord(ctx, issue)) + } + + content, err := renderIssueExport(records, fields, format) + if err != nil { + return err + } + outputPath := strings.TrimSpace(ctx.Arg("output")) + if outputPath == "" { + _, err = os.Stdout.Write(content) + return err + } + if err := os.WriteFile(outputPath, content, 0644); err != nil { + return fmt.Errorf("write export file: %w", err) + } + return ctx.OutputData(issueExportSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Format: format, + Output: outputPath, + Total: len(records), + Pages: pages, + Fields: strings.Join(fields, ","), + }) +} + +func buildIssueExportQuery(ctx *common.RuntimeContext, limit int) url.Values { + q := url.Values{} + q.Set("limit", strconv.Itoa(limit)) + if s := ctx.Arg("state"); s != "" { + q.Set("category", normalizeIssueListState(s)) + } + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + if participant := ctx.Arg("participant"); participant != "" { + q.Set("participant_category", participant) + } + if authorID := ctx.Arg("author-id"); authorID != "" { + q.Set("author_id", authorID) + } + if assigneeID := ctx.Arg("assignee-id"); assigneeID != "" { + q.Set("assigner_id", assigneeID) + } + if milestoneID := ctx.Arg("milestone-id"); milestoneID != "" { + q.Set("milestone_id", milestoneID) + } + if statusID := ctx.Arg("status-id"); statusID != "" { + q.Set("status_id", statusID) + } + if tagIDs := ctx.Arg("tag-ids"); tagIDs != "" { + q.Set("issue_tag_ids", tagIDs) + } + if sortBy := ctx.Arg("sort-by"); sortBy != "" { + q.Set("sort_by", sortBy) + } + if sortDirection := ctx.Arg("sort-direction"); sortDirection != "" { + q.Set("sort_direction", sortDirection) + } + return q +} + +func fetchIssuesForExport(ctx *common.RuntimeContext, query url.Values, limit, maxItems int) ([]map[string]interface{}, int, error) { + var result []map[string]interface{} + page := 1 + pagesFetched := 0 + for { + query.Set("page", strconv.Itoa(page)) + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", query) + if err != nil { + return nil, pagesFetched, err + } + items := extractIssueList(env) + if len(items) == 0 { + break + } + pagesFetched++ + for _, item := range items { + if maxItems > 0 && len(result) >= maxItems { + return result, pagesFetched, nil + } + result = append(result, item) + } + if len(items) < limit { + break + } + if total := totalCount(env); total > 0 && len(result) >= total { + break + } + page++ + } + return result, pagesFetched, nil +} + +func extractIssueList(env *output.Envelope) []map[string]interface{} { + if env == nil || env.Data == nil { + return nil + } + var raw []interface{} + switch data := env.Data.(type) { + case []interface{}: + raw = data + case map[string]interface{}: + for _, key := range []string{"issues", "data"} { + if values, ok := data[key].([]interface{}); ok { + raw = values + break + } + } + } + items := make([]map[string]interface{}, 0, len(raw)) + for _, item := range raw { + if issue, ok := item.(map[string]interface{}); ok { + items = append(items, issue) + } + } + return items +} + +func totalCount(env *output.Envelope) int { + if env == nil { + return 0 + } + if env.Meta != nil && env.Meta.TotalCount > 0 { + return env.Meta.TotalCount + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return 0 + } + return intFromAny(data["total_count"]) +} + +func normalizeIssueExportRecord(ctx *common.RuntimeContext, issue map[string]interface{}) issueExportRecord { + number := firstStringValue(issue, "project_issues_index", "number", "index") + databaseID := firstStringValue(issue, "id", "database_id") + record := issueExportRecord{ + "number": number, + "database_id": databaseID, + "title": firstStringValue(issue, "subject", "title", "name"), + "description": firstStringValue(issue, "description", "body"), + "state": issueState(issue), + "status": nestedName(issue, "status"), + "status_id": nestedID(issue, "status"), + "priority": nestedName(issue, "priority"), + "priority_id": nestedID(issue, "priority"), + "author": nestedLoginOrName(issue, "author"), + "assignees": joinedNames(issue, "assigners", "assignees", "assigned_to"), + "tags": joinedNames(issue, "tags", "issue_tags"), + "milestone": nestedName(issue, "version", "fixed_version", "milestone"), + "branch": firstStringValue(issue, "branch_name"), + "created_at": firstStringValue(issue, "created_at", "created_on"), + "updated_at": firstStringValue(issue, "updated_at", "updated_on"), + "closed_at": firstStringValue(issue, "closed_at", "closed_on"), + } + if number != "" { + record["url"] = fmt.Sprintf("https://www.gitlink.org.cn/%s/%s/issues/%s", ctx.Owner, ctx.Repo, url.PathEscape(number)) + } + return record +} + +func renderIssueExport(records []issueExportRecord, fields []string, format string) ([]byte, error) { + switch format { + case "csv": + return renderIssueExportCSV(records, fields) + case "json": + return json.MarshalIndent(selectIssueExportFields(records, fields), "", " ") + case "markdown": + return renderIssueExportMarkdown(records, fields), nil + default: + return nil, fmt.Errorf("unsupported export format %q", format) + } +} + +func selectIssueExportFields(records []issueExportRecord, fields []string) []issueExportRecord { + selected := make([]issueExportRecord, 0, len(records)) + for _, record := range records { + item := issueExportRecord{} + for _, field := range fields { + item[field] = record[field] + } + selected = append(selected, item) + } + return selected +} + +func renderIssueExportCSV(records []issueExportRecord, fields []string) ([]byte, error) { + var buf bytes.Buffer + writer := csv.NewWriter(&buf) + if err := writer.Write(fields); err != nil { + return nil, err + } + for _, record := range records { + row := make([]string, len(fields)) + for i, field := range fields { + row[i] = record[field] + } + if err := writer.Write(row); err != nil { + return nil, err + } + } + writer.Flush() + return buf.Bytes(), writer.Error() +} + +func renderIssueExportMarkdown(records []issueExportRecord, fields []string) []byte { + var buf strings.Builder + buf.WriteString("| ") + buf.WriteString(strings.Join(fields, " | ")) + buf.WriteString(" |\n| ") + separators := make([]string, len(fields)) + for i := range separators { + separators[i] = "---" + } + buf.WriteString(strings.Join(separators, " | ")) + buf.WriteString(" |\n") + for _, record := range records { + values := make([]string, len(fields)) + for i, field := range fields { + values[i] = escapeMarkdownCell(record[field]) + } + buf.WriteString("| ") + buf.WriteString(strings.Join(values, " | ")) + buf.WriteString(" |\n") + } + return []byte(buf.String()) +} + +func parseIssueExportFields(value string) ([]string, error) { + if strings.TrimSpace(value) == "" { + value = defaultIssueExportFields + } + allowed := issueExportAllowedFields() + fields := []string{} + seen := map[string]bool{} + for _, part := range strings.Split(value, ",") { + field := strings.ToLower(strings.TrimSpace(part)) + if field == "" { + continue + } + if !allowed[field] { + return nil, fmt.Errorf("unsupported export field %q", field) + } + if seen[field] { + continue + } + seen[field] = true + fields = append(fields, field) + } + if len(fields) == 0 { + return nil, fmt.Errorf("at least one export field is required") + } + return fields, nil +} + +func issueExportAllowedFields() map[string]bool { + fields := []string{ + "number", "database_id", "title", "description", "state", "status", "status_id", + "priority", "priority_id", "author", "assignees", "tags", "milestone", "branch", + "created_at", "updated_at", "closed_at", "url", + } + allowed := map[string]bool{} + for _, field := range fields { + allowed[field] = true + } + return allowed +} + +func normalizeIssueExportFormat(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "csv": + return "csv", nil + case "json": + return "json", nil + case "md", "markdown": + return "markdown", nil + default: + return "", fmt.Errorf("unsupported export format %q: use csv, json, or markdown", value) + } +} + +func boundedPositiveInt(value string, defaultValue, maxValue int, label string) (int, error) { + value = strings.TrimSpace(value) + if value == "" { + return defaultValue, nil + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("%s must be a positive integer", label) + } + if parsed > maxValue { + return maxValue, nil + } + return parsed, nil +} + +func nonNegativeInt(value, label string) (int, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, nil + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 0 { + return 0, fmt.Errorf("%s must be a non-negative integer", label) + } + return parsed, nil +} + +func issueState(issue map[string]interface{}) string { + for _, key := range []string{"state", "status_name"} { + if value := stringFromAny(issue[key]); value != "" { + return value + } + } + statusID := nestedID(issue, "status") + if statusID == "5" { + return "closed" + } + return "open" +} + +func firstStringValue(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value := stringFromAny(data[key]); value != "" { + return value + } + } + return "" +} + +func nestedName(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + nested, ok := data[key].(map[string]interface{}) + if !ok { + continue + } + if value := firstStringValue(nested, "name", "title", "subject"); value != "" { + return value + } + } + return "" +} + +func nestedID(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + nested, ok := data[key].(map[string]interface{}) + if !ok { + continue + } + if value := stringFromAny(nested["id"]); value != "" { + return value + } + } + return "" +} + +func nestedLoginOrName(data map[string]interface{}, key string) string { + nested, ok := data[key].(map[string]interface{}) + if !ok { + return "" + } + return firstStringValue(nested, "login", "name") +} + +func joinedNames(data map[string]interface{}, keys ...string) string { + values := []string{} + for _, key := range keys { + switch raw := data[key].(type) { + case []interface{}: + for _, item := range raw { + if name := itemName(item); name != "" { + values = append(values, name) + } + } + case map[string]interface{}: + if name := itemName(raw); name != "" { + values = append(values, name) + } + } + if len(values) > 0 { + break + } + } + sort.Strings(values) + return strings.Join(values, ";") +} + +func itemName(item interface{}) string { + switch value := item.(type) { + case map[string]interface{}: + return firstStringValue(value, "name", "login", "title") + case string: + return value + default: + return stringFromAny(value) + } +} + +func stringFromAny(value interface{}) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case float64: + if v == float64(int64(v)) { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(v, 'f', -1, 64) + case int: + return strconv.Itoa(v) + case int64: + return strconv.FormatInt(v, 10) + case json.Number: + return v.String() + default: + return "" + } +} + +func intFromAny(value interface{}) int { + switch v := value.(type) { + case float64: + return int(v) + case int: + return v + case int64: + return int(v) + case json.Number: + parsed, _ := strconv.Atoi(v.String()) + return parsed + default: + return 0 + } +} + +func escapeMarkdownCell(value string) string { + value = strings.ReplaceAll(value, "\\", "\\\\") + value = strings.ReplaceAll(value, "|", "\\|") + value = strings.ReplaceAll(value, "\r", " ") + value = strings.ReplaceAll(value, "\n", " ") + return value +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index b19027e..52393f1 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -47,6 +47,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { newBatchCloseShortcut(), newBatchUpdateShortcut(), newBatchDeleteShortcut(), + newExportShortcut(tr), { Name: "list", Description: tr.T("cmd.issue.list.short"), diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 48be057..d6974a7 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -171,6 +173,183 @@ func TestIssueListStateAll(t *testing.T) { } } +// --- export --- + +func TestIssueExportCSVWithFiltersAndPagination(t *testing.T) { + var pages []string + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Fatalf("expected GET, got %s", r.Method) + } + if r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + query := r.URL.Query() + assertEqual(t, query.Get("category"), "opened") + assertEqual(t, query.Get("keyword"), "release") + assertEqual(t, query.Get("participant_category"), "assignedme") + assertEqual(t, query.Get("author_id"), "10") + assertEqual(t, query.Get("assigner_id"), "11") + assertEqual(t, query.Get("milestone_id"), "12") + assertEqual(t, query.Get("status_id"), "1") + assertEqual(t, query.Get("issue_tag_ids"), "2,3") + assertEqual(t, query.Get("sort_by"), "issues.updated_on") + assertEqual(t, query.Get("sort_direction"), "desc") + assertEqual(t, query.Get("limit"), "2") + pages = append(pages, query.Get("page")) + + switch query.Get("page") { + case "1": + writeJSON(t, w, map[string]interface{}{ + "total_count": 3, + "issues": []interface{}{ + map[string]interface{}{ + "id": float64(101), + "project_issues_index": float64(1), + "subject": "release blocker", + "status": map[string]interface{}{"id": float64(1), "name": "New"}, + "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, + "author": map[string]interface{}{"login": "alice"}, + "assigners": []interface{}{ + map[string]interface{}{"login": "bob"}, + }, + "tags": []interface{}{ + map[string]interface{}{"name": "bug"}, + }, + "updated_on": "2026-06-01", + }, + map[string]interface{}{ + "id": float64(102), + "project_issues_index": float64(2), + "subject": "release notes", + "status": map[string]interface{}{"id": float64(1), "name": "New"}, + "priority": map[string]interface{}{"id": float64(3), "name": "High"}, + "author": map[string]interface{}{"login": "carol"}, + "updated_on": "2026-06-02", + }, + }, + }) + case "2": + writeJSON(t, w, map[string]interface{}{ + "total_count": 3, + "issues": []interface{}{ + map[string]interface{}{ + "id": float64(103), + "project_issues_index": float64(3), + "subject": "release checklist", + "status": map[string]interface{}{"id": float64(5), "name": "Closed"}, + "priority": map[string]interface{}{"id": float64(2), "name": "Normal"}, + "author": map[string]interface{}{"login": "dave"}, + "updated_on": "2026-06-03", + }, + }, + }) + default: + t.Fatalf("unexpected page %s", query.Get("page")) + } + }) + defer server.Close() + + outputPath := filepath.Join(t.TempDir(), "issues.csv") + err := runShortcut(t, server, "export", map[string]string{ + "state": "open", + "keyword": "release", + "participant": "assignedme", + "author-id": "10", + "assignee-id": "11", + "milestone-id": "12", + "status-id": "1", + "tag-ids": "2,3", + "sort-by": "issues.updated_on", + "sort-direction": "desc", + "limit": "2", + "fields": "number,title,status,priority,author,assignees,tags,updated_at,url", + "export-format": "csv", + "output": outputPath, + }) + if err != nil { + t.Fatalf("export failed: %v", err) + } + assertEqual(t, strings.Join(pages, ","), "1,2") + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read export file: %v", err) + } + got := string(content) + if !strings.Contains(got, "number,title,status,priority,author,assignees,tags,updated_at,url") { + t.Fatalf("missing csv header: %s", got) + } + if !strings.Contains(got, "1,release blocker,New,Normal,alice,bob,bug,2026-06-01,https://www.gitlink.org.cn/owner/repo/issues/1") { + t.Fatalf("missing first issue row: %s", got) + } + if !strings.Contains(got, "3,release checklist,Closed,Normal,dave,,,2026-06-03,https://www.gitlink.org.cn/owner/repo/issues/3") { + t.Fatalf("missing second page issue row: %s", got) + } +} + +func TestIssueExportMaxStopsWithinPage(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") != "1" { + t.Fatalf("unexpected page: %s", r.URL.Query().Get("page")) + } + writeJSON(t, w, map[string]interface{}{ + "total_count": 3, + "issues": []interface{}{ + map[string]interface{}{"project_issues_index": float64(1), "subject": "one"}, + map[string]interface{}{"project_issues_index": float64(2), "subject": "two"}, + }, + }) + }) + defer server.Close() + + outputPath := filepath.Join(t.TempDir(), "issues.json") + err := runShortcut(t, server, "export", map[string]string{ + "limit": "2", + "max": "1", + "fields": "number,title", + "export-format": "json", + "output": outputPath, + }) + if err != nil { + t.Fatalf("export failed: %v", err) + } + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatalf("read export file: %v", err) + } + if strings.Contains(string(content), `"title": "two"`) { + t.Fatalf("expected max=1 to omit second issue: %s", content) + } + if !strings.Contains(string(content), `"title": "one"`) { + t.Fatalf("expected first issue in json export: %s", content) + } +} + +func TestIssueExportMarkdownEscapesCells(t *testing.T) { + content := renderIssueExportMarkdown([]issueExportRecord{ + {"number": "1", "title": "pipe | newline\ntext"}, + }, []string{"number", "title"}) + got := string(content) + if !strings.Contains(got, "pipe \\| newline text") { + t.Fatalf("markdown cell not escaped: %s", got) + } +} + +func TestIssueExportRejectsInvalidOptions(t *testing.T) { + if _, err := parseIssueExportFields("number,unknown"); err == nil { + t.Fatal("expected invalid field error") + } + if _, err := normalizeIssueExportFormat("xml"); err == nil { + t.Fatal("expected invalid format error") + } + if _, err := boundedPositiveInt("0", 50, 100, "limit"); err == nil { + t.Fatal("expected invalid limit error") + } + if _, err := nonNegativeInt("-1", "max"); err == nil { + t.Fatal("expected invalid max error") + } +} + // --- create --- func TestIssueCreate(t *testing.T) { diff --git a/skills/README.md b/skills/README.md index d507074..bf311e8 100644 --- a/skills/README.md +++ b/skills/README.md @@ -128,7 +128,7 @@ skills/ |-------|------|----------| | **gitlink-shared** | 认证、全局参数、API 参考、安全规则、分支约定 | `auth login`, `auth status` | | **gitlink-repo** | 仓库管理与洞察 | `repo +list`, `repo +info`, `repo +languages`, `repo +contributors`, `repo +code-stats`, `repo +follow`, `repo +like` | -| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete` | +| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete`, `issue +export` | | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` | | **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 9bdb1b3..bed90f5 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -28,6 +28,7 @@ metadata: | `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | | `issue +batch-update` | 按 API issue id 批量更新状态、优先级、里程碑、标签、负责人 | 是(dry-run 不写入) | | `issue +batch-delete` | 按 API issue id 批量删除 Issue;真实删除必须 `--yes` | 是(dry-run 不写入) | +| `issue +export` | 按筛选条件批量导出 Issue 到 CSV/JSON/Markdown | 否(公开项目) | | `issue +comment` | 添加评论 | 是 | | `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) | | `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) | @@ -69,6 +70,9 @@ gitlink-cli issue +batch-update --owner myuser --repo myrepo --ids 101,102 --sta gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --dry-run gitlink-cli issue +batch-delete --owner myuser --repo myrepo --ids 101,102 --yes +# 导出打开的 Issue 到 CSV,用于周报、迁移或离线分析 +gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --keyword 登录 --export-format csv --output issues.csv + # 添加评论 gitlink-cli issue +comment --number 4 --body "已修复,请验证" diff --git a/skills/gitlink-issue/references/gitlink-issue-export.md b/skills/gitlink-issue/references/gitlink-issue-export.md new file mode 100644 index 0000000..f1c188b --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-export.md @@ -0,0 +1,41 @@ +# issue +export + +按 Issue 列表筛选条件跨页导出数据,支持 CSV、JSON 和 Markdown。该命令只读取远端数据,不会修改 Issue,适合生成周报、迁移清单、离线排查表和 AI 分析输入。 + +```bash +gitlink-cli issue +export --owner Gitlink --repo forgeplus --state open --export-format csv --output issues.csv +gitlink-cli issue +export --owner Gitlink --repo forgeplus --state all --keyword 登录 --fields number,title,status,assignees,updated_at,url --export-format markdown +gitlink-cli issue +export --owner Gitlink --repo forgeplus --tag-ids 1,2 --max 100 --export-format json --output issues.json +``` + +## 常用参数 + +| 参数 | 说明 | +|------|------| +| `--state` | 筛选状态:`open`、`closed`、`all` | +| `--keyword` | 按关键词搜索 | +| `--participant` | 参与范围:`all`、`aboutme`、`authoredme`、`assignedme`、`atme` | +| `--author-id` / `--assignee-id` | 按作者或负责人用户 ID 筛选 | +| `--milestone-id` / `--status-id` / `--tag-ids` | 按里程碑、状态或标签筛选 | +| `--sort-by` / `--sort-direction` | 复用 Issue 列表排序参数 | +| `--limit` | 每页数量,上限 100 | +| `--max` | 最多导出的 Issue 数量,`0` 表示不额外限制 | +| `--fields` | 逗号分隔的导出字段 | +| `--export-format` | `csv`、`json` 或 `markdown` | +| `--output` / `-o` | 写入文件;不传时输出到 stdout | + +## 字段 + +默认字段为: + +```text +number,title,state,status,priority,author,assignees,tags,updated_at,url +``` + +可选字段包括:`number`、`database_id`、`title`、`description`、`state`、`status`、`status_id`、`priority`、`priority_id`、`author`、`assignees`、`tags`、`milestone`、`branch`、`created_at`、`updated_at`、`closed_at`、`url`。 + +## 使用建议 + +- 需要给维护者或评委提交处理清单时,优先使用 `csv`,便于表格软件打开。 +- 需要交给 AI Agent 或脚本继续处理时,使用 `json`。 +- 需要直接贴到 Issue、PR 或周报时,使用 `markdown`。