From 0fdbd2970605f41334d1ffbf8d514557effa66bb Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Tue, 9 Jun 2026 21:34:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(issue):=20=E5=AE=8C=E5=96=84=E8=AF=84?= =?UTF-8?q?=E8=AE=BA=E7=AE=A1=E7=90=86=E5=BF=AB=E6=8D=B7=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 13 ++ README.zh-CN.md | 13 ++ doc/changes/issue-comment-management.md | 23 +++ shortcuts/issue/issue.go | 246 ++++++++++++++++++++++-- shortcuts/issue/issue_test.go | 212 ++++++++++++++++++++ 5 files changed, 486 insertions(+), 21 deletions(-) create mode 100644 doc/changes/issue-comment-management.md diff --git a/README.md b/README.md index e5e4318..0f6856b 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,19 @@ gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 - # Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" +# Reply to a comment with attachments and mentions +gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "Thanks, please check the log" --parent-id 456 --reply-id 456 --attachment-ids 7,8 --receivers alice,bob + +# List comments only, or include operation records with --category all +gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment --keyword fixed + +# Update or delete a comment +gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 456 -b "Updated comment" +gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 456 + +# List replies under a comment +gitlink-cli issue +comment-replies --owner Gitlink --repo forgeplus --number 123 --comment-id 456 + # List issue assigners gitlink-cli issue +assigners --owner Gitlink --repo forgeplus diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..ca1d479 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -367,6 +367,19 @@ gitlink-cli issue +batch-delete --owner Gitlink --repo forgeplus --ids 101,102 - # 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" +# 回复评论并携带附件和 @ 用户 +gitlink-cli issue +comment --owner Gitlink --repo forgeplus --number 123 -b "请查看日志" --parent-id 456 --reply-id 456 --attachment-ids 7,8 --receivers alice,bob + +# 列出评论;需要操作记录时可传 --category all +gitlink-cli issue +comments --owner Gitlink --repo forgeplus --number 123 --category comment --keyword fixed + +# 更新或删除评论 +gitlink-cli issue +comment-update --owner Gitlink --repo forgeplus --number 123 --comment-id 456 -b "更新后的评论" +gitlink-cli issue +comment-delete --owner Gitlink --repo forgeplus --number 123 --comment-id 456 + +# 列出评论下的回复 +gitlink-cli issue +comment-replies --owner Gitlink --repo forgeplus --number 123 --comment-id 456 + # 列出 Issue 负责人 gitlink-cli issue +assigners --owner Gitlink --repo forgeplus diff --git a/doc/changes/issue-comment-management.md b/doc/changes/issue-comment-management.md new file mode 100644 index 0000000..19db35d --- /dev/null +++ b/doc/changes/issue-comment-management.md @@ -0,0 +1,23 @@ +# Issue comment management shortcuts + +This change expands issue comment support from create-only to a full comment +management workflow. + +- `issue +comment` now supports threaded replies through `--parent-id` and + `--reply-id`, attachment IDs, and mentioned users. +- `issue +comments` lists comments and operation records with category, + keyword, sorting, and pagination filters. +- `issue +comment-update` and `issue +comment-delete` edit or remove existing + issue comments. +- `issue +comment-replies` lists child comments for threaded conversations. + +The implementation keeps the existing `issue +comment -b` behavior compatible +and adds validation for numeric comment, parent, reply, and attachment IDs +before any API request is sent. + +Verification: + +- `go test ./shortcuts/issue` +- `go test ./shortcuts` +- `go build ./...` +- `git diff --check` diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index b19027e..98e3450 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -274,28 +274,55 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { Description: tr.T("cmd.issue.comment.short"), Flags: appendIssueNumberFlags( common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, + common.Flag{Name: "parent-id", Usage: "Parent comment ID for a threaded reply"}, + common.Flag{Name: "reply-id", Usage: "Comment ID being replied to"}, + common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, + common.Flag{Name: "receivers", Usage: "Comma-separated user logins to mention"}, ), - Run: func(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } - number, err := issueNumberArg(ctx) - if err != nil { - return err - } - body, err := ctx.RequireArg("body") - if err != nil { - return err - } - payload := map[string]interface{}{ - "notes": body, - } - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload) - if err != nil { - return err - } - return ctx.Output(env) - }, + Run: runIssueComment, + }, + { + Name: "comments", + Description: "List issue comments and operation records", + Flags: appendIssueNumberFlags( + common.Flag{Name: "category", Short: "c", Usage: "Filter by all, comment, or operate", Default: "comment"}, + common.Flag{Name: "keyword", Short: "k", Usage: "Search comment content"}, + common.Flag{Name: "sort-by", Usage: "Sort field: created_on or updated_on"}, + common.Flag{Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + common.Flag{Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + common.Flag{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + ), + Run: runIssueComments, + }, + { + Name: "comment-update", + Description: "Update an issue comment", + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Usage: "Comment ID", Required: true}, + common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, + common.Flag{Name: "attachment-ids", Usage: "Comma-separated attachment IDs"}, + common.Flag{Name: "receivers", Usage: "Comma-separated user logins to mention"}, + ), + Run: runIssueCommentUpdate, + }, + { + Name: "comment-delete", + Description: "Delete an issue comment", + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Usage: "Comment ID", Required: true}, + ), + Run: runIssueCommentDelete, + }, + { + Name: "comment-replies", + Description: "List replies under an issue comment", + Flags: appendIssueNumberFlags( + common.Flag{Name: "comment-id", Usage: "Parent comment ID", Required: true}, + common.Flag{Name: "keyword", Short: "k", Usage: "Search reply content"}, + common.Flag{Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + common.Flag{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + ), + Run: runIssueCommentReplies, }, { Name: "assigners", @@ -445,6 +472,183 @@ func issueNumberArg(ctx *common.RuntimeContext) (string, error) { return "", fmt.Errorf("required flag --number is missing (or use --id as a compatibility alias)") } +func runIssueComment(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + body, err := ctx.RequireArg("body") + if err != nil { + return err + } + payload, err := issueCommentPayload(ctx, body, true) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", issueJournalPath(ctx, number), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runIssueComments(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := issueNumberArg(ctx) + if err != nil { + return err + } + q := url.Values{} + setIssueQueryIfPresent(q, "category", ctx.Arg("category")) + setIssueQueryIfPresent(q, "keyword", ctx.Arg("keyword")) + setIssueQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) + setIssueQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) + setIssueQueryIfPresent(q, "page", ctx.Arg("page")) + setIssueQueryIfPresent(q, "limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", issueJournalPath(ctx, number), q) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runIssueCommentUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, commentID, err := issueCommentTarget(ctx) + if err != nil { + return err + } + body, err := ctx.RequireArg("body") + if err != nil { + return err + } + payload, err := issueCommentPayload(ctx, body, false) + if err != nil { + return err + } + env, err := ctx.CallAPI("PATCH", issueJournalItemPath(ctx, number, commentID), payload) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runIssueCommentDelete(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, commentID, err := issueCommentTarget(ctx) + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", issueJournalItemPath(ctx, number, commentID), nil) + if err != nil { + return err + } + return ctx.Output(env) +} + +func runIssueCommentReplies(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, commentID, err := issueCommentTarget(ctx) + if err != nil { + return err + } + q := url.Values{} + setIssueQueryIfPresent(q, "keyword", ctx.Arg("keyword")) + setIssueQueryIfPresent(q, "page", ctx.Arg("page")) + setIssueQueryIfPresent(q, "limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", issueJournalItemPath(ctx, number, commentID)+"/children_journals", q) + if err != nil { + return err + } + return ctx.Output(env) +} + +func issueJournalPath(ctx *common.RuntimeContext, number string) string { + return fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), url.PathEscape(number)) +} + +func issueJournalItemPath(ctx *common.RuntimeContext, number, commentID string) string { + return fmt.Sprintf("%s/%s", issueJournalPath(ctx, number), url.PathEscape(commentID)) +} + +func issueCommentTarget(ctx *common.RuntimeContext) (string, string, error) { + number, err := issueNumberArg(ctx) + if err != nil { + return "", "", err + } + commentID, err := ctx.RequireArg("comment-id") + if err != nil { + return "", "", err + } + if _, err := parseIssueID(commentID, "comment-id"); err != nil { + return "", "", err + } + return number, strings.TrimSpace(commentID), nil +} + +func issueCommentPayload(ctx *common.RuntimeContext, body string, includeThreading bool) (map[string]interface{}, error) { + payload := map[string]interface{}{"notes": body} + if includeThreading { + if parentID := ctx.Arg("parent-id"); parentID != "" { + id, err := parseIssueID(parentID, "parent-id") + if err != nil { + return nil, err + } + payload["parent_id"] = id + } + if replyID := ctx.Arg("reply-id"); replyID != "" { + id, err := parseIssueID(replyID, "reply-id") + if err != nil { + return nil, err + } + payload["reply_id"] = id + } + } + if attachmentIDs := ctx.Arg("attachment-ids"); attachmentIDs != "" { + ids, err := parseIssueIDList(attachmentIDs, "attachment-ids") + if err != nil { + return nil, err + } + payload["attachment_ids"] = ids + } + if receivers := parseIssueStringList(ctx.Arg("receivers")); len(receivers) > 0 { + payload["receivers_login"] = receivers + } + return payload, nil +} + +func setIssueQueryIfPresent(q url.Values, name, value string) { + if strings.TrimSpace(value) != "" { + q.Set(name, strings.TrimSpace(value)) + } +} + +func parseIssueStringList(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, part := range parts { + item := strings.TrimSpace(part) + if item == "" || seen[item] { + continue + } + seen[item] = true + result = append(result, item) + } + return result +} + // normalizeIssueListIDs adds "number" (project_issues_index) and renames // "id" to "database_id" so the user-facing output uses the project-level // issue number, not the global database primary key. diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 48be057..2c92c36 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -94,6 +94,58 @@ func assertNumberSlice(t *testing.T, got interface{}, want []float64) { } } +func assertStringSliceEqual(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func assertNumberSliceEqual(t *testing.T, got, want []int) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func interfaceSliceToStrings(value interface{}) []string { + items, ok := value.([]interface{}) + if !ok { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} + +func interfaceSliceToInts(value interface{}) []int { + items, ok := value.([]interface{}) + if !ok { + return nil + } + out := make([]int, 0, len(items)) + for _, item := range items { + if n, ok := item.(float64); ok { + out = append(out, int(n)) + } + } + return out +} + // --- list --- func TestIssueList(t *testing.T) { @@ -717,6 +769,136 @@ func TestIssueCommentAcceptsIDAlias(t *testing.T) { assertEqual(t, commentPayload["notes"], "Fixed") } +func TestIssueCommentSupportsThreadingAttachmentsAndReceivers(t *testing.T) { + var commentPayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + commentPayload = decodeJSON(t, r) + writeJSON(t, w, commentPayload) + }) + defer server.Close() + + err := runShortcut(t, server, "comment", map[string]string{ + "number": "42", + "body": "Reply with context", + "parent-id": "10", + "reply-id": "11", + "attachment-ids": "5, 6", + "receivers": "alice, bob, alice", + }) + if err != nil { + t.Fatalf("comment shortcut failed: %v", err) + } + assertEqual(t, commentPayload["notes"], "Reply with context") + assertEqual(t, commentPayload["parent_id"], float64(10)) + assertEqual(t, commentPayload["reply_id"], float64(11)) + assertStringSliceEqual(t, interfaceSliceToStrings(commentPayload["receivers_login"]), []string{"alice", "bob"}) + assertNumberSliceEqual(t, interfaceSliceToInts(commentPayload["attachment_ids"]), []int{5, 6}) +} + +func TestIssueCommentsListSendsFilters(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + q := r.URL.Query() + assertEqual(t, q.Get("category"), "all") + assertEqual(t, q.Get("keyword"), "panic") + assertEqual(t, q.Get("sort_by"), "updated_on") + assertEqual(t, q.Get("sort_direction"), "desc") + assertEqual(t, q.Get("page"), "2") + assertEqual(t, q.Get("limit"), "50") + writeJSON(t, w, map[string]interface{}{ + "total_count": float64(1), + "journals": []interface{}{ + map[string]interface{}{"id": float64(7), "notes": "panic fixed"}, + }, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "comments", map[string]string{ + "number": "42", + "category": "all", + "keyword": "panic", + "sort-by": "updated_on", + "sort-direction": "desc", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("comments shortcut failed: %v", err) + } +} + +func TestIssueCommentUpdate(t *testing.T) { + var commentPayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + commentPayload = decodeJSON(t, r) + writeJSON(t, w, commentPayload) + }) + defer server.Close() + + err := runShortcut(t, server, "comment-update", map[string]string{ + "number": "42", + "comment-id": "9", + "body": "Updated", + "attachment-ids": "8", + "receivers": "alice", + }) + if err != nil { + t.Fatalf("comment-update failed: %v", err) + } + assertEqual(t, commentPayload["notes"], "Updated") + assertNumberSliceEqual(t, interfaceSliceToInts(commentPayload["attachment_ids"]), []int{8}) + assertStringSliceEqual(t, interfaceSliceToStrings(commentPayload["receivers_login"]), []string{"alice"}) +} + +func TestIssueCommentDelete(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"status": float64(0), "message": "success"}) + }) + defer server.Close() + + err := runShortcut(t, server, "comment-delete", map[string]string{"number": "42", "comment-id": "9"}) + if err != nil { + t.Fatalf("comment-delete failed: %v", err) + } +} + +func TestIssueCommentReplies(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42/journals/9/children_journals.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + q := r.URL.Query() + assertEqual(t, q.Get("keyword"), "thanks") + assertEqual(t, q.Get("page"), "3") + assertEqual(t, q.Get("limit"), "10") + writeJSON(t, w, map[string]interface{}{"total_count": float64(0), "journals": []interface{}{}}) + }) + defer server.Close() + + err := runShortcut(t, server, "comment-replies", map[string]string{ + "number": "42", + "comment-id": "9", + "keyword": "thanks", + "page": "3", + "limit": "10", + }) + if err != nil { + t.Fatalf("comment-replies failed: %v", err) + } +} + func TestIssueCommentMissingBody(t *testing.T) { server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") @@ -743,6 +925,10 @@ func TestIssueNumberOrIDIsRequired(t *testing.T) { {name: "close", args: map[string]string{}}, {name: "update", args: map[string]string{"title": "New title"}}, {name: "comment", args: map[string]string{"body": "Fixed"}}, + {name: "comments", args: map[string]string{}}, + {name: "comment-update", args: map[string]string{"comment-id": "9", "body": "Updated"}}, + {name: "comment-delete", args: map[string]string{"comment-id": "9"}}, + {name: "comment-replies", args: map[string]string{"comment-id": "9"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -757,6 +943,32 @@ func TestIssueNumberOrIDIsRequired(t *testing.T) { } } +func TestIssueCommentRejectsInvalidIDs(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid IDs should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + cases := []struct { + name string + cmd string + args map[string]string + }{ + {name: "bad parent", cmd: "comment", args: map[string]string{"number": "42", "body": "x", "parent-id": "abc"}}, + {name: "bad attachment", cmd: "comment", args: map[string]string{"number": "42", "body": "x", "attachment-ids": "1,,"}}, + {name: "bad update comment", cmd: "comment-update", args: map[string]string{"number": "42", "comment-id": "0", "body": "x"}}, + {name: "bad delete comment", cmd: "comment-delete", args: map[string]string{"number": "42", "comment-id": "-1"}}, + {name: "bad replies comment", cmd: "comment-replies", args: map[string]string{"number": "42", "comment-id": "abc"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := runShortcut(t, server, tc.cmd, tc.args); err == nil { + t.Fatal("expected validation error") + } + }) + } +} + // --- batch-close --- func TestBatchClosePreservesCurrentDescription(t *testing.T) { From 5cb871751c8b4306c6ea0818f1560dcac24a09f8 Mon Sep 17 00:00:00 2001 From: Mengz <2567587994@qq.com> Date: Mon, 8 Jun 2026 20:16:38 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Issue=20?= =?UTF-8?q?=E6=89=B9=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 0f6856b..f57393d 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 ca1d479..41729dc 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 98e3450..60f5d1e 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 2c92c36..70168ea 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" @@ -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) { 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`。