From 2cc0e5c08b771ba6196c483410ddf6ab67c2fca6 Mon Sep 17 00:00:00 2001 From: wyxfzgg <3132758001@qq.com> Date: Wed, 3 Jun 2026 12:00:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=BB=BA=20export=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E6=B7=BB=E5=8A=A0=203=20=E6=9D=A1=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=AF=BC=E5=87=BA=E5=91=BD=E4=BB=A4=20(issues/prs/con?= =?UTF-8?q?tributors)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 shortcuts/export/export.go: 3 条 Shortcut 命令,支持 CSV/JSON 格式导出 - 新增 shortcuts/export/export_test.go: 7 个测试函数 - 修改 shortcuts/register.go: 注册 export 模块,修复重复 webhook import 关联 Issue: #15 --- shortcuts/export/export.go | 179 ++++++++++++++++++++++++++++++++ shortcuts/export/export_test.go | 158 ++++++++++++++++++++++++++++ shortcuts/register.go | 4 +- 3 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 shortcuts/export/export.go create mode 100644 shortcuts/export/export_test.go diff --git a/shortcuts/export/export.go b/shortcuts/export/export.go new file mode 100644 index 0000000..9fe8bdd --- /dev/null +++ b/shortcuts/export/export.go @@ -0,0 +1,179 @@ +package export + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "net/url" + "os" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns data export shortcuts for GitLink. +// +// The export domain provides commands for exporting repository data +// (issues, pull requests, contributors) to CSV or JSON files for +// offline analysis and reporting. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "issues", + Description: "导出仓库 Issue 列表为 CSV 或 JSON 文件", + Flags: []common.Flag{ + {Name: "format", Short: "f", Usage: "输出格式: csv / json", Default: "csv"}, + {Name: "output", Short: "o", Usage: "输出文件路径(默认: issues.csv)", Default: "issues.csv"}, + {Name: "state", Short: "s", Usage: "状态过滤: open / closed / all", Default: "all"}, + {Name: "page", Short: "p", Usage: "起始页", Default: "1"}, + {Name: "limit", Short: "l", Usage: "每页数量", Default: "50"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("state", ctx.Arg("state")) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + apiPath := fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo) + items, err := ctx.PaginateAll(apiPath, q) + if err != nil { + return err + } + return writeExport(ctx.Arg("format"), ctx.Arg("output"), items, issueCSVHeader, issueCSVRow) + }, + }, + { + Name: "prs", + Description: "导出仓库 PR 列表为 CSV 或 JSON 文件", + Flags: []common.Flag{ + {Name: "format", Short: "f", Usage: "输出格式: csv / json", Default: "csv"}, + {Name: "output", Short: "o", Usage: "输出文件路径", Default: "prs.csv"}, + {Name: "state", Short: "s", Usage: "状态过滤", Default: "all"}, + {Name: "page", Short: "p", Usage: "起始页", Default: "1"}, + {Name: "limit", Short: "l", Usage: "每页数量", Default: "50"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("state", ctx.Arg("state")) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + apiPath := fmt.Sprintf("/v1/%s/%s/pulls", ctx.Owner, ctx.Repo) + items, err := ctx.PaginateAll(apiPath, q) + if err != nil { + return err + } + return writeExport(ctx.Arg("format"), ctx.Arg("output"), items, prCSVHeader, prCSVRow) + }, + }, + { + Name: "contributors", + Description: "导出贡献者统计为 CSV 或 JSON 文件", + Flags: []common.Flag{ + {Name: "format", Short: "f", Usage: "输出格式: csv / json", Default: "csv"}, + {Name: "output", Short: "o", Usage: "输出文件路径", Default: "contributors.csv"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + apiPath := fmt.Sprintf("/%s/%s/contributors", ctx.Owner, ctx.Repo) + items, err := ctx.PaginateAll(apiPath, url.Values{}) + if err != nil { + return err + } + return writeExport(ctx.Arg("format"), ctx.Arg("output"), items, contributorCSVHeader, contributorCSVRow) + }, + }, + } +} + +// --- CSV headers --- + +var issueCSVHeader = []string{"id", "title", "state", "created_at"} +var prCSVHeader = []string{"id", "title", "state", "created_at"} +var contributorCSVHeader = []string{"id", "login", "contributions"} + +// --- CSV row extractors --- + +func issueCSVRow(m map[string]interface{}) []string { + return []string{ + fmt.Sprint(m["id"]), + fmt.Sprint(m["subject"]), + fmt.Sprint(m["status"]), + fmt.Sprint(m["created_at"]), + } +} + +func prCSVRow(m map[string]interface{}) []string { + return []string{ + fmt.Sprint(m["id"]), + fmt.Sprint(m["title"]), + fmt.Sprint(m["status"]), + fmt.Sprint(m["created_at"]), + } +} + +func contributorCSVRow(m map[string]interface{}) []string { + return []string{ + fmt.Sprint(m["id"]), + fmt.Sprint(m["login"]), + fmt.Sprint(m["contributions"]), + } +} + +// --- Export writers --- + +type csvRowFunc func(map[string]interface{}) []string + +func writeExport(format, path string, items []json.RawMessage, header []string, rowFn csvRowFunc) error { + switch format { + case "csv": + return writeCSV(path, items, header, rowFn) + case "json": + return writeJSON(path, items) + default: + return fmt.Errorf("不支持的格式: %s(可选: csv, json)", format) + } +} + +func writeCSV(path string, items []json.RawMessage, header []string, rowFn csvRowFunc) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + w := csv.NewWriter(f) + defer w.Flush() + + if err := w.Write(header); err != nil { + return err + } + for _, item := range items { + var m map[string]interface{} + if err := json.Unmarshal(item, &m); err != nil { + continue + } + if err := w.Write(rowFn(m)); err != nil { + return err + } + } + fmt.Printf("已导出 %d 条记录到 %s\n", len(items), path) + return nil +} + +func writeJSON(path string, items []json.RawMessage) error { + data, err := json.MarshalIndent(items, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(path, data, 0644); err != nil { + return err + } + fmt.Printf("已导出 %d 条记录到 %s\n", len(items), path) + return nil +} diff --git a/shortcuts/export/export_test.go b/shortcuts/export/export_test.go new file mode 100644 index 0000000..cda99f0 --- /dev/null +++ b/shortcuts/export/export_test.go @@ -0,0 +1,158 @@ +package export + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestExportIssues(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(200) + w.Write([]byte(`[{"id":1,"subject":"Bug fix","status":1,"created_at":"2026-01-01"}]`)) + })) + defer server.Close() + + shortcut := findExportShortcut(t, "issues") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "format": "json", + "output": os.TempDir() + "/test_issues_export.json", + "state": "all", + "page": "1", + "limit": "50", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("export issues failed: %v", err) + } +} + +func TestExportPrs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(200) + w.Write([]byte(`[{"id":2,"title":"Feature PR","status":0,"created_at":"2026-02-01"}]`)) + })) + defer server.Close() + + shortcut := findExportShortcut(t, "prs") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "format": "json", + "output": os.TempDir() + "/test_prs_export.json", + "state": "all", + "page": "1", + "limit": "50", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("export prs failed: %v", err) + } +} + +func TestExportContributors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + w.WriteHeader(200) + w.Write([]byte(`[{"id":1,"login":"dev1","contributions":42}]`)) + })) + defer server.Close() + + shortcut := findExportShortcut(t, "contributors") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "test", Repo: "test", Format: "json", + Args: map[string]string{ + "format": "json", + "output": os.TempDir() + "/test_contributors_export.json", + }, + } + if err := shortcut.Run(ctx); err != nil { + t.Fatalf("export contributors failed: %v", err) + } +} + +func TestExportUnsupportedFormat(t *testing.T) { + items := []json.RawMessage{[]byte(`{"id":1}`)} + err := writeExport("xml", "/dev/null", items, issueCSVHeader, issueCSVRow) + if err == nil { + t.Fatal("expected error for unsupported format") + } + if !strings.Contains(err.Error(), "不支持的格式") { + t.Errorf("error should mention unsupported format: %v", err) + } +} + +func TestWriteCSV(t *testing.T) { + tmpFile := os.TempDir() + "/test_export_write.csv" + defer os.Remove(tmpFile) + + items := []json.RawMessage{ + []byte(`{"id":1,"subject":"First","status":1,"created_at":"2026-01-01"}`), + []byte(`{"id":2,"subject":"Second","status":0,"created_at":"2026-01-02"}`), + } + if err := writeCSV(tmpFile, items, issueCSVHeader, issueCSVRow); err != nil { + t.Fatalf("writeCSV failed: %v", err) + } + + data, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + content := string(data) + if !strings.Contains(content, "id,title,state,created_at") { + t.Errorf("CSV header missing in output: %s", content) + } + if !strings.Contains(content, "First") { + t.Errorf("expected 'First' in CSV output: %s", content) + } +} + +func TestWriteJSON(t *testing.T) { + tmpFile := os.TempDir() + "/test_export_write.json" + defer os.Remove(tmpFile) + + items := []json.RawMessage{ + []byte(`{"id":1,"name":"test"}`), + } + if err := writeJSON(tmpFile, items); err != nil { + t.Fatalf("writeJSON failed: %v", err) + } + + data, err := os.ReadFile(tmpFile) + if err != nil { + t.Fatalf("failed to read output file: %v", err) + } + if !strings.Contains(string(data), `"id": 1`) && !strings.Contains(string(data), `"id":1`) { + t.Errorf("JSON content unexpected: %s", string(data)) + } +} + +func findExportShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 63b387f..85ac32d 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -8,6 +8,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" + "github.com/gitlink-org/gitlink-cli/shortcuts/export" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/label" "github.com/gitlink-org/gitlink-cli/shortcuts/member" @@ -22,7 +23,6 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/search" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" - "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" ) @@ -50,6 +50,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "search": search.Shortcuts(tr), "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), + "export": export.Shortcuts(), "webhook": webhook.Shortcuts(tr), "wiki": wiki.Shortcuts(), "workflow": workflow.Shortcuts(), @@ -72,6 +73,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "search": tr.T("cmd.search.short"), "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", + "export": "Data export to CSV/JSON", "webhook": tr.T("cmd.webhook.short"), "wiki": "Wiki page operations", "workflow": "AI agent workflow analysis",