525 lines
14 KiB
Go
525 lines
14 KiB
Go
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
|
|
}
|