feat: 新增 Issue 批量导出命令
This commit is contained in:
parent
0fdbd29706
commit
5cb871751c
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "已修复"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 转义和非法参数校验。
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
|
|
@ -223,6 +225,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) {
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 "已修复,请验证"
|
||||
|
||||
|
|
|
|||
|
|
@ -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`。
|
||||
Loading…
Reference in New Issue