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:
parent
a28a8eae8a
commit
2cc0e5c08b
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue