feat: 新增 Issue 批量导出命令

This commit is contained in:
Mengz 2026-06-08 20:16:38 +08:00
parent 71ca2bb683
commit cea318d179
11 changed files with 777 additions and 1 deletions

View File

@ -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"

View File

@ -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 "已修复"

View File

@ -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 转义和非法参数校验。

View File

@ -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",

View File

@ -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",

524
shortcuts/issue/export.go Normal file
View File

@ -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
}

View File

@ -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"),

View File

@ -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) {

View File

@ -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` |

View File

@ -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 "已修复,请验证"

View File

@ -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`