feat: 新建 export 模块,添加 3 条数据导出命令 (issues/prs/contributors)

- 新增 shortcuts/export/export.go: 3 条 Shortcut 命令,支持 CSV/JSON 格式导出
- 新增 shortcuts/export/export_test.go: 7 个测试函数
- 修改 shortcuts/register.go: 注册 export 模块,修复重复 webhook import

关联 Issue: #15
This commit is contained in:
wyxfzgg 2026-06-03 12:00:04 +08:00
parent a28a8eae8a
commit 2cc0e5c08b
3 changed files with 340 additions and 1 deletions

179
shortcuts/export/export.go Normal file
View File

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

View File

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

View File

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